nosignal-shell/src/modules/launcher.tsx
2025-02-28 12:48:42 +11:00

224 lines
7 KiB
TypeScript

import { Apps as AppsService } from "@/services/apps";
import { getAppCategoryIcon } from "@/utils/icons";
import { launch } from "@/utils/system";
import { FlowBox } from "@/utils/widgets";
import PopupWindow from "@/widgets/popupwindow";
import { bind, register, Variable } from "astal";
import { App, Astal, Gtk, Widget } from "astal/gtk3";
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 AppResult = ({ app }: { app: AstalApps.Application }) => (
<Gtk.FlowBoxChild visible canFocus={false}>
<button
className="result"
cursor="pointer"
onClicked={() => {
launch(app);
close();
}}
>
<box>
{app.iconName && Astal.Icon.lookup_icon(app.iconName) ? (
<icon className="icon" icon={app.iconName} />
) : (
<label className="icon" label={getAppCategoryIcon(app)} />
)}
<label truncate label={app.name} />
</box>
</button>
</Gtk.FlowBoxChild>
);
@register()
class Apps extends Widget.Box implements ModeContent {
#content: FlowBox;
constructor() {
super({ name: "apps", className: "apps" });
this.#content = (<FlowBox homogeneous valign={Gtk.Align.START} maxChildrenPerLine={2} />) as FlowBox;
this.add(
<scrollable expand hscroll={Gtk.PolicyType.NEVER}>
{this.#content}
</scrollable>
);
}
updateContent(search: string): void {
this.#content.foreach(c => c.destroy());
AppsService.fuzzy_query(search).forEach(app => this.#content.add(<AppResult app={app} />));
}
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 {
constructor() {
super({ name: "files", className: "files" });
}
updateContent(search: string): void {
throw new Error("Method not implemented.");
}
handleActivate(search: string): void {
throw new Error("Method not implemented.");
}
}
@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<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();
}
}