diff options
| author | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-03-01 16:35:18 +1100 |
|---|---|---|
| committer | 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> | 2025-03-01 16:35:18 +1100 |
| commit | 62602465ced5f65d4626f3cdf54b5fa30ba7c9dd (patch) | |
| tree | 50223190f6d9c5e6dfc72f6d6492e26290521a16 /src/modules | |
| parent | launcher: better files (diff) | |
| download | caelestia-shell-62602465ced5f65d4626f3cdf54b5fa30ba7c9dd.tar.gz caelestia-shell-62602465ced5f65d4626f3cdf54b5fa30ba7c9dd.tar.bz2 caelestia-shell-62602465ced5f65d4626f3cdf54b5fa30ba7c9dd.zip | |
launcher: actions + refactor into multi file
Action prefix is configurable
Diffstat (limited to 'src/modules')
| -rw-r--r-- | src/modules/launcher/actions.tsx | 201 | ||||
| -rw-r--r-- | src/modules/launcher/index.tsx | 136 | ||||
| -rw-r--r-- | src/modules/launcher/modes.tsx (renamed from src/modules/launcher.tsx) | 148 | ||||
| -rw-r--r-- | src/modules/launcher/util.tsx | 19 |
4 files changed, 370 insertions, 134 deletions
diff --git a/src/modules/launcher/actions.tsx b/src/modules/launcher/actions.tsx new file mode 100644 index 0000000..de9f454 --- /dev/null +++ b/src/modules/launcher/actions.tsx @@ -0,0 +1,201 @@ +import { Apps } from "@/services/apps"; +import { notify } from "@/utils/system"; +import { setupCustomTooltip, type FlowBox } from "@/utils/widgets"; +import { execAsync, GLib, readFile, register, type Variable } from "astal"; +import { Gtk, Widget } from "astal/gtk3"; +import { launcher as config } from "config"; +import fuzzysort from "fuzzysort"; +import AstalHyprland from "gi://AstalHyprland"; +import { close, ContentBox, type LauncherContent, type Mode } from "./util"; + +interface IAction { + icon: string; + name: string; + description: string; + action: (...args: string[]) => void; +} + +interface ActionMap { + [k: string]: IAction; +} + +const actions = (mode: Variable<Mode>, entry: Widget.Entry): ActionMap => ({ + apps: { + icon: "apps", + name: "Apps", + description: "Search for apps", + action: () => { + mode.set("apps"); + entry.set_text(""); + }, + }, + files: { + icon: "folder", + name: "Files", + description: "Search for files", + action: () => { + mode.set("files"); + entry.set_text(""); + }, + }, + math: { + icon: "calculate", + name: "Math", + description: "Do math calculations", + action: () => { + mode.set("math"); + entry.set_text(""); + }, + }, + windows: { + icon: "select_window", + name: "Windows", + description: "Manage open windows", + action: () => { + mode.set("windows"); + entry.set_text(""); + }, + }, + scheme: { + icon: "palette", + name: "Scheme", + description: "Change the current colour scheme", + action: (...args) => { + // If no args, autocomplete cmd + if (args.length === 0) { + entry.set_text(">scheme "); + entry.set_position(-1); + return; + } + + execAsync(`caelestia scheme ${args[0]}`).catch(console.error); + close(); + }, + }, + todo: { + icon: "checklist", + name: "Todo", + description: "Create a todo in Todoist", + action: (...args) => { + // If no args, autocomplete cmd + if (args.length === 0) { + entry.set_text(">todo "); + entry.set_position(-1); + return; + } + + // If tod not installed, notify + 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), + }, + }); + close(); + return; + } + + // If tod not configured, notify + 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", + }); + } else { + // Create todo and notify if configured + execAsync(`tod t q -c ${args.join(" ")}`).catch(console.error); + if (config.todo.notify.get()) + 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(); + }, + }, + reload: { + icon: "refresh", + name: "Reload", + description: "Reload app list", + action: () => { + Apps.reload(); + entry.set_text(""); + }, + }, +}); + +const Action = ({ args, icon, name, description, action }: IAction & { args: string[] }) => ( + <Gtk.FlowBoxChild visible canFocus={false}> + <button + className="result" + cursor="pointer" + onClicked={() => action(...args)} + setup={self => setupCustomTooltip(self, description)} + > + <box> + <label className="icon" label={icon} /> + <box vertical className="has-sublabel"> + <label truncate xalign={0} label={name} /> + <label truncate xalign={0} label={description} className="sublabel" /> + </box> + </box> + </button> + </Gtk.FlowBoxChild> +); + +@register() +export default class Actions extends Widget.Box implements LauncherContent { + #map: ActionMap; + #list: string[]; + + #content: FlowBox; + + constructor(mode: Variable<Mode>, entry: Widget.Entry) { + super({ name: "actions", className: "actions" }); + + this.#map = actions(mode, entry); + this.#list = Object.keys(this.#map); + + this.#content = (<ContentBox />) as FlowBox; + + this.add( + <scrollable expand hscroll={Gtk.PolicyType.NEVER}> + {this.#content} + </scrollable> + ); + } + + updateContent(search: string): void { + this.#content.foreach(c => c.destroy()); + const args = search.split(" "); + for (const { target } of fuzzysort.go(args[0].slice(1), this.#list, { all: true })) + this.#content.add(<Action {...this.#map[target]} args={args.slice(1)} />); + } + + handleActivate(): void { + this.#content.get_child_at_index(0)?.get_child()?.grab_focus(); + this.#content.get_child_at_index(0)?.get_child()?.activate(); + } +} diff --git a/src/modules/launcher/index.tsx b/src/modules/launcher/index.tsx new file mode 100644 index 0000000..2821050 --- /dev/null +++ b/src/modules/launcher/index.tsx @@ -0,0 +1,136 @@ +import PopupWindow from "@/widgets/popupwindow"; +import { bind, register, Variable } from "astal"; +import { Astal, Gtk, Widget } from "astal/gtk3"; +import { launcher as config } from "config"; +import Actions from "./actions"; +import Modes from "./modes"; +import type { Mode } from "./util"; + +const getModeIcon = (mode: Mode) => { + if (mode === "apps") return "apps"; + if (mode === "files") return "folder"; + if (mode === "math") return "calculate"; + if (mode === "windows") return "select_window"; + return "search"; +}; + +const getPrettyMode = (mode: Mode) => { + if (mode === "apps") return "Apps"; + if (mode === "files") return "Files"; + if (mode === "math") return "Math"; + if (mode === "windows") return "Windows"; + return mode; +}; + +const isAction = (text: string) => text.startsWith(config.actionPrefix.get()); + +const SearchBar = ({ mode, entry }: { mode: Variable<Mode>; entry: Widget.Entry }) => ( + <box className="search-bar"> + <label className="mode" label={bind(mode)} /> + {entry} + </box> +); + +const ModeSwitcher = ({ mode, modes }: { mode: Variable<Mode>; modes: Mode[] }) => ( + <box homogeneous hexpand className="mode-switcher"> + {modes.map(m => ( + <button + className={bind(mode).as(c => `mode ${c === m ? "selected" : ""}`)} + cursor="pointer" + onClicked={() => mode.set(m)} + > + <box halign={Gtk.Align.CENTER}> + <label className="icon" label={getModeIcon(m)} /> + <label label={getPrettyMode(m)} /> + </box> + </button> + ))} + </box> +); + +@register() +export default class Launcher extends PopupWindow { + readonly mode: Variable<Mode>; + + constructor() { + const entry = ( + <entry + hexpand + className="entry" + placeholderText={bind(config.actionPrefix).as(p => `Type "${p}" for subcommands`)} + /> + ) as Widget.Entry; + const mode = Variable<Mode>("apps"); + const content = Modes(); + const actions = new Actions(mode, entry); + + super({ + name: "launcher", + anchor: + Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT, + keymode: Astal.Keymode.EXCLUSIVE, + borderWidth: 0, + 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: ( + <box + vertical + halign={Gtk.Align.CENTER} + valign={Gtk.Align.CENTER} + className={bind(mode).as(m => `launcher ${m}`)} + > + <SearchBar mode={mode} entry={entry} /> + <stack + expand + transitionType={Gtk.StackTransitionType.CROSSFADE} + transitionDuration={100} + shown={bind(entry, "text").as(t => (isAction(t) ? "actions" : "content"))} + > + <stack + name="content" + transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT} + transitionDuration={200} + shown={bind(mode)} + > + {Object.values(content)} + </stack> + {actions} + </stack> + <ModeSwitcher mode={mode} modes={Object.keys(content) as Mode[]} /> + </box> + ), + }); + + this.mode = mode; + + content[mode.get()].updateContent(entry.get_text()); + this.hook(mode, (_, v: Mode) => { + entry.set_text(""); + content[v].updateContent(entry.get_text()); + }); + this.hook(entry, "changed", () => + (isAction(entry.get_text()) ? actions : content[mode.get()]).updateContent(entry.get_text()) + ); + this.hook(entry, "activate", () => + (isAction(entry.get_text()) ? actions : content[mode.get()]).handleActivate(entry.get_text()) + ); + + // 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("")); + } + + open(mode: Mode) { + this.mode.set(mode); + this.show(); + } +} diff --git a/src/modules/launcher.tsx b/src/modules/launcher/modes.tsx index e38eb96..4d54ad2 100644 --- a/src/modules/launcher.tsx +++ b/src/modules/launcher/modes.tsx @@ -1,42 +1,12 @@ import { Apps as AppsService } from "@/services/apps"; import { getAppCategoryIcon } from "@/utils/icons"; import { launch } from "@/utils/system"; -import { FlowBox, setupCustomTooltip } from "@/utils/widgets"; -import PopupWindow from "@/widgets/popupwindow"; -import { bind, execAsync, Gio, register, Variable } from "astal"; -import { App, Astal, Gtk, Widget } from "astal/gtk3"; +import { type FlowBox, setupCustomTooltip } from "@/utils/widgets"; +import { execAsync, Gio, register } from "astal"; +import { Astal, Gtk, Widget } from "astal/gtk3"; import { launcher as config } from "config"; import type AstalApps from "gi://AstalApps"; - -type Mode = "apps" | "files" | "math" | "windows"; - -interface ModeContent { - updateContent(search: string): void; - handleActivate(search: string): void; -} - -const close = () => App.get_window("launcher")?.hide(); - -const getModeIcon = (mode: Mode) => { - if (mode === "apps") return "apps"; - if (mode === "files") return "folder"; - if (mode === "math") return "calculate"; - if (mode === "windows") return "select_window"; - return "search"; -}; - -const getPrettyMode = (mode: Mode) => { - if (mode === "apps") return "Apps"; - if (mode === "files") return "Files"; - if (mode === "math") return "Math"; - if (mode === "windows") return "Windows"; - return mode; -}; - -const limitLength = <T,>(arr: T[], cfg: { maxResults: Variable<number> }) => - cfg.maxResults.get() > 0 && arr.length > cfg.maxResults.get() ? arr.slice(0, cfg.maxResults.get()) : arr; - -const ContentBox = () => <FlowBox homogeneous valign={Gtk.Align.START} minChildrenPerLine={2} maxChildrenPerLine={2} />; +import { close, ContentBox, type LauncherContent, limitLength } from "./util"; const AppResult = ({ app }: { app: AstalApps.Application }) => ( <Gtk.FlowBoxChild visible canFocus={false}> @@ -102,7 +72,7 @@ const FileResult = ({ path }: { path: string }) => ( ); @register() -class Apps extends Widget.Box implements ModeContent { +class Apps extends Widget.Box implements LauncherContent { #content: FlowBox; constructor() { @@ -130,7 +100,7 @@ class Apps extends Widget.Box implements ModeContent { } @register() -class Files extends Widget.Box implements ModeContent { +class Files extends Widget.Box implements LauncherContent { #content: FlowBox; constructor() { @@ -162,7 +132,7 @@ class Files extends Widget.Box implements ModeContent { } @register() -class Math extends Widget.Box implements ModeContent { +class Math extends Widget.Box implements LauncherContent { constructor() { super({ name: "math", className: "math" }); } @@ -177,7 +147,7 @@ class Math extends Widget.Box implements ModeContent { } @register() -class Windows extends Widget.Box implements ModeContent { +class Windows extends Widget.Box implements LauncherContent { constructor() { super({ name: "windows", className: "windows" }); } @@ -191,99 +161,9 @@ class Windows extends Widget.Box implements ModeContent { } } -const SearchBar = ({ mode, entry }: { mode: Variable<Mode>; entry: Widget.Entry }) => ( - <box className="search-bar"> - <label className="mode" label={bind(mode)} /> - {entry} - </box> -); - -const ModeSwitcher = ({ mode, modes }: { mode: Variable<Mode>; modes: Mode[] }) => ( - <box homogeneous hexpand className="mode-switcher"> - {modes.map(m => ( - <button - className={bind(mode).as(c => `mode ${c === m ? "selected" : ""}`)} - cursor="pointer" - onClicked={() => mode.set(m)} - > - <box halign={Gtk.Align.CENTER}> - <label className="icon" label={getModeIcon(m)} /> - <label label={getPrettyMode(m)} /> - </box> - </button> - ))} - </box> -); - -@register() -export default class Launcher extends PopupWindow { - readonly mode: Variable<Mode>; - - constructor() { - const entry = (<entry hexpand className="entry" />) as Widget.Entry; - const mode = Variable<Mode>("apps"); - const content = { - apps: new Apps(), - files: new Files(), - math: new Math(), - windows: new Windows(), - }; - - super({ - name: "launcher", - anchor: - Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT, - keymode: Astal.Keymode.EXCLUSIVE, - borderWidth: 0, - 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: ( - <box - vertical - halign={Gtk.Align.CENTER} - valign={Gtk.Align.CENTER} - className={bind(mode).as(m => `launcher ${m}`)} - > - <SearchBar mode={mode} entry={entry} /> - <stack - expand - transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT} - transitionDuration={200} - shown={bind(mode)} - > - {Object.values(content)} - </stack> - <ModeSwitcher mode={mode} modes={Object.keys(content) as Mode[]} /> - </box> - ), - }); - - this.mode = mode; - - content[mode.get()].updateContent(entry.get_text()); - this.hook(mode, (_, v: Mode) => { - entry.set_text(""); - content[v].updateContent(entry.get_text()); - }); - this.hook(entry, "changed", () => content[mode.get()].updateContent(entry.get_text())); - this.hook(entry, "activate", () => content[mode.get()].handleActivate(entry.get_text())); - - // 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("")); - } - - open(mode: Mode) { - this.mode.set(mode); - this.show(); - } -} +export default () => ({ + apps: new Apps(), + files: new Files(), + math: new Math(), + windows: new Windows(), +}); diff --git a/src/modules/launcher/util.tsx b/src/modules/launcher/util.tsx new file mode 100644 index 0000000..3c4e8bf --- /dev/null +++ b/src/modules/launcher/util.tsx @@ -0,0 +1,19 @@ +import { FlowBox } from "@/utils/widgets"; +import type { Variable } from "astal"; +import { App, Gtk } from "astal/gtk3"; + +export type Mode = "apps" | "files" | "math" | "windows"; + +export interface LauncherContent { + updateContent(search: string): void; + handleActivate(search: string): void; +} + +export const close = () => App.get_window("launcher")?.hide(); + +export const limitLength = <T,>(arr: T[], cfg: { maxResults: Variable<number> }) => + cfg.maxResults.get() > 0 && arr.length > cfg.maxResults.get() ? arr.slice(0, cfg.maxResults.get()) : arr; + +export const ContentBox = () => ( + <FlowBox homogeneous valign={Gtk.Align.START} minChildrenPerLine={2} maxChildrenPerLine={2} /> +); |