launcher: made search bar + mode switcher
This commit is contained in:
parent
e2a0c19267
commit
73e7c08b8c
4 changed files with 943 additions and 769 deletions
|
|
@ -4,13 +4,18 @@
|
||||||
|
|
||||||
@mixin launcher($mode, $colour) {
|
@mixin launcher($mode, $colour) {
|
||||||
&.#{$mode} {
|
&.#{$mode} {
|
||||||
label.icon {
|
.search-bar {
|
||||||
color: $colour;
|
.mode {
|
||||||
|
@include lib.border($colour, $width: 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.entry {
|
||||||
background-color: $colour;
|
border-bottom: lib.s(2) solid $colour;
|
||||||
margin: lib.s(5) 0;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switcher .mode.selected {
|
||||||
|
border-top: lib.s(2) solid $colour;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -18,12 +23,16 @@
|
||||||
.launcher {
|
.launcher {
|
||||||
@include lib.rounded(10);
|
@include lib.rounded(10);
|
||||||
@include lib.shadow;
|
@include lib.shadow;
|
||||||
@include lib.element-decel;
|
@include font.mono;
|
||||||
@include font.main;
|
|
||||||
|
|
||||||
background-color: scheme.$mantle;
|
background-color: scheme.$base;
|
||||||
color: scheme.$text;
|
color: scheme.$text;
|
||||||
padding: lib.s(10) lib.s(14);
|
padding: lib.s(14);
|
||||||
|
font-size: lib.s(16);
|
||||||
|
min-width: lib.s(700);
|
||||||
|
min-height: lib.s(420);
|
||||||
|
|
||||||
|
@include lib.spacing(20, true);
|
||||||
|
|
||||||
@include launcher(apps, scheme.$sapphire);
|
@include launcher(apps, scheme.$sapphire);
|
||||||
@include launcher(files, scheme.$peach);
|
@include launcher(files, scheme.$peach);
|
||||||
|
|
@ -31,18 +40,21 @@
|
||||||
@include launcher(windows, scheme.$teal);
|
@include launcher(windows, scheme.$teal);
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
margin-bottom: lib.s(5);
|
@include lib.spacing(10);
|
||||||
font-size: lib.s(16);
|
|
||||||
|
|
||||||
@include lib.spacing;
|
.mode {
|
||||||
|
@include lib.rounded(5);
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
.icon {
|
padding: lib.s(5);
|
||||||
font-size: lib.s(18);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.mode-switcher .mode {
|
||||||
color: scheme.$subtext0;
|
@include lib.element-decel;
|
||||||
}
|
|
||||||
|
border-top: lib.s(2) solid transparent;
|
||||||
|
padding-top: lib.s(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result {
|
.result {
|
||||||
|
|
|
||||||
|
|
@ -1,765 +1,91 @@
|
||||||
import { Apps } from "@/services/apps";
|
|
||||||
import MathService, { type HistoryItem } from "@/services/math";
|
|
||||||
import { getAppCategoryIcon } from "@/utils/icons";
|
|
||||||
import { launch, notify } from "@/utils/system";
|
|
||||||
import type { Client } from "@/utils/types";
|
|
||||||
import { MenuItem, setupCustomTooltip } from "@/utils/widgets";
|
|
||||||
import PopupWindow from "@/widgets/popupwindow";
|
import PopupWindow from "@/widgets/popupwindow";
|
||||||
import { bind, execAsync, Gio, GLib, readFile, register, timeout, Variable } from "astal";
|
import { bind, register, Variable } from "astal";
|
||||||
import { App, Astal, Gtk, Widget } from "astal/gtk3";
|
import { Astal, Gtk, Widget } from "astal/gtk3";
|
||||||
import { launcher as config } from "config";
|
|
||||||
import fuzzysort from "fuzzysort";
|
|
||||||
import type AstalApps from "gi://AstalApps";
|
|
||||||
import AstalHyprland from "gi://AstalHyprland";
|
|
||||||
|
|
||||||
type Mode = "apps" | "files" | "math" | "windows";
|
type Mode = "apps" | "files" | "math" | "windows";
|
||||||
|
|
||||||
interface Subcommand {
|
interface ModeContent {
|
||||||
icon: string;
|
updateContent(search: string): void;
|
||||||
name: string;
|
handleActivate(search: string): void;
|
||||||
description: string;
|
|
||||||
command: (...args: string[]) => boolean | void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getIconFromMode = (mode: Mode) => {
|
@register()
|
||||||
switch (mode) {
|
class Apps extends Widget.Box implements ModeContent {
|
||||||
case "apps":
|
constructor() {
|
||||||
return "apps";
|
super({ name: "apps" });
|
||||||
case "files":
|
|
||||||
return "folder";
|
|
||||||
case "math":
|
|
||||||
return "calculate";
|
|
||||||
case "windows":
|
|
||||||
return "select_window";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEmptyTextFromMode = (mode: Mode) => {
|
|
||||||
switch (mode) {
|
|
||||||
case "apps":
|
|
||||||
return "No apps found";
|
|
||||||
case "files":
|
|
||||||
return GLib.find_program_in_path("fd") === null ? "File search requires `fd`" : "No files found";
|
|
||||||
case "math":
|
|
||||||
return "Type an expression";
|
|
||||||
case "windows":
|
|
||||||
return "No windows found";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const limitLength = <T,>(arr: T[], cfg: { maxResults: Variable<number> }) =>
|
|
||||||
cfg.maxResults.get() > 0 && arr.length > cfg.maxResults.get() ? arr.slice(0, cfg.maxResults.get()) : arr;
|
|
||||||
|
|
||||||
const close = (self: JSX.Element) => {
|
|
||||||
const toplevel = self.get_toplevel();
|
|
||||||
if (toplevel instanceof Widget.Window) toplevel.hide();
|
|
||||||
};
|
|
||||||
|
|
||||||
const launchAndClose = (self: JSX.Element, astalApp: AstalApps.Application) => {
|
|
||||||
close(self);
|
|
||||||
launch(astalApp);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFileAndClose = (self: JSX.Element, path: string) => {
|
|
||||||
close(self);
|
|
||||||
execAsync([
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
`dbus-send --session --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems array:string:"file://${path}" string:"" || xdg-open "${path}"`,
|
|
||||||
]).catch(console.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PinnedApp = (names: string[]) => {
|
|
||||||
let app: Gio.DesktopAppInfo | null = null;
|
|
||||||
let astalApp: AstalApps.Application | undefined;
|
|
||||||
for (const name of names) {
|
|
||||||
app = Gio.DesktopAppInfo.new(`${name}.desktop`);
|
|
||||||
if (app) {
|
|
||||||
astalApp = Apps.get_list().find(a => a.entry === `${name}.desktop`);
|
|
||||||
if (app.get_icon() && astalApp) break;
|
|
||||||
else app = null; // Set app to null if no icon or matching AstalApps#Application
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app) {
|
updateContent(search: string): void {
|
||||||
console.error(`Launcher - Unable to find app for "${names.join(", ")}"`);
|
throw new Error("Method not implemented.");
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const menu = new Gtk.Menu();
|
handleActivate(search: string): void {
|
||||||
menu.append(new MenuItem({ label: "Launch", onActivate: () => launchAndClose(widget, astalApp!) }));
|
throw new Error("Method not implemented.");
|
||||||
|
|
||||||
if (app.list_actions().length > 0) menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
|
||||||
app.list_actions().forEach(action => {
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: app.get_action_name(action),
|
|
||||||
onActivate: () => {
|
|
||||||
close(widget); // Pass result cause menu is its own toplevel
|
|
||||||
app.launch_action(action, null);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const widget = (
|
|
||||||
<button
|
|
||||||
className="pinned-app result"
|
|
||||||
cursor="pointer"
|
|
||||||
onClicked={self => launchAndClose(self, astalApp!)}
|
|
||||||
onClick={(_, event) => event.button === Astal.MouseButton.SECONDARY && menu.popup_at_pointer(null)}
|
|
||||||
setup={self => setupCustomTooltip(self, app.get_display_name())}
|
|
||||||
onDestroy={() => menu.destroy()}
|
|
||||||
>
|
|
||||||
<icon gicon={app.get_icon()!} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
return widget;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PinnedApps = () => <box homogeneous>{bind(config.apps.pins).as(p => p.map(PinnedApp))}</box>;
|
|
||||||
|
|
||||||
const SearchEntry = ({ entry }: { entry: Widget.Entry }) => (
|
|
||||||
<stack
|
|
||||||
hexpand
|
|
||||||
transitionType={Gtk.StackTransitionType.CROSSFADE}
|
|
||||||
transitionDuration={150}
|
|
||||||
setup={self =>
|
|
||||||
self.hook(entry, "notify::text-length", () =>
|
|
||||||
// Timeout to avoid flickering when replacing entire text (cause it'll set len to 0 then back to > 0)
|
|
||||||
timeout(1, () => (self.shown = entry.textLength > 0 ? "entry" : "placeholder"))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
}
|
||||||
<label name="placeholder" className="placeholder" xalign={0} label='Type ">" for subcommands' />
|
|
||||||
{entry}
|
|
||||||
</stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Result = ({
|
@register()
|
||||||
icon,
|
class Files extends Widget.Box implements ModeContent {
|
||||||
materialIcon,
|
constructor() {
|
||||||
label,
|
super({ name: "files" });
|
||||||
sublabel,
|
|
||||||
tooltip,
|
|
||||||
italic,
|
|
||||||
onClicked,
|
|
||||||
onSecondaryClick,
|
|
||||||
onMiddleClick,
|
|
||||||
onDestroy,
|
|
||||||
}: {
|
|
||||||
icon?: string | Gio.Icon | null;
|
|
||||||
materialIcon?: string;
|
|
||||||
label: string;
|
|
||||||
sublabel?: string;
|
|
||||||
tooltip?: string;
|
|
||||||
italic?: boolean;
|
|
||||||
onClicked: (self: Widget.Button) => void;
|
|
||||||
onSecondaryClick?: (self: Widget.Button) => void;
|
|
||||||
onMiddleClick?: (self: Widget.Button) => void;
|
|
||||||
onDestroy?: () => void;
|
|
||||||
}) => (
|
|
||||||
<button
|
|
||||||
className={`result ${italic ? "italic" : ""}`}
|
|
||||||
cursor="pointer"
|
|
||||||
onClicked={onClicked}
|
|
||||||
onClick={(self, event) => {
|
|
||||||
if (event.button === Astal.MouseButton.SECONDARY) onSecondaryClick?.(self);
|
|
||||||
else if (event.button === Astal.MouseButton.MIDDLE) onMiddleClick?.(self);
|
|
||||||
}}
|
|
||||||
onDestroy={onDestroy}
|
|
||||||
setup={self => tooltip && setupCustomTooltip(self, tooltip)}
|
|
||||||
>
|
|
||||||
<box>
|
|
||||||
{icon &&
|
|
||||||
(typeof icon === "string" ? (
|
|
||||||
Astal.Icon.lookup_icon(icon) && <icon valign={Gtk.Align.START} className="icon" icon={icon} />
|
|
||||||
) : (
|
|
||||||
<icon valign={Gtk.Align.START} className="icon" gicon={icon} />
|
|
||||||
))}
|
|
||||||
{materialIcon && (!icon || (typeof icon === "string" && !Astal.Icon.lookup_icon(icon))) && (
|
|
||||||
<label valign={Gtk.Align.START} className="icon" label={materialIcon} />
|
|
||||||
)}
|
|
||||||
{sublabel ? (
|
|
||||||
<box vertical valign={Gtk.Align.CENTER} className="has-sublabel">
|
|
||||||
<label hexpand truncate maxWidthChars={1} xalign={0} label={label} />
|
|
||||||
<label hexpand truncate maxWidthChars={1} className="sublabel" xalign={0} label={sublabel} />
|
|
||||||
</box>
|
|
||||||
) : (
|
|
||||||
<label hexpand truncate maxWidthChars={1} xalign={0} label={label} />
|
|
||||||
)}
|
|
||||||
</box>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SubcommandResult = ({
|
|
||||||
entry,
|
|
||||||
subcommand,
|
|
||||||
args,
|
|
||||||
}: {
|
|
||||||
entry: Widget.Entry;
|
|
||||||
subcommand: Subcommand;
|
|
||||||
args: string[];
|
|
||||||
}) => (
|
|
||||||
<Result
|
|
||||||
materialIcon={subcommand.icon}
|
|
||||||
label={subcommand.name}
|
|
||||||
sublabel={subcommand.description}
|
|
||||||
onClicked={() => {
|
|
||||||
if (!subcommand.command(...args)) entry.set_text("");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const AppResult = ({ app }: { app: AstalApps.Application }) => {
|
|
||||||
const menu = new Gtk.Menu();
|
|
||||||
menu.append(new MenuItem({ label: "Launch", onActivate: () => launchAndClose(result, app) }));
|
|
||||||
|
|
||||||
const appInfo = app.app as Gio.DesktopAppInfo;
|
|
||||||
if (appInfo.list_actions().length > 0) menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
|
||||||
appInfo.list_actions().forEach(action => {
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: appInfo.get_action_name(action),
|
|
||||||
onActivate: () => {
|
|
||||||
close(result); // Pass result cause menu is its own toplevel
|
|
||||||
appInfo.launch_action(action, null);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = (
|
|
||||||
<Result
|
|
||||||
icon={app.iconName}
|
|
||||||
materialIcon={getAppCategoryIcon(app)}
|
|
||||||
label={app.name}
|
|
||||||
sublabel={app.description}
|
|
||||||
onClicked={self => launchAndClose(self, app)}
|
|
||||||
onSecondaryClick={() => menu.popup_at_pointer(null)}
|
|
||||||
onDestroy={() => menu.destroy()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MathResult = ({ math, isHistory, entry }: { math: HistoryItem; isHistory?: boolean; entry: Widget.Entry }) => (
|
|
||||||
<Result
|
|
||||||
materialIcon={math.icon}
|
|
||||||
label={math.equation}
|
|
||||||
sublabel={math.result}
|
|
||||||
onClicked={() => {
|
|
||||||
if (isHistory) {
|
|
||||||
MathService.get_default().select(math);
|
|
||||||
entry.set_text(math.equation);
|
|
||||||
entry.grab_focus();
|
|
||||||
entry.set_position(-1);
|
|
||||||
} else {
|
|
||||||
execAsync(`wl-copy -- ${math.result}`).catch(console.error);
|
|
||||||
entry.set_text("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FileResult = ({ path }: { path: string }) => {
|
|
||||||
const menu = new Gtk.Menu();
|
|
||||||
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: "Open",
|
|
||||||
onActivate: () => {
|
|
||||||
execAsync(["xdg-open", path]).catch(console.error);
|
|
||||||
close(result);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
|
||||||
menu.append(new MenuItem({ label: "Open in file manager", onActivate: () => openFileAndClose(result, path) }));
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: "Open containing folder in terminal",
|
|
||||||
onActivate: () => {
|
|
||||||
execAsync(`uwsm app -- foot -D ${path.slice(0, path.lastIndexOf("/"))}`).catch(console.error);
|
|
||||||
close(result);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = (
|
|
||||||
<Result
|
|
||||||
icon={Gio.File.new_for_path(path)
|
|
||||||
.query_info(Gio.FILE_ATTRIBUTE_STANDARD_ICON, Gio.FileQueryInfoFlags.NONE, null)
|
|
||||||
.get_icon()}
|
|
||||||
label={path.split("/").pop()!}
|
|
||||||
sublabel={path.startsWith(HOME) ? "~" + path.slice(HOME.length) : path}
|
|
||||||
onClicked={self => openFileAndClose(self, path)}
|
|
||||||
onSecondaryClick={() => menu.popup_at_pointer(null)}
|
|
||||||
onDestroy={() => menu.destroy()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const WindowResult = ({ client, reload }: { client: Client; reload: () => void }) => {
|
|
||||||
const hyprland = AstalHyprland.get_default();
|
|
||||||
const app = Apps.fuzzy_query(client.class)[0];
|
|
||||||
const astalClient = hyprland.get_client(client.address);
|
|
||||||
|
|
||||||
const menu = new Gtk.Menu();
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: "Focus",
|
|
||||||
onActivate: () => {
|
|
||||||
close(result);
|
|
||||||
astalClient?.focus();
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
|
||||||
|
|
||||||
const addSubmenus = (silent: boolean) => {
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: `Move to workspace${silent ? " (silent)" : ""}`,
|
|
||||||
setup: self => {
|
|
||||||
const submenu = new Gtk.Menu();
|
|
||||||
const start = Math.floor((hyprland.focusedWorkspace.id - 1) / 10) * 10;
|
|
||||||
for (let i = 1; i <= 10; i++)
|
|
||||||
submenu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: `Workspace ${start + i}`,
|
|
||||||
onActivate: () => {
|
|
||||||
if (!silent) close(result);
|
|
||||||
hyprland.dispatch(
|
|
||||||
`movetoworkspace${silent ? "silent" : ""}`,
|
|
||||||
`${start + i},address:${client.address}`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
self.set_submenu(submenu);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: `Move to special workspace${silent ? " (silent)" : ""}`,
|
|
||||||
setup: self => {
|
|
||||||
const submenu = new Gtk.Menu();
|
|
||||||
submenu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: "special",
|
|
||||||
onActivate: () => {
|
|
||||||
if (!silent) close(result);
|
|
||||||
hyprland.dispatch(
|
|
||||||
`movetoworkspace${silent ? "silent" : ""}`,
|
|
||||||
`special,address:${client.address}`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
hyprland.message_async("j/workspaces", (_, res) => {
|
|
||||||
const workspaces = JSON.parse(hyprland.message_finish(res));
|
|
||||||
for (const workspace of workspaces)
|
|
||||||
if (workspace.name.startsWith("special:"))
|
|
||||||
submenu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: workspace.name.slice(8),
|
|
||||||
onActivate: () => {
|
|
||||||
if (!silent) close(result);
|
|
||||||
hyprland.dispatch(
|
|
||||||
`movetoworkspace${silent ? "silent" : ""}`,
|
|
||||||
`${workspace.name},address:${client.address}`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
self.set_submenu(submenu);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
addSubmenus(false);
|
|
||||||
addSubmenus(true);
|
|
||||||
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: "Copy property",
|
|
||||||
setup: self => {
|
|
||||||
const addSubmenu = (self: MenuItem, obj: object) => {
|
|
||||||
const submenu = new Gtk.Menu();
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(obj))
|
|
||||||
if (typeof value === "object") submenu.append(addSubmenu(new MenuItem({ label: key }), value));
|
|
||||||
else
|
|
||||||
submenu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: key,
|
|
||||||
onActivate: () => {
|
|
||||||
close(result);
|
|
||||||
execAsync(`wl-copy -- ${value}`).catch(console.error);
|
|
||||||
},
|
|
||||||
tooltipText: String(value), // Cannot use custom tooltip cause it'll be below menu
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
self.set_submenu(submenu);
|
|
||||||
return self;
|
|
||||||
};
|
|
||||||
addSubmenu(self, client);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
|
||||||
menu.append(
|
|
||||||
new MenuItem({
|
|
||||||
label: "Kill",
|
|
||||||
onActivate: () => {
|
|
||||||
astalClient?.kill();
|
|
||||||
const id = hyprland.connect("client-removed", () => {
|
|
||||||
hyprland.disconnect(id);
|
|
||||||
reload();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const classOrTitle = (prop: "Class" | "Title", header = true) => {
|
|
||||||
const lower = prop.toLowerCase() as "class" | "title";
|
|
||||||
return (
|
|
||||||
(header ? `${prop}: ` : "") +
|
|
||||||
(client[lower] || (client[`initial${prop}`] ? `${client[`initial${prop}`]} (initial)` : `No ${lower}`))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const workspace = (header = false) =>
|
|
||||||
(header ? "Workspace: " : "") + `${client.workspace.name} (${client.workspace.id})`;
|
|
||||||
const prop = (prop: keyof typeof client, header?: string) =>
|
|
||||||
`${header ?? prop.slice(0, 1).toUpperCase() + prop.slice(1)}: ${client[prop]}`;
|
|
||||||
|
|
||||||
const result = (
|
|
||||||
<Result
|
|
||||||
icon={app.iconName}
|
|
||||||
materialIcon={getAppCategoryIcon(app)}
|
|
||||||
label={
|
|
||||||
classOrTitle("Title", false).length < 5
|
|
||||||
? `${classOrTitle("Class", false)}: ${classOrTitle("Title", false)}`
|
|
||||||
: classOrTitle("Title", false)
|
|
||||||
}
|
|
||||||
sublabel={`Workspace ${workspace()} on ${hyprland.get_monitor(client.monitor).name}`}
|
|
||||||
tooltip={`${classOrTitle("Title")}\n${classOrTitle("Class")}\n${prop("address")}\n${workspace(
|
|
||||||
true
|
|
||||||
)}\n${prop("pid", "Process ID")}\n${prop("floating")}\n${prop("inhibitingIdle", "Inhibiting idle")}`}
|
|
||||||
italic={client.xwayland}
|
|
||||||
onClicked={self => {
|
|
||||||
close(self);
|
|
||||||
astalClient?.focus();
|
|
||||||
}}
|
|
||||||
onSecondaryClick={() => menu.popup_at_pointer(null)}
|
|
||||||
onMiddleClick={() => {
|
|
||||||
astalClient?.kill();
|
|
||||||
const id = hyprland.connect("client-removed", () => {
|
|
||||||
hyprland.disconnect(id);
|
|
||||||
reload();
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onDestroy={() => menu.destroy()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> }) => {
|
|
||||||
const empty = Variable(true);
|
|
||||||
|
|
||||||
const scrollable = (
|
|
||||||
<scrollable name="list" hscroll={Gtk.PolicyType.NEVER}>
|
|
||||||
<box
|
|
||||||
vertical
|
|
||||||
setup={self => {
|
|
||||||
const subcommands: Record<string, Subcommand> = {
|
|
||||||
apps: {
|
|
||||||
icon: "apps",
|
|
||||||
name: "Apps",
|
|
||||||
description: "Search for apps",
|
|
||||||
command: () => mode.set("apps"),
|
|
||||||
},
|
|
||||||
files: {
|
|
||||||
icon: "folder",
|
|
||||||
name: "Files",
|
|
||||||
description: "Search for files",
|
|
||||||
command: () => mode.set("files"),
|
|
||||||
},
|
|
||||||
math: {
|
|
||||||
icon: "calculate",
|
|
||||||
name: "Math",
|
|
||||||
description: "Do math calculations",
|
|
||||||
command: () => mode.set("math"),
|
|
||||||
},
|
|
||||||
windows: {
|
|
||||||
icon: "select_window",
|
|
||||||
name: "Windows",
|
|
||||||
description: "Manage open windows",
|
|
||||||
command: () => mode.set("windows"),
|
|
||||||
},
|
|
||||||
scheme: {
|
|
||||||
icon: "palette",
|
|
||||||
name: "Scheme",
|
|
||||||
description: "Change the current colour scheme",
|
|
||||||
command: (...args) => {
|
|
||||||
// If no args, autocomplete cmd
|
|
||||||
if (args.length === 0) {
|
|
||||||
entry.set_text(">scheme ");
|
|
||||||
entry.set_position(-1);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
execAsync(`caelestia scheme ${args[0]}`).catch(console.error);
|
updateContent(search: string): void {
|
||||||
close(self);
|
throw new Error("Method not implemented.");
|
||||||
},
|
|
||||||
},
|
|
||||||
todo: {
|
|
||||||
icon: "checklist",
|
|
||||||
name: "Todo",
|
|
||||||
description: "Create a todo in Todoist",
|
|
||||||
command: (...args) => {
|
|
||||||
// If no args, autocomplete cmd
|
|
||||||
if (args.length === 0) {
|
|
||||||
entry.set_text(">todo ");
|
|
||||||
entry.set_position(-1);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!GLib.find_program_in_path("tod")) {
|
handleActivate(search: string): void {
|
||||||
notify({
|
throw new Error("Method not implemented.");
|
||||||
summary: "Tod not installed",
|
}
|
||||||
body: "The launcher todo subcommand requires `tod`. Install it with `yay -S tod-bin`",
|
}
|
||||||
icon: "dialog-warning-symbolic",
|
|
||||||
urgency: "critical",
|
@register()
|
||||||
actions: {
|
class Math extends Widget.Box implements ModeContent {
|
||||||
Install: () =>
|
constructor() {
|
||||||
execAsync("uwsm app -T -- yay -S tod-bin").catch(console.error),
|
super({ name: "math" });
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If tod not configured, notify and exit
|
updateContent(search: string): void {
|
||||||
let token = null;
|
throw new Error("Method not implemented.");
|
||||||
try {
|
|
||||||
token = JSON.parse(readFile(GLib.get_user_config_dir() + "/tod.cfg")).token;
|
|
||||||
} catch {} // Ignore
|
|
||||||
if (!token) {
|
|
||||||
notify({
|
|
||||||
summary: "Tod not configured",
|
|
||||||
body: "You need to configure tod first. Run any tod command to do this.",
|
|
||||||
icon: "dialog-warning-symbolic",
|
|
||||||
urgency: "critical",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create todo, notify and close
|
handleActivate(search: string): void {
|
||||||
execAsync(`tod t q -c ${args.join(" ")}`).catch(console.error);
|
throw new Error("Method not implemented.");
|
||||||
if (config.todo.notify.get())
|
|
||||||
notify({
|
|
||||||
summary: "Todo created",
|
|
||||||
body: `Created todo with content: ${args.join(" ")}`,
|
|
||||||
icon: "view-list-bullet-symbolic",
|
|
||||||
urgency: "low",
|
|
||||||
transient: true,
|
|
||||||
actions: {
|
|
||||||
"Copy content": () =>
|
|
||||||
execAsync(`wl-copy -- ${args.join(" ")}`).catch(console.error),
|
|
||||||
View: () => {
|
|
||||||
const client = AstalHyprland.get_default().clients.find(
|
|
||||||
c => c.class === "Todoist"
|
|
||||||
);
|
|
||||||
if (client) client.focus();
|
|
||||||
else execAsync("uwsm app -- todoist").catch(console.error);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
close(self);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reload: {
|
|
||||||
icon: "refresh",
|
|
||||||
name: "Reload",
|
|
||||||
description: "Reload app list",
|
|
||||||
command: () => Apps.reload(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const subcommandList = Object.keys(subcommands);
|
|
||||||
|
|
||||||
const afterUpdate = () => {
|
|
||||||
empty.set(self.get_children().length === 0);
|
|
||||||
|
|
||||||
const children = limitLength(self.get_children(), config);
|
|
||||||
const height = children.reduce((a, b) => a + b.get_preferred_height()[1], 0);
|
|
||||||
scrollable.css = `min-height: ${height}px;`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const appSearch = () => {
|
|
||||||
const apps = limitLength(Apps.fuzzy_query(entry.text), config.apps);
|
|
||||||
for (const app of apps) self.add(<AppResult app={app} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculate = () => {
|
|
||||||
if (entry.text) {
|
|
||||||
self.add(
|
|
||||||
<MathResult math={MathService.get_default().evaluate(entry.text)} entry={entry} />
|
|
||||||
);
|
|
||||||
self.add(<box className="separator" />);
|
|
||||||
}
|
}
|
||||||
for (const item of limitLength(MathService.get_default().history, config.math))
|
}
|
||||||
self.add(<MathResult isHistory math={item} entry={entry} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileSearch = () =>
|
@register()
|
||||||
execAsync(["fd", ...config.files.fdOpts.get(), entry.text, HOME])
|
class Windows extends Widget.Box implements ModeContent {
|
||||||
.then(out => {
|
constructor() {
|
||||||
const paths = out.split("\n").filter(path => path);
|
super({ name: "windows" });
|
||||||
self.foreach(ch => ch.destroy());
|
|
||||||
for (const path of limitLength(paths, config.files))
|
|
||||||
self.add(<FileResult path={path} />);
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
// Ignore execAsync error
|
|
||||||
if (!(e instanceof Gio.IOErrorEnum || e instanceof GLib.SpawnError)) console.error(e);
|
|
||||||
})
|
|
||||||
.finally(afterUpdate);
|
|
||||||
|
|
||||||
const listWindows = () => {
|
|
||||||
const hyprland = AstalHyprland.get_default();
|
|
||||||
// Use message cause AstalHyprland is buggy (inconsistent prop updating)
|
|
||||||
hyprland.message_async("j/clients", (_, res) => {
|
|
||||||
try {
|
|
||||||
const unsortedClients: Client[] = JSON.parse(hyprland.message_finish(res));
|
|
||||||
if (entry.text) {
|
|
||||||
const clients = fuzzysort.go(entry.text, unsortedClients, {
|
|
||||||
all: true,
|
|
||||||
limit:
|
|
||||||
config.windows.maxResults.get() < 0
|
|
||||||
? undefined
|
|
||||||
: config.windows.maxResults.get(),
|
|
||||||
keys: ["title", "class", "initialTitle", "initialClass"],
|
|
||||||
scoreFn: r =>
|
|
||||||
r[0].score * config.windows.weights.title.get() +
|
|
||||||
r[1].score * config.windows.weights.class.get() +
|
|
||||||
r[2].score * config.windows.weights.initialTitle.get() +
|
|
||||||
r[3].score * config.windows.weights.initialClass.get(),
|
|
||||||
});
|
|
||||||
self.foreach(ch => ch.destroy());
|
|
||||||
for (const { obj } of clients)
|
|
||||||
self.add(<WindowResult reload={listWindows} client={obj} />);
|
|
||||||
} else {
|
|
||||||
const clients = unsortedClients.sort((a, b) => a.focusHistoryID - b.focusHistoryID);
|
|
||||||
self.foreach(ch => ch.destroy());
|
|
||||||
for (const client of limitLength(clients, config.windows))
|
|
||||||
self.add(<WindowResult reload={listWindows} client={client} />);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
updateContent(search: string): void {
|
||||||
} finally {
|
throw new Error("Method not implemented.");
|
||||||
afterUpdate();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update windows on open
|
handleActivate(search: string): void {
|
||||||
self.hook(App, "window-toggled", (_, window) => {
|
throw new Error("Method not implemented.");
|
||||||
if (window.name === "launcher" && window.visible && mode.get() === "windows") listWindows();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.hook(entry, "activate", () => {
|
|
||||||
if (mode.get() === "math") {
|
|
||||||
if (entry.text.startsWith("clear")) MathService.get_default().clear();
|
|
||||||
else MathService.get_default().commit();
|
|
||||||
}
|
}
|
||||||
self.get_children()[0]?.activate();
|
}
|
||||||
});
|
|
||||||
self.hook(entry, "changed", () => {
|
|
||||||
if (!entry.text && mode.get() === "apps") return;
|
|
||||||
|
|
||||||
// Files and windows have delay cause async so they do some stuff by themselves
|
const SearchBar = ({ mode, entry }: { mode: Variable<Mode>; entry: Widget.Entry }) => (
|
||||||
const ignoreFileAsync =
|
|
||||||
entry.text.startsWith(">") || (mode.get() !== "files" && mode.get() !== "windows");
|
|
||||||
if (ignoreFileAsync) self.foreach(ch => ch.destroy());
|
|
||||||
|
|
||||||
if (entry.text.startsWith(">")) {
|
|
||||||
const args = entry.text.split(" ");
|
|
||||||
for (const { target } of fuzzysort.go(args[0].slice(1), subcommandList, { all: true }))
|
|
||||||
self.add(
|
|
||||||
<SubcommandResult
|
|
||||||
entry={entry}
|
|
||||||
subcommand={subcommands[target]}
|
|
||||||
args={args.slice(1)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (mode.get() === "apps") appSearch();
|
|
||||||
else if (mode.get() === "math") calculate();
|
|
||||||
else if (mode.get() === "files") fileSearch();
|
|
||||||
else if (mode.get() === "windows") listWindows();
|
|
||||||
|
|
||||||
if (ignoreFileAsync) afterUpdate();
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</scrollable>
|
|
||||||
) as Widget.Scrollable;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<stack
|
|
||||||
className="results"
|
|
||||||
transitionType={Gtk.StackTransitionType.CROSSFADE}
|
|
||||||
transitionDuration={150}
|
|
||||||
shown={bind(empty).as(t => (t ? "empty" : "list"))}
|
|
||||||
>
|
|
||||||
<box name="empty" className="empty" halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER}>
|
|
||||||
<label className="icon" label="bug_report" />
|
|
||||||
<label
|
|
||||||
label={bind(entry, "text").as(t =>
|
|
||||||
t.startsWith(">") ? "No matching subcommands" : getEmptyTextFromMode(mode.get())
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
{scrollable}
|
|
||||||
</stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LauncherContent = ({
|
|
||||||
mode,
|
|
||||||
showResults,
|
|
||||||
entry,
|
|
||||||
}: {
|
|
||||||
mode: Variable<Mode>;
|
|
||||||
showResults: Variable<boolean>;
|
|
||||||
entry: Widget.Entry;
|
|
||||||
}) => (
|
|
||||||
<box vertical className={bind(mode).as(m => `launcher ${m}`)}>
|
|
||||||
<box className="search-bar">
|
<box className="search-bar">
|
||||||
<label className="icon" label="search" />
|
<label className="mode" label={bind(mode)} />
|
||||||
<SearchEntry entry={entry} />
|
{entry}
|
||||||
<label className="icon" label={bind(mode).as(getIconFromMode)} />
|
|
||||||
</box>
|
</box>
|
||||||
<revealer
|
);
|
||||||
revealChild={bind(showResults).as(s => !s)}
|
|
||||||
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
|
const ModeSwitcher = ({ mode, modes }: { mode: Variable<Mode>; modes: Mode[] }) => (
|
||||||
transitionDuration={150}
|
<box homogeneous hexpand className="mode-switcher">
|
||||||
>
|
{modes.map(m => (
|
||||||
<PinnedApps />
|
<button
|
||||||
</revealer>
|
className={bind(mode).as(c => `mode ${c === m ? "selected" : ""}`)}
|
||||||
<revealer
|
cursor="pointer"
|
||||||
revealChild={bind(showResults)}
|
onClicked={() => mode.set(m)}
|
||||||
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
|
label={m}
|
||||||
transitionDuration={150}
|
/>
|
||||||
>
|
))}
|
||||||
<Results entry={entry} mode={mode} />
|
|
||||||
</revealer>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -768,14 +94,21 @@ export default class Launcher extends PopupWindow {
|
||||||
readonly mode: Variable<Mode>;
|
readonly mode: Variable<Mode>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const entry = (<entry name="entry" />) as Widget.Entry;
|
const entry = (<entry hexpand className="entry" />) as Widget.Entry;
|
||||||
const mode = Variable<Mode>("apps");
|
const mode = Variable<Mode>("apps");
|
||||||
const showResults = Variable.derive([bind(entry, "textLength"), mode], (t, m) => t > 0 || m !== "apps");
|
const content = {
|
||||||
|
apps: new Apps(),
|
||||||
|
files: new Files(),
|
||||||
|
math: new Math(),
|
||||||
|
windows: new Windows(),
|
||||||
|
};
|
||||||
|
|
||||||
super({
|
super({
|
||||||
name: "launcher",
|
name: "launcher",
|
||||||
anchor: Astal.WindowAnchor.TOP,
|
anchor:
|
||||||
|
Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT,
|
||||||
keymode: Astal.Keymode.EXCLUSIVE,
|
keymode: Astal.Keymode.EXCLUSIVE,
|
||||||
|
borderWidth: 0,
|
||||||
onKeyPressEvent(_, event) {
|
onKeyPressEvent(_, event) {
|
||||||
const keyval = event.get_keyval()[1];
|
const keyval = event.get_keyval()[1];
|
||||||
// Focus entry on typing
|
// Focus entry on typing
|
||||||
|
|
@ -788,17 +121,38 @@ export default class Launcher extends PopupWindow {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: <LauncherContent mode={mode} showResults={showResults} entry={entry} />,
|
child: (
|
||||||
|
<box
|
||||||
|
vertical
|
||||||
|
halign={Gtk.Align.CENTER}
|
||||||
|
valign={Gtk.Align.CENTER}
|
||||||
|
className={bind(mode).as(m => `launcher ${m}`)}
|
||||||
|
>
|
||||||
|
<SearchBar mode={mode} entry={entry} />
|
||||||
|
<stack
|
||||||
|
expand
|
||||||
|
transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT}
|
||||||
|
transitionDuration={200}
|
||||||
|
shown={bind(mode)}
|
||||||
|
>
|
||||||
|
{Object.values(content)}
|
||||||
|
</stack>
|
||||||
|
<ModeSwitcher mode={mode} modes={Object.keys(content) as Mode[]} />
|
||||||
|
</box>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
|
|
||||||
this.connect("show", () => (this.marginTop = AstalHyprland.get_default().focusedMonitor.height / 4));
|
this.hook(mode, (_, v: Mode) => {
|
||||||
|
entry.set_text("");
|
||||||
|
content[v].updateContent(entry.get_text());
|
||||||
|
});
|
||||||
|
this.hook(entry, "changed", () => content[mode.get()].updateContent(entry.get_text()));
|
||||||
|
this.hook(entry, "activate", () => content[mode.get()].handleActivate(entry.get_text()));
|
||||||
|
|
||||||
// Clear search on hide if not in math mode or creating a todo
|
// Clear search on hide if not in math mode or creating a todo
|
||||||
this.connect("hide", () => mode.get() !== "math" && !entry.text.startsWith(">todo") && entry.set_text(""));
|
this.connect("hide", () => mode.get() !== "math" && !entry.text.startsWith(">todo") && entry.set_text(""));
|
||||||
|
|
||||||
this.connect("destroy", () => showResults.drop());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
open(mode: Mode) {
|
open(mode: Mode) {
|
||||||
|
|
|
||||||
808
src/modules/launcher_old.tsx
Normal file
808
src/modules/launcher_old.tsx
Normal file
|
|
@ -0,0 +1,808 @@
|
||||||
|
import { Apps } from "@/services/apps";
|
||||||
|
import MathService, { type HistoryItem } from "@/services/math";
|
||||||
|
import { getAppCategoryIcon } from "@/utils/icons";
|
||||||
|
import { launch, notify } from "@/utils/system";
|
||||||
|
import type { Client } from "@/utils/types";
|
||||||
|
import { MenuItem, setupCustomTooltip } from "@/utils/widgets";
|
||||||
|
import PopupWindow from "@/widgets/popupwindow";
|
||||||
|
import { bind, execAsync, Gio, GLib, readFile, register, timeout, Variable } from "astal";
|
||||||
|
import { App, Astal, Gtk, Widget } from "astal/gtk3";
|
||||||
|
import { launcher as config } from "config";
|
||||||
|
import fuzzysort from "fuzzysort";
|
||||||
|
import type AstalApps from "gi://AstalApps";
|
||||||
|
import AstalHyprland from "gi://AstalHyprland";
|
||||||
|
|
||||||
|
type Mode = "apps" | "files" | "math" | "windows";
|
||||||
|
|
||||||
|
interface Subcommand {
|
||||||
|
icon: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
command: (...args: string[]) => boolean | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIconFromMode = (mode: Mode) => {
|
||||||
|
switch (mode) {
|
||||||
|
case "apps":
|
||||||
|
return "apps";
|
||||||
|
case "files":
|
||||||
|
return "folder";
|
||||||
|
case "math":
|
||||||
|
return "calculate";
|
||||||
|
case "windows":
|
||||||
|
return "select_window";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmptyTextFromMode = (mode: Mode) => {
|
||||||
|
switch (mode) {
|
||||||
|
case "apps":
|
||||||
|
return "No apps found";
|
||||||
|
case "files":
|
||||||
|
return GLib.find_program_in_path("fd") === null ? "File search requires `fd`" : "No files found";
|
||||||
|
case "math":
|
||||||
|
return "Type an expression";
|
||||||
|
case "windows":
|
||||||
|
return "No windows found";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const limitLength = <T,>(arr: T[], cfg: { maxResults: Variable<number> }) =>
|
||||||
|
cfg.maxResults.get() > 0 && arr.length > cfg.maxResults.get() ? arr.slice(0, cfg.maxResults.get()) : arr;
|
||||||
|
|
||||||
|
const close = (self: JSX.Element) => {
|
||||||
|
const toplevel = self.get_toplevel();
|
||||||
|
if (toplevel instanceof Widget.Window) toplevel.hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const launchAndClose = (self: JSX.Element, astalApp: AstalApps.Application) => {
|
||||||
|
close(self);
|
||||||
|
launch(astalApp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFileAndClose = (self: JSX.Element, path: string) => {
|
||||||
|
close(self);
|
||||||
|
execAsync([
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`dbus-send --session --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems array:string:"file://${path}" string:"" || xdg-open "${path}"`,
|
||||||
|
]).catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PinnedApp = (names: string[]) => {
|
||||||
|
let app: Gio.DesktopAppInfo | null = null;
|
||||||
|
let astalApp: AstalApps.Application | undefined;
|
||||||
|
for (const name of names) {
|
||||||
|
app = Gio.DesktopAppInfo.new(`${name}.desktop`);
|
||||||
|
if (app) {
|
||||||
|
astalApp = Apps.get_list().find(a => a.entry === `${name}.desktop`);
|
||||||
|
if (app.get_icon() && astalApp) break;
|
||||||
|
else app = null; // Set app to null if no icon or matching AstalApps#Application
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
console.error(`Launcher - Unable to find app for "${names.join(", ")}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = new Gtk.Menu();
|
||||||
|
menu.append(new MenuItem({ label: "Launch", onActivate: () => launchAndClose(widget, astalApp!) }));
|
||||||
|
|
||||||
|
if (app.list_actions().length > 0) menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
||||||
|
app.list_actions().forEach(action => {
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: app.get_action_name(action),
|
||||||
|
onActivate: () => {
|
||||||
|
close(widget); // Pass result cause menu is its own toplevel
|
||||||
|
app.launch_action(action, null);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const widget = (
|
||||||
|
<button
|
||||||
|
className="pinned-app result"
|
||||||
|
cursor="pointer"
|
||||||
|
onClicked={self => launchAndClose(self, astalApp!)}
|
||||||
|
onClick={(_, event) => event.button === Astal.MouseButton.SECONDARY && menu.popup_at_pointer(null)}
|
||||||
|
setup={self => setupCustomTooltip(self, app.get_display_name())}
|
||||||
|
onDestroy={() => menu.destroy()}
|
||||||
|
>
|
||||||
|
<icon gicon={app.get_icon()!} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return widget;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PinnedApps = () => <box homogeneous>{bind(config.apps.pins).as(p => p.map(PinnedApp))}</box>;
|
||||||
|
|
||||||
|
const SearchEntry = ({ entry }: { entry: Widget.Entry }) => (
|
||||||
|
<stack
|
||||||
|
hexpand
|
||||||
|
transitionType={Gtk.StackTransitionType.CROSSFADE}
|
||||||
|
transitionDuration={150}
|
||||||
|
setup={self =>
|
||||||
|
self.hook(entry, "notify::text-length", () =>
|
||||||
|
// Timeout to avoid flickering when replacing entire text (cause it'll set len to 0 then back to > 0)
|
||||||
|
timeout(1, () => (self.shown = entry.textLength > 0 ? "entry" : "placeholder"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label name="placeholder" className="placeholder" xalign={0} label='Type ">" for subcommands' />
|
||||||
|
{entry}
|
||||||
|
</stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Result = ({
|
||||||
|
icon,
|
||||||
|
materialIcon,
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
tooltip,
|
||||||
|
italic,
|
||||||
|
onClicked,
|
||||||
|
onSecondaryClick,
|
||||||
|
onMiddleClick,
|
||||||
|
onDestroy,
|
||||||
|
}: {
|
||||||
|
icon?: string | Gio.Icon | null;
|
||||||
|
materialIcon?: string;
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
italic?: boolean;
|
||||||
|
onClicked: (self: Widget.Button) => void;
|
||||||
|
onSecondaryClick?: (self: Widget.Button) => void;
|
||||||
|
onMiddleClick?: (self: Widget.Button) => void;
|
||||||
|
onDestroy?: () => void;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
className={`result ${italic ? "italic" : ""}`}
|
||||||
|
cursor="pointer"
|
||||||
|
onClicked={onClicked}
|
||||||
|
onClick={(self, event) => {
|
||||||
|
if (event.button === Astal.MouseButton.SECONDARY) onSecondaryClick?.(self);
|
||||||
|
else if (event.button === Astal.MouseButton.MIDDLE) onMiddleClick?.(self);
|
||||||
|
}}
|
||||||
|
onDestroy={onDestroy}
|
||||||
|
setup={self => tooltip && setupCustomTooltip(self, tooltip)}
|
||||||
|
>
|
||||||
|
<box>
|
||||||
|
{icon &&
|
||||||
|
(typeof icon === "string" ? (
|
||||||
|
Astal.Icon.lookup_icon(icon) && <icon valign={Gtk.Align.START} className="icon" icon={icon} />
|
||||||
|
) : (
|
||||||
|
<icon valign={Gtk.Align.START} className="icon" gicon={icon} />
|
||||||
|
))}
|
||||||
|
{materialIcon && (!icon || (typeof icon === "string" && !Astal.Icon.lookup_icon(icon))) && (
|
||||||
|
<label valign={Gtk.Align.START} className="icon" label={materialIcon} />
|
||||||
|
)}
|
||||||
|
{sublabel ? (
|
||||||
|
<box vertical valign={Gtk.Align.CENTER} className="has-sublabel">
|
||||||
|
<label hexpand truncate maxWidthChars={1} xalign={0} label={label} />
|
||||||
|
<label hexpand truncate maxWidthChars={1} className="sublabel" xalign={0} label={sublabel} />
|
||||||
|
</box>
|
||||||
|
) : (
|
||||||
|
<label hexpand truncate maxWidthChars={1} xalign={0} label={label} />
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SubcommandResult = ({
|
||||||
|
entry,
|
||||||
|
subcommand,
|
||||||
|
args,
|
||||||
|
}: {
|
||||||
|
entry: Widget.Entry;
|
||||||
|
subcommand: Subcommand;
|
||||||
|
args: string[];
|
||||||
|
}) => (
|
||||||
|
<Result
|
||||||
|
materialIcon={subcommand.icon}
|
||||||
|
label={subcommand.name}
|
||||||
|
sublabel={subcommand.description}
|
||||||
|
onClicked={() => {
|
||||||
|
if (!subcommand.command(...args)) entry.set_text("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AppResult = ({ app }: { app: AstalApps.Application }) => {
|
||||||
|
const menu = new Gtk.Menu();
|
||||||
|
menu.append(new MenuItem({ label: "Launch", onActivate: () => launchAndClose(result, app) }));
|
||||||
|
|
||||||
|
const appInfo = app.app as Gio.DesktopAppInfo;
|
||||||
|
if (appInfo.list_actions().length > 0) menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
||||||
|
appInfo.list_actions().forEach(action => {
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: appInfo.get_action_name(action),
|
||||||
|
onActivate: () => {
|
||||||
|
close(result); // Pass result cause menu is its own toplevel
|
||||||
|
appInfo.launch_action(action, null);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (
|
||||||
|
<Result
|
||||||
|
icon={app.iconName}
|
||||||
|
materialIcon={getAppCategoryIcon(app)}
|
||||||
|
label={app.name}
|
||||||
|
sublabel={app.description}
|
||||||
|
onClicked={self => launchAndClose(self, app)}
|
||||||
|
onSecondaryClick={() => menu.popup_at_pointer(null)}
|
||||||
|
onDestroy={() => menu.destroy()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MathResult = ({ math, isHistory, entry }: { math: HistoryItem; isHistory?: boolean; entry: Widget.Entry }) => (
|
||||||
|
<Result
|
||||||
|
materialIcon={math.icon}
|
||||||
|
label={math.equation}
|
||||||
|
sublabel={math.result}
|
||||||
|
onClicked={() => {
|
||||||
|
if (isHistory) {
|
||||||
|
MathService.get_default().select(math);
|
||||||
|
entry.set_text(math.equation);
|
||||||
|
entry.grab_focus();
|
||||||
|
entry.set_position(-1);
|
||||||
|
} else {
|
||||||
|
execAsync(`wl-copy -- ${math.result}`).catch(console.error);
|
||||||
|
entry.set_text("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FileResult = ({ path }: { path: string }) => {
|
||||||
|
const menu = new Gtk.Menu();
|
||||||
|
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: "Open",
|
||||||
|
onActivate: () => {
|
||||||
|
execAsync(["xdg-open", path]).catch(console.error);
|
||||||
|
close(result);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
||||||
|
menu.append(new MenuItem({ label: "Open in file manager", onActivate: () => openFileAndClose(result, path) }));
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: "Open containing folder in terminal",
|
||||||
|
onActivate: () => {
|
||||||
|
execAsync(`uwsm app -- foot -D ${path.slice(0, path.lastIndexOf("/"))}`).catch(console.error);
|
||||||
|
close(result);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (
|
||||||
|
<Result
|
||||||
|
icon={Gio.File.new_for_path(path)
|
||||||
|
.query_info(Gio.FILE_ATTRIBUTE_STANDARD_ICON, Gio.FileQueryInfoFlags.NONE, null)
|
||||||
|
.get_icon()}
|
||||||
|
label={path.split("/").pop()!}
|
||||||
|
sublabel={path.startsWith(HOME) ? "~" + path.slice(HOME.length) : path}
|
||||||
|
onClicked={self => openFileAndClose(self, path)}
|
||||||
|
onSecondaryClick={() => menu.popup_at_pointer(null)}
|
||||||
|
onDestroy={() => menu.destroy()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WindowResult = ({ client, reload }: { client: Client; reload: () => void }) => {
|
||||||
|
const hyprland = AstalHyprland.get_default();
|
||||||
|
const app = Apps.fuzzy_query(client.class)[0];
|
||||||
|
const astalClient = hyprland.get_client(client.address);
|
||||||
|
|
||||||
|
const menu = new Gtk.Menu();
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: "Focus",
|
||||||
|
onActivate: () => {
|
||||||
|
close(result);
|
||||||
|
astalClient?.focus();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
||||||
|
|
||||||
|
const addSubmenus = (silent: boolean) => {
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: `Move to workspace${silent ? " (silent)" : ""}`,
|
||||||
|
setup: self => {
|
||||||
|
const submenu = new Gtk.Menu();
|
||||||
|
const start = Math.floor((hyprland.focusedWorkspace.id - 1) / 10) * 10;
|
||||||
|
for (let i = 1; i <= 10; i++)
|
||||||
|
submenu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: `Workspace ${start + i}`,
|
||||||
|
onActivate: () => {
|
||||||
|
if (!silent) close(result);
|
||||||
|
hyprland.dispatch(
|
||||||
|
`movetoworkspace${silent ? "silent" : ""}`,
|
||||||
|
`${start + i},address:${client.address}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.set_submenu(submenu);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: `Move to special workspace${silent ? " (silent)" : ""}`,
|
||||||
|
setup: self => {
|
||||||
|
const submenu = new Gtk.Menu();
|
||||||
|
submenu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: "special",
|
||||||
|
onActivate: () => {
|
||||||
|
if (!silent) close(result);
|
||||||
|
hyprland.dispatch(
|
||||||
|
`movetoworkspace${silent ? "silent" : ""}`,
|
||||||
|
`special,address:${client.address}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
hyprland.message_async("j/workspaces", (_, res) => {
|
||||||
|
const workspaces = JSON.parse(hyprland.message_finish(res));
|
||||||
|
for (const workspace of workspaces)
|
||||||
|
if (workspace.name.startsWith("special:"))
|
||||||
|
submenu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: workspace.name.slice(8),
|
||||||
|
onActivate: () => {
|
||||||
|
if (!silent) close(result);
|
||||||
|
hyprland.dispatch(
|
||||||
|
`movetoworkspace${silent ? "silent" : ""}`,
|
||||||
|
`${workspace.name},address:${client.address}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
self.set_submenu(submenu);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
addSubmenus(false);
|
||||||
|
addSubmenus(true);
|
||||||
|
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: "Copy property",
|
||||||
|
setup: self => {
|
||||||
|
const addSubmenu = (self: MenuItem, obj: object) => {
|
||||||
|
const submenu = new Gtk.Menu();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj))
|
||||||
|
if (typeof value === "object") submenu.append(addSubmenu(new MenuItem({ label: key }), value));
|
||||||
|
else
|
||||||
|
submenu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: key,
|
||||||
|
onActivate: () => {
|
||||||
|
close(result);
|
||||||
|
execAsync(`wl-copy -- ${value}`).catch(console.error);
|
||||||
|
},
|
||||||
|
tooltipText: String(value), // Cannot use custom tooltip cause it'll be below menu
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
self.set_submenu(submenu);
|
||||||
|
return self;
|
||||||
|
};
|
||||||
|
addSubmenu(self, client);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
|
||||||
|
menu.append(
|
||||||
|
new MenuItem({
|
||||||
|
label: "Kill",
|
||||||
|
onActivate: () => {
|
||||||
|
astalClient?.kill();
|
||||||
|
const id = hyprland.connect("client-removed", () => {
|
||||||
|
hyprland.disconnect(id);
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const classOrTitle = (prop: "Class" | "Title", header = true) => {
|
||||||
|
const lower = prop.toLowerCase() as "class" | "title";
|
||||||
|
return (
|
||||||
|
(header ? `${prop}: ` : "") +
|
||||||
|
(client[lower] || (client[`initial${prop}`] ? `${client[`initial${prop}`]} (initial)` : `No ${lower}`))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const workspace = (header = false) =>
|
||||||
|
(header ? "Workspace: " : "") + `${client.workspace.name} (${client.workspace.id})`;
|
||||||
|
const prop = (prop: keyof typeof client, header?: string) =>
|
||||||
|
`${header ?? prop.slice(0, 1).toUpperCase() + prop.slice(1)}: ${client[prop]}`;
|
||||||
|
|
||||||
|
const result = (
|
||||||
|
<Result
|
||||||
|
icon={app.iconName}
|
||||||
|
materialIcon={getAppCategoryIcon(app)}
|
||||||
|
label={
|
||||||
|
classOrTitle("Title", false).length < 5
|
||||||
|
? `${classOrTitle("Class", false)}: ${classOrTitle("Title", false)}`
|
||||||
|
: classOrTitle("Title", false)
|
||||||
|
}
|
||||||
|
sublabel={`Workspace ${workspace()} on ${hyprland.get_monitor(client.monitor).name}`}
|
||||||
|
tooltip={`${classOrTitle("Title")}\n${classOrTitle("Class")}\n${prop("address")}\n${workspace(
|
||||||
|
true
|
||||||
|
)}\n${prop("pid", "Process ID")}\n${prop("floating")}\n${prop("inhibitingIdle", "Inhibiting idle")}`}
|
||||||
|
italic={client.xwayland}
|
||||||
|
onClicked={self => {
|
||||||
|
close(self);
|
||||||
|
astalClient?.focus();
|
||||||
|
}}
|
||||||
|
onSecondaryClick={() => menu.popup_at_pointer(null)}
|
||||||
|
onMiddleClick={() => {
|
||||||
|
astalClient?.kill();
|
||||||
|
const id = hyprland.connect("client-removed", () => {
|
||||||
|
hyprland.disconnect(id);
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDestroy={() => menu.destroy()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> }) => {
|
||||||
|
const empty = Variable(true);
|
||||||
|
|
||||||
|
const scrollable = (
|
||||||
|
<scrollable name="list" hscroll={Gtk.PolicyType.NEVER}>
|
||||||
|
<box
|
||||||
|
vertical
|
||||||
|
setup={self => {
|
||||||
|
const subcommands: Record<string, Subcommand> = {
|
||||||
|
apps: {
|
||||||
|
icon: "apps",
|
||||||
|
name: "Apps",
|
||||||
|
description: "Search for apps",
|
||||||
|
command: () => mode.set("apps"),
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
icon: "folder",
|
||||||
|
name: "Files",
|
||||||
|
description: "Search for files",
|
||||||
|
command: () => mode.set("files"),
|
||||||
|
},
|
||||||
|
math: {
|
||||||
|
icon: "calculate",
|
||||||
|
name: "Math",
|
||||||
|
description: "Do math calculations",
|
||||||
|
command: () => mode.set("math"),
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
icon: "select_window",
|
||||||
|
name: "Windows",
|
||||||
|
description: "Manage open windows",
|
||||||
|
command: () => mode.set("windows"),
|
||||||
|
},
|
||||||
|
scheme: {
|
||||||
|
icon: "palette",
|
||||||
|
name: "Scheme",
|
||||||
|
description: "Change the current colour scheme",
|
||||||
|
command: (...args) => {
|
||||||
|
// If no args, autocomplete cmd
|
||||||
|
if (args.length === 0) {
|
||||||
|
entry.set_text(">scheme ");
|
||||||
|
entry.set_position(-1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
execAsync(`caelestia scheme ${args[0]}`).catch(console.error);
|
||||||
|
close(self);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
todo: {
|
||||||
|
icon: "checklist",
|
||||||
|
name: "Todo",
|
||||||
|
description: "Create a todo in Todoist",
|
||||||
|
command: (...args) => {
|
||||||
|
// If no args, autocomplete cmd
|
||||||
|
if (args.length === 0) {
|
||||||
|
entry.set_text(">todo ");
|
||||||
|
entry.set_position(-1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GLib.find_program_in_path("tod")) {
|
||||||
|
notify({
|
||||||
|
summary: "Tod not installed",
|
||||||
|
body: "The launcher todo subcommand requires `tod`. Install it with `yay -S tod-bin`",
|
||||||
|
icon: "dialog-warning-symbolic",
|
||||||
|
urgency: "critical",
|
||||||
|
actions: {
|
||||||
|
Install: () =>
|
||||||
|
execAsync("uwsm app -T -- yay -S tod-bin").catch(console.error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tod not configured, notify and exit
|
||||||
|
let token = null;
|
||||||
|
try {
|
||||||
|
token = JSON.parse(readFile(GLib.get_user_config_dir() + "/tod.cfg")).token;
|
||||||
|
} catch {} // Ignore
|
||||||
|
if (!token) {
|
||||||
|
notify({
|
||||||
|
summary: "Tod not configured",
|
||||||
|
body: "You need to configure tod first. Run any tod command to do this.",
|
||||||
|
icon: "dialog-warning-symbolic",
|
||||||
|
urgency: "critical",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create todo, notify and close
|
||||||
|
execAsync(`tod t q -c ${args.join(" ")}`).catch(console.error);
|
||||||
|
if (config.todo.notify.get())
|
||||||
|
notify({
|
||||||
|
summary: "Todo created",
|
||||||
|
body: `Created todo with content: ${args.join(" ")}`,
|
||||||
|
icon: "view-list-bullet-symbolic",
|
||||||
|
urgency: "low",
|
||||||
|
transient: true,
|
||||||
|
actions: {
|
||||||
|
"Copy content": () =>
|
||||||
|
execAsync(`wl-copy -- ${args.join(" ")}`).catch(console.error),
|
||||||
|
View: () => {
|
||||||
|
const client = AstalHyprland.get_default().clients.find(
|
||||||
|
c => c.class === "Todoist"
|
||||||
|
);
|
||||||
|
if (client) client.focus();
|
||||||
|
else execAsync("uwsm app -- todoist").catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
close(self);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reload: {
|
||||||
|
icon: "refresh",
|
||||||
|
name: "Reload",
|
||||||
|
description: "Reload app list",
|
||||||
|
command: () => Apps.reload(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const subcommandList = Object.keys(subcommands);
|
||||||
|
|
||||||
|
const afterUpdate = () => {
|
||||||
|
empty.set(self.get_children().length === 0);
|
||||||
|
|
||||||
|
const children = limitLength(self.get_children(), config);
|
||||||
|
const height = children.reduce((a, b) => a + b.get_preferred_height()[1], 0);
|
||||||
|
scrollable.css = `min-height: ${height}px;`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appSearch = () => {
|
||||||
|
const apps = limitLength(Apps.fuzzy_query(entry.text), config.apps);
|
||||||
|
for (const app of apps) self.add(<AppResult app={app} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculate = () => {
|
||||||
|
if (entry.text) {
|
||||||
|
self.add(
|
||||||
|
<MathResult math={MathService.get_default().evaluate(entry.text)} entry={entry} />
|
||||||
|
);
|
||||||
|
self.add(<box className="separator" />);
|
||||||
|
}
|
||||||
|
for (const item of limitLength(MathService.get_default().history, config.math))
|
||||||
|
self.add(<MathResult isHistory math={item} entry={entry} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileSearch = () =>
|
||||||
|
execAsync(["fd", ...config.files.fdOpts.get(), entry.text, HOME])
|
||||||
|
.then(out => {
|
||||||
|
const paths = out.split("\n").filter(path => path);
|
||||||
|
self.foreach(ch => ch.destroy());
|
||||||
|
for (const path of limitLength(paths, config.files))
|
||||||
|
self.add(<FileResult path={path} />);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
// Ignore execAsync error
|
||||||
|
if (!(e instanceof Gio.IOErrorEnum || e instanceof GLib.SpawnError)) console.error(e);
|
||||||
|
})
|
||||||
|
.finally(afterUpdate);
|
||||||
|
|
||||||
|
const listWindows = () => {
|
||||||
|
const hyprland = AstalHyprland.get_default();
|
||||||
|
// Use message cause AstalHyprland is buggy (inconsistent prop updating)
|
||||||
|
hyprland.message_async("j/clients", (_, res) => {
|
||||||
|
try {
|
||||||
|
const unsortedClients: Client[] = JSON.parse(hyprland.message_finish(res));
|
||||||
|
if (entry.text) {
|
||||||
|
const clients = fuzzysort.go(entry.text, unsortedClients, {
|
||||||
|
all: true,
|
||||||
|
limit:
|
||||||
|
config.windows.maxResults.get() < 0
|
||||||
|
? undefined
|
||||||
|
: config.windows.maxResults.get(),
|
||||||
|
keys: ["title", "class", "initialTitle", "initialClass"],
|
||||||
|
scoreFn: r =>
|
||||||
|
r[0].score * config.windows.weights.title.get() +
|
||||||
|
r[1].score * config.windows.weights.class.get() +
|
||||||
|
r[2].score * config.windows.weights.initialTitle.get() +
|
||||||
|
r[3].score * config.windows.weights.initialClass.get(),
|
||||||
|
});
|
||||||
|
self.foreach(ch => ch.destroy());
|
||||||
|
for (const { obj } of clients)
|
||||||
|
self.add(<WindowResult reload={listWindows} client={obj} />);
|
||||||
|
} else {
|
||||||
|
const clients = unsortedClients.sort((a, b) => a.focusHistoryID - b.focusHistoryID);
|
||||||
|
self.foreach(ch => ch.destroy());
|
||||||
|
for (const client of limitLength(clients, config.windows))
|
||||||
|
self.add(<WindowResult reload={listWindows} client={client} />);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
afterUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update windows on open
|
||||||
|
self.hook(App, "window-toggled", (_, window) => {
|
||||||
|
if (window.name === "launcher" && window.visible && mode.get() === "windows") listWindows();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.hook(entry, "activate", () => {
|
||||||
|
if (mode.get() === "math") {
|
||||||
|
if (entry.text.startsWith("clear")) MathService.get_default().clear();
|
||||||
|
else MathService.get_default().commit();
|
||||||
|
}
|
||||||
|
self.get_children()[0]?.activate();
|
||||||
|
});
|
||||||
|
self.hook(entry, "changed", () => {
|
||||||
|
if (!entry.text && mode.get() === "apps") return;
|
||||||
|
|
||||||
|
// Files and windows have delay cause async so they do some stuff by themselves
|
||||||
|
const ignoreFileAsync =
|
||||||
|
entry.text.startsWith(">") || (mode.get() !== "files" && mode.get() !== "windows");
|
||||||
|
if (ignoreFileAsync) self.foreach(ch => ch.destroy());
|
||||||
|
|
||||||
|
if (entry.text.startsWith(">")) {
|
||||||
|
const args = entry.text.split(" ");
|
||||||
|
for (const { target } of fuzzysort.go(args[0].slice(1), subcommandList, { all: true }))
|
||||||
|
self.add(
|
||||||
|
<SubcommandResult
|
||||||
|
entry={entry}
|
||||||
|
subcommand={subcommands[target]}
|
||||||
|
args={args.slice(1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (mode.get() === "apps") appSearch();
|
||||||
|
else if (mode.get() === "math") calculate();
|
||||||
|
else if (mode.get() === "files") fileSearch();
|
||||||
|
else if (mode.get() === "windows") listWindows();
|
||||||
|
|
||||||
|
if (ignoreFileAsync) afterUpdate();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</scrollable>
|
||||||
|
) as Widget.Scrollable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<stack
|
||||||
|
className="results"
|
||||||
|
transitionType={Gtk.StackTransitionType.CROSSFADE}
|
||||||
|
transitionDuration={150}
|
||||||
|
shown={bind(empty).as(t => (t ? "empty" : "list"))}
|
||||||
|
>
|
||||||
|
<box name="empty" className="empty" halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER}>
|
||||||
|
<label className="icon" label="bug_report" />
|
||||||
|
<label
|
||||||
|
label={bind(entry, "text").as(t =>
|
||||||
|
t.startsWith(">") ? "No matching subcommands" : getEmptyTextFromMode(mode.get())
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
{scrollable}
|
||||||
|
</stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LauncherContent = ({
|
||||||
|
mode,
|
||||||
|
showResults,
|
||||||
|
entry,
|
||||||
|
}: {
|
||||||
|
mode: Variable<Mode>;
|
||||||
|
showResults: Variable<boolean>;
|
||||||
|
entry: Widget.Entry;
|
||||||
|
}) => (
|
||||||
|
<box vertical className={bind(mode).as(m => `launcher ${m}`)}>
|
||||||
|
<box className="search-bar">
|
||||||
|
<label className="icon" label="search" />
|
||||||
|
<SearchEntry entry={entry} />
|
||||||
|
<label className="icon" label={bind(mode).as(getIconFromMode)} />
|
||||||
|
</box>
|
||||||
|
<revealer
|
||||||
|
revealChild={bind(showResults).as(s => !s)}
|
||||||
|
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
|
||||||
|
transitionDuration={150}
|
||||||
|
>
|
||||||
|
<PinnedApps />
|
||||||
|
</revealer>
|
||||||
|
<revealer
|
||||||
|
revealChild={bind(showResults)}
|
||||||
|
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
|
||||||
|
transitionDuration={150}
|
||||||
|
>
|
||||||
|
<Results entry={entry} mode={mode} />
|
||||||
|
</revealer>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
|
||||||
|
@register()
|
||||||
|
export default class Launcher extends PopupWindow {
|
||||||
|
readonly mode: Variable<Mode>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const entry = (<entry name="entry" />) as Widget.Entry;
|
||||||
|
const mode = Variable<Mode>("apps");
|
||||||
|
const showResults = Variable.derive([bind(entry, "textLength"), mode], (t, m) => t > 0 || m !== "apps");
|
||||||
|
|
||||||
|
super({
|
||||||
|
name: "launcher",
|
||||||
|
anchor: Astal.WindowAnchor.TOP,
|
||||||
|
keymode: Astal.Keymode.EXCLUSIVE,
|
||||||
|
onKeyPressEvent(_, event) {
|
||||||
|
const keyval = event.get_keyval()[1];
|
||||||
|
// Focus entry on typing
|
||||||
|
if (!entry.isFocus && keyval >= 32 && keyval <= 126) {
|
||||||
|
entry.text += String.fromCharCode(keyval);
|
||||||
|
entry.grab_focus();
|
||||||
|
entry.set_position(-1);
|
||||||
|
|
||||||
|
// Consume event, if not consumed it will duplicate character in entry
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: <LauncherContent mode={mode} showResults={showResults} entry={entry} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mode = mode;
|
||||||
|
|
||||||
|
this.connect("show", () => (this.marginTop = AstalHyprland.get_default().focusedMonitor.height / 4));
|
||||||
|
|
||||||
|
// Clear search on hide if not in math mode or creating a todo
|
||||||
|
this.connect("hide", () => mode.get() !== "math" && !entry.text.startsWith(">todo") && entry.set_text(""));
|
||||||
|
|
||||||
|
this.connect("destroy", () => showResults.drop());
|
||||||
|
}
|
||||||
|
|
||||||
|
open(mode: Mode) {
|
||||||
|
this.mode.set(mode);
|
||||||
|
this.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Binding, register } from "astal";
|
import { Binding, register } from "astal";
|
||||||
import { App, Astal, Gdk, Widget } from "astal/gtk3";
|
import { App, Astal, Gdk, Widget } from "astal/gtk3";
|
||||||
import { bar } from "config";
|
import { bar } from "config";
|
||||||
import AstalHyprland from "gi://AstalHyprland?version=0.1";
|
import AstalHyprland from "gi://AstalHyprland";
|
||||||
|
|
||||||
const extendProp = <T>(
|
const extendProp = <T>(
|
||||||
prop: T | Binding<T | undefined> | undefined,
|
prop: T | Binding<T | undefined> | undefined,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue