import { bind, execAsync, Gio, GLib, register, timeout, Variable } from "astal"; import { Astal, Gtk, Widget } from "astal/gtk3"; import fuzzysort from "fuzzysort"; import type AstalApps from "gi://AstalApps"; import AstalHyprland from "gi://AstalHyprland"; import { launcher as config } from "../../config"; import { Apps } from "../services/apps"; import Math, { type HistoryItem } from "../services/math"; import { getAppCategoryIcon } from "../utils/icons"; import { launch } from "../utils/system"; import { setupCustomTooltip } from "../utils/widgets"; import PopupWindow from "../widgets/popupwindow"; type Mode = "apps" | "files" | "math"; interface Subcommand { icon: string; name: string; description: string; command: (...args: string[]) => void; } const getIconFromMode = (mode: Mode) => { switch (mode) { case "apps": return "apps"; case "files": return "folder"; case "math": return "calculate"; } }; 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"; } }; 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 app ? ( ) : null; }; const PinnedApps = () => {config.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, onClicked, }: { icon?: string; materialIcon?: string; label: string; sublabel?: string; onClicked: (self: Widget.Button) => void; }) => ( ); const SubcommandResult = ({ entry, subcommand, args, }: { entry: Widget.Entry; subcommand: Subcommand; args: string[]; }) => ( { subcommand.command(...args); entry.set_text(""); }} /> ); const AppResult = ({ app }: { app: AstalApps.Application }) => ( launchAndClose(self, app)} /> ); const MathResult = ({ math, isHistory, entry }: { math: HistoryItem; isHistory?: boolean; entry: Widget.Entry }) => ( { if (isHistory) { Math.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 }) => ( openFileAndClose(self, path)} /> ); const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable }) => { const empty = Variable(true); return ( (t ? "empty" : "list"))} > { 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"), }, todo: { icon: "checklist", name: "Todo", description: "Create a todo in ", command: (...args) => { // TODO: todo service or maybe use external app }, }, }; const subcommandList = Object.keys(subcommands); const updateEmpty = () => empty.set(self.get_children().length === 0); const appSearch = () => { const apps = Apps.fuzzy_query(entry.text); if (apps.length > config.maxResults) apps.length = config.maxResults; for (const app of apps) self.add(); }; const calculate = () => { if (entry.text) { self.add(); self.add(); } for (const item of Math.get_default().history) self.add(); }; const fileSearch = () => execAsync(["fd", ...config.fdOpts, entry.text, HOME]) .then(out => { const paths = out.split("\n").filter(path => path); if (paths.length > config.maxResults) paths.length = config.maxResults; self.foreach(ch => ch.destroy()); for (const path of paths) self.add(); }) .catch(e => { // Ignore execAsync error if (!(e instanceof Gio.IOErrorEnum || e instanceof GLib.SpawnError)) console.error(e); }) .finally(updateEmpty); self.hook(entry, "activate", () => { if (mode.get() === "math") { if (entry.text.startsWith("clear")) Math.get_default().clear(); else Math.get_default().commit(); } self.get_children()[0]?.activate(); }); self.hook(entry, "changed", () => { if (!entry.text && mode.get() === "apps") return; // Files has delay cause async so it does some stuff by itself const ignoreFileAsync = entry.text.startsWith(">") || mode.get() !== "files"; 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(); if (ignoreFileAsync) updateEmpty(); }); }} /> ); }; 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 this.connect("hide", () => mode.get() !== "math" && entry.set_text("")); this.connect("destroy", () => showResults.drop()); } open(mode: Mode) { this.mode.set(mode); this.show(); } }