launcher: actions + refactor into multi file

Action prefix is configurable
This commit is contained in:
2 * r + 2 * t 2025-03-01 16:35:18 +11:00
parent e16bf9a9ad
commit 62602465ce
6 changed files with 392 additions and 182 deletions

View file

@ -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: [

View file

@ -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;
.sublabel {
color: color.mix(scheme.$red, scheme.$base, 70%);
}
}
&: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;
}
}
.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;
}
}
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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(),
});

View file

@ -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} />
);