diff options
Diffstat (limited to 'src/modules/launcher.tsx')
| -rw-r--r-- | src/modules/launcher.tsx | 391 |
1 files changed, 391 insertions, 0 deletions
diff --git a/src/modules/launcher.tsx b/src/modules/launcher.tsx new file mode 100644 index 0000000..2fc6eef --- /dev/null +++ b/src/modules/launcher.tsx @@ -0,0 +1,391 @@ +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 ? ( + <button + className="pinned-app result" + cursor="pointer" + onClicked={self => launchAndClose(self, astalApp!)} + setup={self => setupCustomTooltip(self, app.get_display_name())} + > + <icon gicon={app.get_icon()!} /> + </button> + ) : null; +}; + +const PinnedApps = () => <box homogeneous>{config.pins.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, + onClicked, +}: { + icon?: string; + materialIcon?: string; + label: string; + sublabel?: string; + onClicked: (self: Widget.Button) => void; +}) => ( + <button className="result" cursor="pointer" onClicked={onClicked}> + <box> + {icon && Astal.Icon.lookup_icon(icon) ? ( + <icon valign={Gtk.Align.START} className="icon" 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 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={() => { + subcommand.command(...args); + entry.set_text(""); + }} + /> +); + +const AppResult = ({ app }: { app: AstalApps.Application }) => ( + <Result + icon={app.iconName} + materialIcon={getAppCategoryIcon(app)} + label={app.name} + sublabel={app.description} + onClicked={self => launchAndClose(self, app)} + /> +); + +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) { + 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 }) => ( + <Result + label={path.split("/").pop()!} + sublabel={path.startsWith(HOME) ? "~" + path.slice(HOME.length) : path} + onClicked={self => openFileAndClose(self, path)} + /> +); + +const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> }) => { + const empty = Variable(true); + + 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> + <box + vertical + name="list" + 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"), + }, + todo: { + icon: "checklist", + name: "Todo", + description: "Create a todo in <INSERT_TODO_APP>", + 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(<AppResult app={app} />); + }; + + const calculate = () => { + if (entry.text) { + self.add(<MathResult math={Math.get_default().evaluate(entry.text)} entry={entry} />); + self.add(<box className="separator" />); + } + for (const item of Math.get_default().history) + self.add(<MathResult isHistory math={item} entry={entry} />); + }; + + 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(<FileResult path={path} />); + }) + .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( + <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(); + + if (ignoreFileAsync) updateEmpty(); + }); + }} + /> + </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 + this.connect("hide", () => mode.get() !== "math" && entry.set_text("")); + + this.connect("destroy", () => showResults.drop()); + } + + open(mode: Mode) { + this.mode.set(mode); + this.show(); + } +} |