app launcher

This commit is contained in:
2 * r + 2 * t 2025-01-14 00:22:59 +11:00
parent 6a66214b8f
commit e923d39cde
7 changed files with 412 additions and 8 deletions

16
app.tsx
View file

@ -2,7 +2,9 @@ import { execAsync, GLib, writeFileAsync } from "astal";
import { App } from "astal/gtk3";
import AstalHyprland from "gi://AstalHyprland";
import Bar from "./modules/bar";
import Launcher from "./modules/launcher";
import NotifPopups from "./modules/notifpopups";
import { PopupWindow } from "./utils/widgets";
const loadStyleAsync = async () => {
if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS))
@ -17,15 +19,25 @@ App.start({
main() {
loadStyleAsync().catch(console.error);
<Launcher />;
<NotifPopups />;
AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />);
console.log("Caelestia started");
},
requestHandler(request, res) {
let log = true;
if (request === "reload css") loadStyleAsync().catch(console.error);
else return res("Unknown command: " + request);
console.log(`Request handled: ${request}`);
else if (request.startsWith("toggle")) {
const window = App.get_window(request.slice(7));
if (window instanceof PopupWindow) window.toggle();
else App.toggle_window(request.slice(7));
log = false;
} else return res("Unknown command: " + request);
if (log) console.log(`Request handled: ${request}`);
res("OK");
},
});

190
modules/launcher.tsx Normal file
View file

@ -0,0 +1,190 @@
import { bind, Gio, timeout, Variable } from "astal";
import { Astal, Gtk, Widget } from "astal/gtk3";
import type AstalApps from "gi://AstalApps";
import AstalHyprland from "gi://AstalHyprland";
import { Apps } from "../services/apps";
import { getAppCategoryIcon } from "../utils/icons";
import { launch } from "../utils/system";
import { PopupWindow, setupCustomTooltip, TransitionType } from "../utils/widgets";
const maxSearchResults = 15;
const browser = [
"firefox",
"waterfox",
"google-chrome",
"chromium",
"brave-browser",
"vivaldi-stable",
"vivaldi-snapshot",
];
const terminal = ["foot", "alacritty", "kitty", "wezterm"];
const files = ["thunar", "nemo", "nautilus"];
const ide = ["codium", "code", "clion", "intellij-idea-ultimate-edition"];
const music = ["spotify-adblock", "spotify", "audacious", "elisa"];
const launchAndClose = (self: JSX.Element, astalApp: AstalApps.Application) => {
const toplevel = self.get_toplevel();
if (toplevel instanceof Widget.Window) toplevel.hide();
launch(astalApp);
};
const PinnedApp = ({ names }: { names: string[] }) => {
let app: Gio.DesktopAppInfo | null = null;
let astalApp: AstalApps.Application | undefined;
for (const name of names) {
app = Gio.DesktopAppInfo.new(`${name}.desktop`);
if (app) {
astalApp = Apps.get_list().find(a => a.entry === `${name}.desktop`);
if (app.get_icon() && astalApp) break;
else app = null; // Set app to null if no icon or matching AstalApps#Application
}
}
if (!app) console.error(`Launcher - Unable to find app for "${names.join(", ")}"`);
return app ? (
<button
className="app"
cursor="pointer"
onClicked={self => launchAndClose(self, astalApp!)}
setup={self => setupCustomTooltip(self, app.get_display_name())}
>
<icon gicon={app.get_icon()!} />
</button>
) : null;
};
const PinnedApps = () => (
<box homogeneous className="pinned">
<PinnedApp names={browser} />
<PinnedApp names={terminal} />
<PinnedApp names={files} />
<PinnedApp names={ide} />
<PinnedApp names={music} />
</box>
);
const SearchEntry = ({ entry }: { entry: Widget.Entry }) => (
<stack
hexpand
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={150}
setup={self =>
self.hook(entry, "notify::text-length", () =>
// Timeout to avoid flickering when replacing entire text (cause it'll set len to 0 then back to > 0)
timeout(1, () => (self.shown = entry.textLength > 0 ? "entry" : "placeholder"))
)
}
>
<label name="placeholder" className="placeholder" xalign={0} label='Type ">" for subcommands' />
{entry}
</stack>
);
const Result = ({ app }: { app: AstalApps.Application }) => (
<button className="app" cursor="pointer" onClicked={self => launchAndClose(self, app)}>
<box>
{Astal.Icon.lookup_icon(app.iconName) ? (
<icon className="icon" icon={app.iconName} />
) : (
<label className="icon" label={getAppCategoryIcon(app)} />
)}
<label xalign={0} label={app.name} />
</box>
</button>
);
const Results = ({ entry }: { entry: Widget.Entry }) => {
const empty = Variable(true);
return (
<stack
className="results"
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={150}
shown={bind(empty).as(t => (t ? "empty" : "list"))}
>
<box name="empty" className="empty" halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER}>
<label className="icon" label="apps_outage" />
<label label="No results" />
</box>
<box
vertical
name="list"
setup={self => {
let apps: AstalApps.Application[] = [];
self.hook(entry, "activate", () => {
if (entry.text && apps[0]) launchAndClose(self, apps[0]);
});
self.hook(entry, "changed", () => {
if (!entry.text) return;
self.foreach(ch => ch.destroy());
apps = Apps.fuzzy_query(entry.text);
empty.set(apps.length === 0);
if (apps.length > maxSearchResults) apps.length = maxSearchResults;
for (const app of apps) self.add(<Result app={app} />);
});
}}
/>
</stack>
);
};
const Launcher = ({ entry }: { entry: Widget.Entry }) => (
<box
vertical
className="launcher"
css={bind(AstalHyprland.get_default(), "focusedMonitor").as(m => `margin-top: ${m.height / 4}px;`)}
>
<box className="search-bar">
<label className="icon" label="search" />
<SearchEntry entry={entry} />
<label className="icon" label="apps" />
</box>
<revealer
revealChild={bind(entry, "textLength").as(t => t === 0)}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
>
<PinnedApps />
</revealer>
<revealer
revealChild={bind(entry, "textLength").as(t => t > 0)}
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
transitionDuration={150}
>
<Results entry={entry} />
</revealer>
</box>
);
export default () => {
const entry = (<entry name="entry" />) as Widget.Entry;
return (
<PopupWindow
name="launcher"
keymode={Astal.Keymode.EXCLUSIVE}
exclusivity={Astal.Exclusivity.IGNORE}
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;
}
}}
// Clear entry text on hide
setup={self => self.connect("hide", () => entry.set_text(""))}
transitionType={TransitionType.SLIDE_DOWN}
halign={Gtk.Align.CENTER}
valign={Gtk.Align.START}
>
<Launcher entry={entry} />
</PopupWindow>
);
};

