From 62602465ced5f65d4626f3cdf54b5fa30ba7c9dd Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Sat, 1 Mar 2025 16:35:18 +1100 Subject: launcher: actions + refactor into multi file Action prefix is configurable --- config.ts | 2 +- scss/launcher.scss | 68 +++------ src/modules/launcher.tsx | 289 --------------------------------------- src/modules/launcher/actions.tsx | 201 +++++++++++++++++++++++++++ src/modules/launcher/index.tsx | 136 ++++++++++++++++++ src/modules/launcher/modes.tsx | 169 +++++++++++++++++++++++ src/modules/launcher/util.tsx | 19 +++ 7 files changed, 547 insertions(+), 337 deletions(-) delete mode 100644 src/modules/launcher.tsx create mode 100644 src/modules/launcher/actions.tsx create mode 100644 src/modules/launcher/index.tsx create mode 100644 src/modules/launcher/modes.tsx create mode 100644 src/modules/launcher/util.tsx diff --git a/config.ts b/config.ts index a25651f..89398a0 100644 --- a/config.ts +++ b/config.ts @@ -89,7 +89,7 @@ const DEFAULTS = { }, }, launcher: { - maxResults: 15, // Max shown results at one time (i.e. max height of the launcher) + actionPrefix: ":", // Prefix for launcher actions apps: { maxResults: 30, // Actual max results, -1 for infinite pins: [ diff --git a/scss/launcher.scss b/scss/launcher.scss index 3c4ff0b..8d3d6a2 100644 --- a/scss/launcher.scss +++ b/scss/launcher.scss @@ -5,6 +5,10 @@ @mixin launcher($mode, $colour) { &.#{$mode} { + label.icon { + color: $colour; + } + .search-bar { .mode { @include lib.border($colour, $width: 2); @@ -87,10 +91,23 @@ font-size: lib.s(32); } + .has-sublabel { + padding: lib.s(3) 0; + } + + .sublabel { + color: scheme.$subtext0; + font-size: lib.s(14); + } + & > box { @include lib.spacing(10); } + &.italic { + font-style: italic; + } + &:hover { background-color: scheme.$surface0; } @@ -98,57 +115,14 @@ &:focus { border-bottom: lib.s(2) solid scheme.$red; color: scheme.$red; - } - - &:active { - background-color: scheme.$surface1; - } - } - - .pinned-app { - @include lib.rounded(5); - - font-size: lib.s(64); - } - .results { - .icon { - font-size: lib.s(32); - } - - .empty { - color: scheme.$subtext0; - font-size: lib.s(18); - padding: lib.s(10) 0; - - @include lib.spacing; - - .icon { - color: scheme.$subtext0; + .sublabel { + color: color.mix(scheme.$red, scheme.$base, 70%); } } - .result { - @include lib.rounded(10); - - font-size: lib.s(18); - - & > * { - @include lib.spacing(8); - } - - .has-sublabel { - font-size: lib.s(16); - - .sublabel { - color: scheme.$subtext0; - font-size: lib.s(14); - } - } - - &.italic { - font-style: italic; - } + &:active { + background-color: scheme.$surface1; } } } diff --git a/src/modules/launcher.tsx b/src/modules/launcher.tsx deleted file mode 100644 index e38eb96..0000000 --- a/src/modules/launcher.tsx +++ /dev/null @@ -1,289 +0,0 @@ -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 { 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 = (arr: T[], cfg: { maxResults: Variable }) => - cfg.maxResults.get() > 0 && arr.length > cfg.maxResults.get() ? arr.slice(0, cfg.maxResults.get()) : arr; - -const ContentBox = () => ; - -const AppResult = ({ app }: { app: AstalApps.Application }) => ( - - - -); - -const FileResult = ({ path }: { path: string }) => ( - - - -); - -@register() -class Apps extends Widget.Box implements ModeContent { - #content: FlowBox; - - constructor() { - super({ name: "apps", className: "apps" }); - - this.#content = () as FlowBox; - - this.add( - - {this.#content} - - ); - } - - updateContent(search: string): void { - this.#content.foreach(c => c.destroy()); - for (const app of limitLength(AppsService.fuzzy_query(search), config.apps)) - this.#content.add(); - } - - handleActivate(): void { - this.#content.get_child_at_index(0)?.get_child()?.grab_focus(); - this.#content.get_child_at_index(0)?.get_child()?.activate(); - } -} - -@register() -class Files extends Widget.Box implements ModeContent { - #content: FlowBox; - - constructor() { - super({ name: "files", className: "files" }); - - this.#content = () as FlowBox; - - this.add( - - {this.#content} - - ); - } - - updateContent(search: string): void { - execAsync(["fd", ...config.files.fdOpts.get(), search, HOME]) - .then(out => { - this.#content.foreach(c => c.destroy()); - const paths = out.split("\n").filter(path => path); - for (const path of limitLength(paths, config.files)) this.#content.add(); - }) - .catch(() => {}); // Ignore errors - } - - handleActivate(): void { - this.#content.get_child_at_index(0)?.get_child()?.grab_focus(); - this.#content.get_child_at_index(0)?.get_child()?.activate(); - } -} - -@register() -class Math extends Widget.Box implements ModeContent { - constructor() { - super({ name: "math", className: "math" }); - } - - updateContent(search: string): void { - throw new Error("Method not implemented."); - } - - handleActivate(search: string): void { - throw new Error("Method not implemented."); - } -} - -@register() -class Windows extends Widget.Box implements ModeContent { - constructor() { - super({ name: "windows", className: "windows" }); - } - - updateContent(search: string): void { - throw new Error("Method not implemented."); - } - - handleActivate(search: string): void { - throw new Error("Method not implemented."); - } -} - -const SearchBar = ({ mode, entry }: { mode: Variable; entry: Widget.Entry }) => ( - - -); - -const ModeSwitcher = ({ mode, modes }: { mode: Variable; modes: Mode[] }) => ( - - {modes.map(m => ( - - ))} - -); - -@register() -export default class Launcher extends PopupWindow { - readonly mode: Variable; - - constructor() { - const entry = () as Widget.Entry; - const mode = Variable("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: ( - `launcher ${m}`)} - > - - - {Object.values(content)} - - - - ), - }); - - 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(); - } -} 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, 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[] }) => ( + + + +); + +@register() +export default class Actions extends Widget.Box implements LauncherContent { + #map: ActionMap; + #list: string[]; + + #content: FlowBox; + + constructor(mode: Variable, entry: Widget.Entry) { + super({ name: "actions", className: "actions" }); + + this.#map = actions(mode, entry); + this.#list = Object.keys(this.#map); + + this.#content = () as FlowBox; + + this.add( + + {this.#content} + + ); + } + + 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(); + } + + 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; entry: Widget.Entry }) => ( + + +); + +const ModeSwitcher = ({ mode, modes }: { mode: Variable; modes: Mode[] }) => ( + + {modes.map(m => ( + + ))} + +); + +@register() +export default class Launcher extends PopupWindow { + readonly mode: Variable; + + constructor() { + const entry = ( + `Type "${p}" for subcommands`)} + /> + ) as Widget.Entry; + const mode = Variable("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: ( + `launcher ${m}`)} + > + + (isAction(t) ? "actions" : "content"))} + > + + {Object.values(content)} + + {actions} + + + + ), + }); + + 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/modes.tsx b/src/modules/launcher/modes.tsx new file mode 100644 index 0000000..4d54ad2 --- /dev/null +++ b/src/modules/launcher/modes.tsx @@ -0,0 +1,169 @@ +import { Apps as AppsService } from "@/services/apps"; +import { getAppCategoryIcon } from "@/utils/icons"; +import { launch } from "@/utils/system"; +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"; +import { close, ContentBox, type LauncherContent, limitLength } from "./util"; + +const AppResult = ({ app }: { app: AstalApps.Application }) => ( + + + +); + +const FileResult = ({ path }: { path: string }) => ( + + + +); + +@register() +class Apps extends Widget.Box implements LauncherContent { + #content: FlowBox; + + constructor() { + super({ name: "apps", className: "apps" }); + + this.#content = () as FlowBox; + + this.add( + + {this.#content} + + ); + } + + updateContent(search: string): void { + this.#content.foreach(c => c.destroy()); + for (const app of limitLength(AppsService.fuzzy_query(search), config.apps)) + this.#content.add(); + } + + handleActivate(): void { + this.#content.get_child_at_index(0)?.get_child()?.grab_focus(); + this.#content.get_child_at_index(0)?.get_child()?.activate(); + } +} + +@register() +class Files extends Widget.Box implements LauncherContent { + #content: FlowBox; + + constructor() { + super({ name: "files", className: "files" }); + + this.#content = () as FlowBox; + + this.add( + + {this.#content} + + ); + } + + updateContent(search: string): void { + execAsync(["fd", ...config.files.fdOpts.get(), search, HOME]) + .then(out => { + this.#content.foreach(c => c.destroy()); + const paths = out.split("\n").filter(path => path); + for (const path of limitLength(paths, config.files)) this.#content.add(); + }) + .catch(() => {}); // Ignore errors + } + + handleActivate(): void { + this.#content.get_child_at_index(0)?.get_child()?.grab_focus(); + this.#content.get_child_at_index(0)?.get_child()?.activate(); + } +} + +@register() +class Math extends Widget.Box implements LauncherContent { + constructor() { + super({ name: "math", className: "math" }); + } + + updateContent(search: string): void { + throw new Error("Method not implemented."); + } + + handleActivate(search: string): void { + throw new Error("Method not implemented."); + } +} + +@register() +class Windows extends Widget.Box implements LauncherContent { + constructor() { + super({ name: "windows", className: "windows" }); + } + + updateContent(search: string): void { + throw new Error("Method not implemented."); + } + + handleActivate(search: string): void { + throw new Error("Method not implemented."); + } +} + +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 = (arr: T[], cfg: { maxResults: Variable }) => + cfg.maxResults.get() > 0 && arr.length > cfg.maxResults.get() ? arr.slice(0, cfg.maxResults.get()) : arr; + +export const ContentBox = () => ( + +); -- cgit v1.2.3-freya