summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-03-01 16:35:18 +1100
committer2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-03-01 16:35:18 +1100
commit62602465ced5f65d4626f3cdf54b5fa30ba7c9dd (patch)
tree50223190f6d9c5e6dfc72f6d6492e26290521a16
parentlauncher: better files (diff)
downloadcaelestia-shell-62602465ced5f65d4626f3cdf54b5fa30ba7c9dd.tar.gz
caelestia-shell-62602465ced5f65d4626f3cdf54b5fa30ba7c9dd.tar.bz2
caelestia-shell-62602465ced5f65d4626f3cdf54b5fa30ba7c9dd.zip
launcher: actions + refactor into multi file
Action prefix is configurable
-rw-r--r--config.ts2
-rw-r--r--scss/launcher.scss68
-rw-r--r--src/modules/launcher/actions.tsx201
-rw-r--r--src/modules/launcher/index.tsx136
-rw-r--r--src/modules/launcher/modes.tsx (renamed from src/modules/launcher.tsx)148
-rw-r--r--src/modules/launcher/util.tsx19
6 files changed, 392 insertions, 182 deletions
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/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} />
+);