80
scss/launcher.scss Normal file
View file

@ -0,0 +1,80 @@
@use "scheme";
@use "lib";
@use "font";
.launcher {
@include lib.rounded(10);
@include lib.border(scheme.$overlay0, 0.2);
@include lib.shadow;
@include lib.element-decel;
@include font.main;
background-color: scheme.$mantle;
color: scheme.$text;
padding: lib.s(10) lib.s(14);
.search-bar {
@include lib.spacing;
margin-bottom: lib.s(5);
font-size: lib.s(16);
.icon {
font-size: lib.s(18);
color: scheme.$sapphire;
}
.placeholder {
color: scheme.$subtext0;
}
}
.app {
@include lib.element-decel;
padding: lib.s(5) lib.s(10);
&:hover,
&:focus {
background-color: scheme.$surface0;
}
&:active {
background-color: scheme.$surface1;
}
}
.pinned {
.app {
@include lib.rounded(5);
font-size: lib.s(64);
}
}
.results {
.empty {
@include lib.spacing;
color: scheme.$subtext0;
.icon {
font-size: lib.s(32);
}
}
.app {
@include lib.rounded(10);
font-size: lib.s(18);
& > * {
@include lib.spacing;
}
.icon {
font-size: lib.s(32);
}
}
}
}

View file

