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 = (arr: T[], cfg: { maxResults: number }) => cfg.maxResults > 0 && arr.length > cfg.maxResults ? arr.slice(0, cfg.maxResults) : 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 = ( ); return widget; }; const PinnedApps = () => {config.apps.pins.map(PinnedApp)}; const SearchEntry = ({ entry }: { entry: Widget.Entry }) => ( 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")) ) } > ); const Result = ({ icon, materialIcon, label, sublabel, tooltip, onClicked, onSecondaryClick, onMiddleClick, onDestroy, }: { icon?: string | Gio.Icon | null; materialIcon?: string; label: string; sublabel?: string; tooltip?: string; onClicked: (self: Widget.Button) => void; onSecondaryClick?: (self: Widget.Button) => void; onMiddleClick?: (self: Widget.Button) => void; onDestroy?: () => void; }) => ( ); const SubcommandResult = ({ entry, subcommand, args, }: { entry: Widget.Entry; subcommand: Subcommand; args: string[]; }) => ( { 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 = ( 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 }) => ( { 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 = ( 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 = ( { 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 }) => { const empty = Variable(true); const scrollable = ( { const subcommands: Record = { 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) 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(); }; const calculate = () => { if (entry.text) { self.add( ); self.add(); } for (const item of limitLength(MathService.get_default().history, config.math)) self.add(); }; const fileSearch = () => execAsync(["fd", ...config.files.fdOpts, 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(); }) .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 < 0 ? undefined : config.windows.maxResults, keys: ["title", "class", "initialTitle", "initialClass"], scoreFn: r => r[0].score * config.windows.weights.title + r[1].score * config.windows.weights.class + r[2].score * config.windows.weights.initialTitle + r[3].score * config.windows.weights.initialClass, }); self.foreach(ch => ch.destroy()); for (const { obj } of clients) self.add(); } 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(); } } 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( ); } 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(); }); }} /> ) as Widget.Scrollable; return ( (t ? "empty" : "list"))} > {scrollable} ); }; const LauncherContent = ({ mode, showResults, entry, }: { mode: Variable; showResults: Variable; entry: Widget.Entry; }) => ( `launcher ${m}`)}> !s)} transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} transitionDuration={150} > ); @register() export default class Launcher extends PopupWindow { readonly mode: Variable; constructor() { const entry = () as Widget.Entry; const mode = Variable("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: , }); 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(); } }