diff options
| author | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-01-16 16:35:37 +1100 |
|---|---|---|
| committer | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-01-16 16:35:37 +1100 |
| commit | 02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38 (patch) | |
| tree | 5e2a56becf6ba6961995e541ce9688224f704773 /modules/launcher.tsx | |
| parent | popupwindow: switch to class (diff) | |
| download | caelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.tar.gz caelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.tar.bz2 caelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.zip | |
refactor: move ts to src
Also move popupwindow to own file
Diffstat (limited to 'modules/launcher.tsx')
| -rw-r--r-- | modules/launcher.tsx | 390 |
1 files changed, 0 insertions, 390 deletions
diff --git a/modules/launcher.tsx b/modules/launcher.tsx deleted file mode 100644 index 966cdfd..0000000 --- a/modules/launcher.tsx +++ /dev/null @@ -1,390 +0,0 @@ -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 { PopupWindow, setupCustomTooltip } from "../utils/widgets"; - -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(); - } -} |