@ -4,6 +4,7 @@
// Modules
@use "scss/bar";
@use "scss/notifpopups";
@use "scss/launcher";
* {
all: unset; // Remove GTK theme styles

View file

@ -1,4 +1,5 @@
import { Gio } from "astal";
import type AstalApps from "gi://AstalApps";
import { Apps } from "../services/apps";
// Code points from https://www.github.com/lukas-w/font-logos
@ -73,10 +74,12 @@ const categoryIcons: Record<string, string> = {
System: "host",
};
export const getAppCategoryIcon = (name: string) => {
export const getAppCategoryIcon = (nameOrApp: string | AstalApps.Application) => {
const categories =
Gio.DesktopAppInfo.new(`${name}.desktop`)?.get_categories()?.split(";") ??
Apps.fuzzy_query(name)[0]?.categories;
typeof nameOrApp === "string"
? Gio.DesktopAppInfo.new(`${nameOrApp}.desktop`)?.get_categories()?.split(";") ??
Apps.fuzzy_query(nameOrApp)[0]?.categories
: nameOrApp.categories;
if (categories)
for (const [key, value] of Object.entries(categoryIcons)) if (categories.includes(key)) return value;
return "terminal";

View file

@ -1,4 +1,5 @@
import { exec, GLib } from "astal";
import { exec, execAsync, GLib } from "astal";
import type AstalApps from "gi://AstalApps";
import { osIcons } from "./icons";
export const inPath = (bin: string) => {
@ -10,6 +11,14 @@ export const inPath = (bin: string) => {
return true;
};
export const launch = (app: AstalApps.Application) => {
execAsync(["uwsm", "app", "--", app.entry]).catch(() => {
app.frequency--; // Decrement frequency cause launch also increments it
app.launch();
});
app.frequency++;
};
export const osId = GLib.get_os_info("ID") ?? "unknown";
export const osIdLike = GLib.get_os_info("ID_LIKE");
export const osIcon = String.fromCodePoint(

View file

@ -1,5 +1,5 @@
import { Binding } from "astal";
import { Astal, type Widget } from "astal/gtk3";
import { Binding, property, register, timeout } from "astal";
import { App, Astal, Gdk, Gtk, Widget } from "astal/gtk3";
import AstalHyprland from "gi://AstalHyprland";
export const setupCustomTooltip = (self: any, text: string | Binding<string>) => {
@ -46,3 +46,112 @@ export const setupCustomTooltip = (self: any, text: string | Binding<string>) =>
export const setupChildClickthrough = (self: any) =>
self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes());
export enum TransitionType {
FADE = "",
SLIDE_DOWN = "margin-top: -${height}px; margin-bottom: ${height}px;",
SLIDE_UP = "margin-top: ${height}px; margin-bottom: -${height}px;",
SLIDE_RIGHT = "margin-left: -${width}px; margin-right: ${width}px;",
SLIDE_LEFT = "margin-left: ${width}px; margin-right: -${width}px;",
}
@register()
export class PopupWindow extends Widget.Window {
readonly transitionType: TransitionType;
readonly transitionDuration: number;
readonly transitionAmount: number;
readonly #content: Widget.Box;
#visible: boolean = false;
@property(Boolean)
get realVisible() {
return this.#visible;
}
set realVisible(v: boolean) {
if (v) this.show();
else this.hide();
}
constructor(
props: Widget.WindowProps & {
transitionType?: TransitionType;
transitionDuration?: number;
transitionAmount?: number;
}
) {
const {
onKeyPressEvent,
clickThrough,
child,
halign = Gtk.Align.START,
valign = Gtk.Align.START,
transitionType = TransitionType.FADE,
transitionDuration = 200,
transitionAmount = 0.2,
...sProps
} = props;
sProps.visible = false;
sProps.application = App;
sProps.namespace = `caelestia-${props.name}`;
sProps.anchor =
Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT;
sProps.onKeyPressEvent = (self, event) => {
// Close window on escape
if (event.get_keyval()[1] === Gdk.KEY_Escape) self.hide();
return onKeyPressEvent?.(self, event);
};
super(sProps);
this.transitionType = transitionType;
this.transitionDuration = transitionDuration;
this.transitionAmount = transitionAmount;
// Wrapper box for animations
this.#content = (
<box halign={halign} valign={valign} className={`${props.name}-wrapper`}>
{clickThrough ? <eventbox>{child}</eventbox> : child}
</box>
) as Widget.Box;
this.#content.css = this.#getTransitionCss(false);
this.add(this.#content);
if (clickThrough) setupChildClickthrough(this);
}
#getTransitionCss(visible: boolean) {
return (
`transition-duration: ${this.transitionDuration}ms;` +
(visible
? "opacity: 1;" + this.transitionType.replaceAll("${width}", "0").replaceAll("${height}", "0")
: "opacity: 0;" +
this.transitionType
.replaceAll("${width}", String(this.#content.get_preferred_width()[1] * this.transitionAmount))
.replaceAll("${height}", String(this.#content.get_preferred_height()[1] * this.transitionAmount)))
);
}
show() {
this.#visible = true;
this.notify("real-visible");
super.show();
this.#content.css = this.#getTransitionCss(true);
}
hide() {
this.#visible = false;
this.notify("real-visible");
this.#content.css = this.#getTransitionCss(false);
timeout(this.transitionDuration, () => !this.#visible && super.hide());
}
toggle() {
if (this.#visible) this.hide();
else this.show();
}
}