summaryrefslogtreecommitdiff
path: root/modules/launcher.tsx
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-01-16 16:35:37 +1100
committer2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-01-16 16:35:37 +1100
commit02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38 (patch)
tree5e2a56becf6ba6961995e541ce9688224f704773 /modules/launcher.tsx
parentpopupwindow: switch to class (diff)
downloadcaelestia-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.tsx390
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();
- }
-}