summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-01-16 16:35:37 +1100
committer2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-01-16 16:35:37 +1100
commit02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38 (patch)
tree5e2a56becf6ba6961995e541ce9688224f704773 /src
parentpopupwindow: switch to class (diff)
downloadcaelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.tar.gz
caelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.tar.bz2
caelestia-shell-02fd2e97f2c8a53bf2344e6fa8b14769cb15ee38.zip
refactor: move ts to src
Also move popupwindow to own file
Diffstat (limited to 'src')
-rw-r--r--src/modules/bar.tsx500
-rw-r--r--src/modules/launcher.tsx391
-rw-r--r--src/modules/notifications.tsx57
-rw-r--r--src/modules/notifpopups.tsx49
-rw-r--r--src/modules/osds.tsx326
-rw-r--r--src/services/apps.ts3
-rw-r--r--src/services/math.ts151
-rw-r--r--src/services/monitors.ts127
-rw-r--r--src/services/players.ts154
-rw-r--r--src/services/updates.ts148
-rw-r--r--src/utils/icons.ts86
-rw-r--r--src/utils/mpris.ts16
-rw-r--r--src/utils/strings.ts1
-rw-r--r--src/utils/system.ts21
-rw-r--r--src/utils/widgets.ts45
-rw-r--r--src/widgets/notification.tsx132
-rw-r--r--src/widgets/popupwindow.ts39
17 files changed, 2246 insertions, 0 deletions
diff --git a/src/modules/bar.tsx b/src/modules/bar.tsx
new file mode 100644
index 0000000..b56d94a
--- /dev/null
+++ b/src/modules/bar.tsx
@@ -0,0 +1,500 @@
+import { execAsync, GLib, register, Variable } from "astal";
+import { bind, kebabify } from "astal/binding";
+import { App, Astal, astalify, Gdk, Gtk, type ConstructProps } from "astal/gtk3";
+import AstalBluetooth from "gi://AstalBluetooth";
+import AstalHyprland from "gi://AstalHyprland";
+import AstalNetwork from "gi://AstalNetwork";
+import AstalNotifd from "gi://AstalNotifd";
+import AstalTray from "gi://AstalTray";
+import AstalWp01 from "gi://AstalWp";
+import { bar as config } from "../../config";
+import type { Monitor } from "../services/monitors";
+import Players from "../services/players";
+import Updates from "../services/updates";
+import { getAppCategoryIcon } from "../utils/icons";
+import { ellipsize } from "../utils/strings";
+import { osIcon } from "../utils/system";
+import { setupCustomTooltip } from "../utils/widgets";
+
+const hyprland = AstalHyprland.get_default();
+
+const hookFocusedClientProp = (
+ self: any, // Ugh why is there no base Widget type
+ prop: keyof AstalHyprland.Client,
+ callback: (c: AstalHyprland.Client | null) => void
+) => {
+ let id: number | null = null;
+ let lastClient: AstalHyprland.Client | null = null;
+ self.hook(hyprland, "notify::focused-client", () => {
+ if (id) lastClient?.disconnect(id);
+ lastClient = hyprland.focusedClient; // Can be null
+ id = lastClient?.connect(`notify::${kebabify(prop)}`, () => callback(lastClient));
+ callback(lastClient);
+ });
+ self.connect("destroy", () => id && lastClient?.disconnect(id));
+ callback(lastClient);
+};
+
+const OSIcon = () => <label className="module os-icon" label={osIcon} />;
+
+const ActiveWindow = () => (
+ <box
+ hasTooltip
+ className="module active-window"
+ setup={self => {
+ const title = Variable("");
+ const updateTooltip = (c: AstalHyprland.Client | null) =>
+ title.set(c?.class && c?.title ? `${c.class}: ${c.title}` : "");
+ hookFocusedClientProp(self, "class", updateTooltip);
+ hookFocusedClientProp(self, "title", updateTooltip);
+ updateTooltip(hyprland.focusedClient);
+
+ const window = setupCustomTooltip(self, bind(title));
+ if (window) {
+ self.hook(title, (_, v) => !v && window.hide());
+ self.hook(window, "map", () => !title.get() && window.hide());
+ }
+ }}
+ >
+ <label
+ className="icon"
+ setup={self =>
+ hookFocusedClientProp(self, "class", c => {
+ self.label = c?.class ? getAppCategoryIcon(c.class) : "desktop_windows";
+ })
+ }
+ />
+ <label
+ setup={self =>
+ hookFocusedClientProp(self, "title", c => (self.label = c?.title ? ellipsize(c.title) : "Desktop"))
+ }
+ />
+ </box>
+);
+
+const MediaPlaying = () => {
+ const players = Players.get_default();
+ const getLabel = (fallback = "") =>
+ players.lastPlayer ? `${players.lastPlayer.title} - ${players.lastPlayer.artist}` : fallback;
+ return (
+ <button
+ onClick={(_, event) => {
+ if (event.button === Astal.MouseButton.PRIMARY) {
+ // TODO: media panel
+ } else if (event.button === Astal.MouseButton.SECONDARY) players.lastPlayer?.play_pause();
+ else if (event.button === Astal.MouseButton.MIDDLE) players.lastPlayer?.raise();
+ }}
+ setup={self => {
+ const label = Variable(getLabel());
+ players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => label.set(getLabel()));
+ setupCustomTooltip(self, bind(label));
+ }}
+ >
+ <box className="module media-playing">
+ <icon
+ setup={self =>
+ players.hookLastPlayer(self, "notify::identity", () => {
+ const icon = `caelestia-${players.lastPlayer?.identity.toLowerCase()}-symbolic`;
+ self.icon = players.lastPlayer
+ ? Astal.Icon.lookup_icon(icon)
+ ? icon
+ : "caelestia-media-generic-symbolic"
+ : "caelestia-media-none-symbolic";
+ })
+ }
+ />
+ <label
+ setup={self =>
+ players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => {
+ self.label = ellipsize(getLabel("No media")); // TODO: scroll text
+ })
+ }
+ />
+ </box>
+ </button>
+ );
+};
+
+const Workspace = ({ idx }: { idx: number }) => {
+ let wsId = Math.floor((hyprland.focusedWorkspace.id - 1) / config.wsPerGroup) * config.wsPerGroup + idx;
+ return (
+ <button
+ halign={Gtk.Align.CENTER}
+ valign={Gtk.Align.CENTER}
+ onClicked={() => hyprland.dispatch("workspace", String(wsId))}
+ setup={self => {
+ const update = () =>
+ self.toggleClassName(
+ "occupied",
+ hyprland.clients.some(c => c.workspace.id === wsId)
+ );
+
+ self.hook(hyprland, "notify::focused-workspace", () => {
+ wsId = Math.floor((hyprland.focusedWorkspace.id - 1) / config.wsPerGroup) * config.wsPerGroup + idx;
+ self.toggleClassName("focused", hyprland.focusedWorkspace.id === wsId);
+ update();
+ });
+ self.hook(hyprland, "client-added", update);
+ self.hook(hyprland, "client-moved", update);
+ self.hook(hyprland, "client-removed", update);
+
+ self.toggleClassName("focused", hyprland.focusedWorkspace.id === wsId);
+ update();
+ }}
+ />
+ );
+};
+
+const Workspaces = () => (
+ <eventbox
+ onScroll={(_, event) => {
+ const activeWs = hyprland.focusedClient?.workspace.name;
+ if (activeWs?.startsWith("special:")) hyprland.dispatch("togglespecialworkspace", activeWs.slice(8));
+ else if (event.delta_y > 0 || hyprland.focusedWorkspace.id > 1)
+ hyprland.dispatch("workspace", (event.delta_y < 0 ? "-" : "+") + 1);
+ }}
+ >
+ <box className="module workspaces">
+ {Array.from({ length: config.wsPerGroup }).map((_, idx) => (
+ <Workspace idx={idx + 1} /> // Start from 1
+ ))}
+ </box>
+ </eventbox>
+);
+
+@register()
+class TrayItemMenu extends astalify(Gtk.Menu) {
+ readonly item: AstalTray.TrayItem;
+
+ constructor(props: ConstructProps<TrayItemMenu, Gtk.Menu.ConstructorProps> & { item: AstalTray.TrayItem }) {
+ const { item, ...sProps } = props;
+ super(sProps as any);
+
+ this.item = item;
+
+ this.hook(item, "notify::menu-model", () => this.bind_model(item.menuModel, null, true));
+ this.hook(item, "notify::action-group", () => this.insert_action_group("dbusmenu", item.actionGroup));
+ this.bind_model(item.menuModel, null, true);
+ this.insert_action_group("dbusmenu", item.actionGroup);
+ }
+
+ popup_at_widget_bottom(widget: Gtk.Widget) {
+ this.item.about_to_show();
+ this.popup_at_widget(widget, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null);
+ }
+}
+
+const TrayItem = (item: AstalTray.TrayItem) => {
+ const menu = (<TrayItemMenu item={item} />) as TrayItemMenu;
+ return (
+ <button
+ onClick={(self, event) => {
+ if (event.button === Astal.MouseButton.PRIMARY) {
+ if (item.isMenu) menu.popup_at_widget_bottom(self);
+ else item.activate(0, 0);
+ } else if (event.button === Astal.MouseButton.SECONDARY) menu.popup_at_widget_bottom(self);
+ }}
+ onScroll={(_, event) => {
+ if (event.delta_x !== 0) item.scroll(event.delta_x, "horizontal");
+ if (event.delta_y !== 0) item.scroll(event.delta_y, "vertical");
+ }}
+ onDestroy={() => menu.destroy()}
+ setup={self => setupCustomTooltip(self, bind(item, "tooltipMarkup"))}
+ >
+ <icon halign={Gtk.Align.CENTER} gicon={bind(item, "gicon")} />
+ </button>
+ );
+};
+
+const Tray = () => <box className="module tray">{bind(AstalTray.get_default(), "items").as(i => i.map(TrayItem))}</box>;
+
+const Network = () => (
+ <button
+ onClick={(_, event) => {
+ const network = AstalNetwork.get_default();
+ if (event.button === Astal.MouseButton.PRIMARY) {
+ // TODO: networks panel
+ } else if (event.button === Astal.MouseButton.SECONDARY) network.wifi.enabled = !network.wifi.enabled;
+ else if (event.button === Astal.MouseButton.MIDDLE)
+ execAsync("uwsm app -- gnome-control-center wifi").catch(() => {
+ network.wifi.scan();
+ execAsync(
+ "uwsm app -- foot -T nmtui fish -c 'sleep .1; set -e COLORTERM; TERM=xterm-old nmtui connect'"
+ ).catch(err => {
+ // Idk why but foot always throws this error when it opens
+ if (
+ err.message !==
+ "warn: wayland.c:1619: compositor does not implement the XDG toplevel icon protocol\nwarn: terminal.c:1973: slave exited with signal 1 (Hangup)"
+ )
+ console.error(err);
+ });
+ });
+ }}
+ setup={self => {
+ const network = AstalNetwork.get_default();
+ const tooltipText = Variable("");
+ const update = () => {
+ if (network.primary === AstalNetwork.Primary.WIFI) {
+ if (network.wifi.internet === AstalNetwork.Internet.CONNECTED)
+ tooltipText.set(`${network.wifi.ssid} | Strength: ${network.wifi.strength}/100`);
+ else if (network.wifi.internet === AstalNetwork.Internet.CONNECTING)
+ tooltipText.set(`Connecting to ${network.wifi.ssid}`);
+ else tooltipText.set("Disconnected");
+ } else if (network.primary === AstalNetwork.Primary.WIRED) {
+ if (network.wired.internet === AstalNetwork.Internet.CONNECTED)
+ tooltipText.set(`Speed: ${network.wired.speed}`);
+ else if (network.wired.internet === AstalNetwork.Internet.CONNECTING) tooltipText.set("Connecting");
+ else tooltipText.set("Disconnected");
+ } else {
+ tooltipText.set("Unknown");
+ }
+ };
+ self.hook(network, "notify::primary", update);
+ self.hook(network.wifi, "notify::internet", update);
+ self.hook(network.wifi, "notify::ssid", update);
+ self.hook(network.wifi, "notify::strength", update);
+ if (network.wired) {
+ self.hook(network.wired, "notify::internet", update);
+ self.hook(network.wired, "notify::speed", update);
+ }
+ update();
+ setupCustomTooltip(self, bind(tooltipText));
+ }}
+ >
+ <stack
+ transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
+ transitionDuration={120}
+ shown={bind(AstalNetwork.get_default(), "primary").as(p =>
+ p === AstalNetwork.Primary.WIFI ? "wifi" : "wired"
+ )}
+ >
+ <stack
+ name="wifi"
+ transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
+ transitionDuration={120}
+ setup={self => {
+ const network = AstalNetwork.get_default();
+ const update = () => {
+ if (network.wifi.internet === AstalNetwork.Internet.CONNECTED)
+ self.shown = String(Math.ceil(network.wifi.strength / 25));
+ else if (network.wifi.internet === AstalNetwork.Internet.CONNECTING) self.shown = "connecting";
+ else self.shown = "disconnected";
+ };
+ self.hook(network.wifi, "notify::internet", update);
+ self.hook(network.wifi, "notify::strength", update);
+ update();
+ }}
+ >
+ <label className="icon" label="wifi_off" name="disconnected" />
+ <label className="icon" label="settings_ethernet" name="connecting" />
+ <label className="icon" label="signal_wifi_0_bar" name="0" />
+ <label className="icon" label="network_wifi_1_bar" name="1" />
+ <label className="icon" label="network_wifi_2_bar" name="2" />
+ <label className="icon" label="network_wifi_3_bar" name="3" />
+ <label className="icon" label="signal_wifi_4_bar" name="4" />
+ </stack>
+ <stack
+ name="wired"
+ transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
+ transitionDuration={120}
+ setup={self => {
+ const network = AstalNetwork.get_default();
+ const update = () => {
+ if (network.primary !== AstalNetwork.Primary.WIRED) return;
+
+ if (network.wired.internet === AstalNetwork.Internet.CONNECTED) self.shown = "connected";
+ else if (network.wired.internet === AstalNetwork.Internet.CONNECTING) self.shown = "connecting";
+ else self.shown = "disconnected";
+ };
+ self.hook(network, "notify::primary", update);
+ if (network.wired) self.hook(network.wired, "notify::internet", update);
+ update();
+ }}
+ >
+ <label className="icon" label="wifi_off" name="disconnected" />
+ <label className="icon" label="settings_ethernet" name="connecting" />
+ <label className="icon" label="lan" name="connected" />
+ </stack>
+ </stack>
+ </button>
+);
+
+const Bluetooth = () => (
+ <button
+ onClick={(_, event) => {
+ if (event.button === Astal.MouseButton.PRIMARY) {
+ // TODO: bluetooth panel
+ } else if (event.button === Astal.MouseButton.SECONDARY) AstalBluetooth.get_default().toggle();
+ else if (event.button === Astal.MouseButton.MIDDLE)
+ execAsync("uwsm app -- blueman-manager").catch(console.error);
+ }}
+ setup={self => {
+ const bluetooth = AstalBluetooth.get_default();
+ const tooltipText = Variable("");
+ const update = () => {
+ const devices = bluetooth.get_devices().filter(d => d.connected);
+ tooltipText.set(
+ devices.length > 0
+ ? `Connected devices: ${devices.map(d => d.alias).join(", ")}`
+ : "No connected devices"
+ );
+ };
+ const hookDevice = (device: AstalBluetooth.Device) => {
+ self.hook(device, "notify::connected", update);
+ self.hook(device, "notify::alias", update);
+ };
+ bluetooth.get_devices().forEach(hookDevice);
+ self.hook(bluetooth, "device-added", (_, device) => {
+ hookDevice(device);
+ update();
+ });
+ update();
+ setupCustomTooltip(self, bind(tooltipText));
+ }}
+ >
+ <stack
+ transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
+ transitionDuration={120}
+ shown={bind(AstalBluetooth.get_default(), "isPowered").as(p => (p ? "enabled" : "disabled"))}
+ >
+ <label className="icon" label="bluetooth" name="enabled" />
+ <label className="icon" label="bluetooth_disabled" name="disabled" />
+ </stack>
+ </button>
+);
+
+const StatusIcons = () => (
+ <box className="module status-icons">
+ <Network />
+ <Bluetooth />
+ </box>
+);
+
+const PkgUpdates = () => (
+ <box
+ className="module updates"
+ setup={self =>
+ setupCustomTooltip(
+ self,
+ bind(Updates.get_default(), "numUpdates").as(n => `${n} update${n === 1 ? "" : "s"} available`)
+ )
+ }
+ >
+ <label className="icon" label="download" />
+ <label label={bind(Updates.get_default(), "numUpdates").as(String)} />
+ </box>
+);
+
+const Notifications = () => {
+ const unreadCount = Variable(0);
+ return (
+ <box
+ className="module notifications"
+ setup={self =>
+ setupCustomTooltip(
+ self,
+ bind(unreadCount).as(n => `${n} unread notification${n === 1 ? "" : "s"}`)
+ )
+ }
+ >
+ <label className="icon" label="info" />
+ <label
+ label="0"
+ setup={self => {
+ const notifd = AstalNotifd.get_default();
+ let notifsOpen = false;
+ let unread = new Set<number>();
+
+ self.hook(notifd, "notified", (self, id, replaced) => {
+ if (!notifsOpen && !replaced) {
+ unread.add(id);
+ unreadCount.set(unread.size);
+ self.label = String(unread.size);
+ }
+ });
+ self.hook(notifd, "resolved", (self, id) => {
+ if (unread.delete(id)) {
+ unreadCount.set(unread.size);
+ self.label = String(unread.size);
+ }
+ });
+ self.hook(App, "window-toggled", (_, window) => {
+ if (window.name === "notifications") {
+ notifsOpen = window.visible;
+ if (notifsOpen) {
+ unread.clear();
+ unreadCount.set(0);
+ }
+ }
+ });
+ }}
+ />
+ </box>
+ );
+};
+
+const DateTime = () => (
+ <box className="module date-time">
+ <label className="icon" label="calendar_month" />
+ <label
+ setup={self => {
+ const pollVar = Variable(null).poll(5000, () => {
+ self.label =
+ GLib.DateTime.new_now_local().format(config.dateTimeFormat) ?? new Date().toLocaleString();
+ return null;
+ });
+ self.connect("destroy", () => pollVar.drop());
+ }}
+ />
+ </box>
+);
+
+const Power = () => (
+ <button
+ className="module power"
+ label="power_settings_new"
+ onClicked={() => execAsync("fish -c 'pkill wlogout || wlogout -p layer-shell'").catch(console.error)}
+ />
+);
+
+export default ({ monitor }: { monitor: Monitor }) => (
+ <window
+ namespace="caelestia-bar"
+ monitor={monitor.id}
+ anchor={Astal.WindowAnchor.TOP}
+ exclusivity={Astal.Exclusivity.EXCLUSIVE}
+ >
+ <centerbox className="bar" css={"min-width: " + monitor.width * 0.8 + "px;"}>
+ <box>
+ <OSIcon />
+ <ActiveWindow />
+ <MediaPlaying />
+ <button
+ hexpand
+ onScroll={(_, event) =>
+ event.delta_y > 0 ? (monitor.brightness -= 0.1) : (monitor.brightness += 0.1)
+ }
+ />
+ </box>
+ <Workspaces />
+ <box>
+ <button
+ hexpand
+ onScroll={(_, event) => {
+ const speaker = AstalWp01.get_default()?.audio.defaultSpeaker;
+ if (!speaker) return;
+ speaker.mute = false;
+ if (event.delta_y > 0) speaker.volume -= 0.1;
+ else speaker.volume += 0.1;
+ }}
+ />
+ <Tray />
+ <StatusIcons />
+ <PkgUpdates />
+ <Notifications />
+ <DateTime />
+ <Power />
+ </box>
+ </centerbox>
+ </window>
+);
diff --git a/src/modules/launcher.tsx b/src/modules/launcher.tsx
new file mode 100644
index 0000000..2fc6eef
--- /dev/null
+++ b/src/modules/launcher.tsx
@@ -0,0 +1,391 @@
+import { bind, execAsync, Gio, GLib, register, timeout, Variable } from "astal";
+import { Astal, Gtk, Widget } from "astal/gtk3";
+import fuzzysort from "fuzzysort";
+import type AstalApps from "gi://AstalApps";
+import AstalHyprland from "gi://AstalHyprland";
+import { launcher as config } from "../../config";
+import { Apps } from "../services/apps";
+import Math, { type HistoryItem } from "../services/math";
+import { getAppCategoryIcon } from "../utils/icons";
+import { launch } from "../utils/system";
+import { setupCustomTooltip } from "../utils/widgets";
+import PopupWindow from "../widgets/popupwindow";
+
+type Mode = "apps" | "files" | "math";
+
+interface Subcommand {
+ icon: string;
+ name: string;
+ description: string;
+ command: (...args: string[]) => void;
+}
+
+const getIconFromMode = (mode: Mode) => {
+ switch (mode) {
+ case "apps":
+ return "apps";
+ case "files":
+ return "folder";
+ case "math":
+ return "calculate";
+ }
+};
+
+const getEmptyTextFromMode = (mode: Mode) => {
+ switch (mode) {
+ case "apps":
+ return "No apps found";
+ case "files":
+ return GLib.find_program_in_path("fd") === null ? "File search requires `fd`" : "No files found";
+ case "math":
+ return "Type an expression";
+ }
+};
+
+const close = (self: JSX.Element) => {
+ const toplevel = self.get_toplevel();
+ if (toplevel instanceof Widget.Window) toplevel.hide();
+};
+
+const launchAndClose = (self: JSX.Element, astalApp: AstalApps.Application) => {
+ close(self);
+ launch(astalApp);
+};
+
+const openFileAndClose = (self: JSX.Element, path: string) => {
+ close(self);
+ execAsync([
+ "bash",
+ "-c",
+ `dbus-send --session --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems array:string:"file://${path}" string:"" || xdg-open "${path}"`,
+ ]).catch(console.error);
+};
+
+const PinnedApp = (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="pinned-app result"
+ 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>{config.pins.map(PinnedApp)}</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 = ({
+ icon,
+ materialIcon,
+ label,
+ sublabel,
+ onClicked,
+}: {
+ icon?: string;
+ materialIcon?: string;
+ label: string;
+ sublabel?: string;
+ onClicked: (self: Widget.Button) => void;
+}) => (
+ <button className="result" cursor="pointer" onClicked={onClicked}>
+ <box>
+ {icon && Astal.Icon.lookup_icon(icon) ? (
+ <icon valign={Gtk.Align.START} className="icon" icon={icon} />
+ ) : (
+ <label valign={Gtk.Align.START} className="icon" label={materialIcon} />
+ )}
+ {sublabel ? (
+ <box vertical valign={Gtk.Align.CENTER} className="has-sublabel">
+ <label hexpand truncate maxWidthChars={1} xalign={0} label={label} />
+ <label hexpand truncate maxWidthChars={1} className="sublabel" xalign={0} label={sublabel} />
+ </box>
+ ) : (
+ <label xalign={0} label={label} />
+ )}
+ </box>
+ </button>
+);
+
+const SubcommandResult = ({
+ entry,
+ subcommand,
+ args,
+}: {
+ entry: Widget.Entry;
+ subcommand: Subcommand;
+ args: string[];
+}) => (
+ <Result
+ materialIcon={subcommand.icon}
+ label={subcommand.name}
+ sublabel={subcommand.description}
+ onClicked={() => {
+ subcommand.command(...args);
+ entry.set_text("");
+ }}
+ />
+);
+
+const AppResult = ({ app }: { app: AstalApps.Application }) => (
+ <Result
+ icon={app.iconName}
+ materialIcon={getAppCategoryIcon(app)}
+ label={app.name}
+ sublabel={app.description}
+ onClicked={self => launchAndClose(self, app)}
+ />
+);
+
+const MathResult = ({ math, isHistory, entry }: { math: HistoryItem; isHistory?: boolean; entry: Widget.Entry }) => (
+ <Result
+ materialIcon={math.icon}
+ label={math.equation}
+ sublabel={math.result}
+ onClicked={() => {
+ if (isHistory) {
+ Math.get_default().select(math);
+ entry.set_text(math.equation);
+ entry.grab_focus();
+ entry.set_position(-1);
+ } else {
+ execAsync(`wl-copy -- ${math.result}`).catch(console.error);
+ entry.set_text("");
+ }
+ }}
+ />
+);
+
+const FileResult = ({ path }: { path: string }) => (
+ <Result
+ label={path.split("/").pop()!}
+ sublabel={path.startsWith(HOME) ? "~" + path.slice(HOME.length) : path}
+ onClicked={self => openFileAndClose(self, path)}
+ />
+);
+
+const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> }) => {
+ 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="bug_report" />
+ <label
+ label={bind(entry, "text").as(t =>
+ t.startsWith(">") ? "No matching subcommands" : getEmptyTextFromMode(mode.get())
+ )}
+ />
+ </box>
+ <box
+ vertical
+ name="list"
+ setup={self => {
+ const subcommands: Record<string, Subcommand> = {
+ apps: {
+ icon: "apps",
+ name: "Apps",
+ description: "Search for apps",
+ command: () => mode.set("apps"),
+ },
+ files: {
+ icon: "folder",
+ name: "Files",
+ description: "Search for files",
+ command: () => mode.set("files"),
+ },
+ math: {
+ icon: "calculate",
+ name: "Math",
+ description: "Do math calculations",
+ command: () => mode.set("math"),
+ },
+ todo: {
+ icon: "checklist",
+ name: "Todo",
+ description: "Create a todo in <INSERT_TODO_APP>",
+ command: (...args) => {
+ // TODO: todo service or maybe use external app
+ },
+ },
+ };
+ const subcommandList = Object.keys(subcommands);
+
+ const updateEmpty = () => empty.set(self.get_children().length === 0);
+
+ const appSearch = () => {
+ const apps = Apps.fuzzy_query(entry.text);
+ if (apps.length > config.maxResults) apps.length = config.maxResults;
+ for (const app of apps) self.add(<AppResult app={app} />);
+ };
+
+ const calculate = () => {
+ if (entry.text) {
+ self.add(<MathResult math={Math.get_default().evaluate(entry.text)} entry={entry} />);
+ self.add(<box className="separator" />);
+ }
+ for (const item of Math.get_default().history)
+ self.add(<MathResult isHistory math={item} entry={entry} />);
+ };
+
+ const fileSearch = () =>
+ execAsync(["fd", ...config.fdOpts, entry.text, HOME])
+ .then(out => {
+ const paths = out.split("\n").filter(path => path);
+ if (paths.length > config.maxResults) paths.length = config.maxResults;
+ self.foreach(ch => ch.destroy());
+ for (const path of paths) self.add(<FileResult path={path} />);
+ })
+ .catch(e => {
+ // Ignore execAsync error
+ if (!(e instanceof Gio.IOErrorEnum || e instanceof GLib.SpawnError)) console.error(e);
+ })
+ .finally(updateEmpty);
+
+ self.hook(entry, "activate", () => {
+ if (mode.get() === "math") {
+ if (entry.text.startsWith("clear")) Math.get_default().clear();
+ else Math.get_default().commit();
+ }
+ self.get_children()[0]?.activate();
+ });
+ self.hook(entry, "changed", () => {
+ if (!entry.text && mode.get() === "apps") return;
+
+ // Files has delay cause async so it does some stuff by itself
+ const ignoreFileAsync = entry.text.startsWith(">") || mode.get() !== "files";
+ if (ignoreFileAsync) self.foreach(ch => ch.destroy());
+
+ if (entry.text.startsWith(">")) {
+ const args = entry.text.split(" ");
+ for (const { target } of fuzzysort.go(args[0].slice(1), subcommandList, { all: true }))
+ self.add(
+ <SubcommandResult
+ entry={entry}
+ subcommand={subcommands[target]}
+ args={args.slice(1)}
+ />
+ );
+ } else if (mode.get() === "apps") appSearch();
+ else if (mode.get() === "math") calculate();
+ else if (mode.get() === "files") fileSearch();
+
+ if (ignoreFileAsync) updateEmpty();
+ });
+ }}
+ />
+ </stack>
+ );
+};
+
+const LauncherContent = ({
+ mode,
+ showResults,
+ entry,
+}: {
+ mode: Variable<Mode>;
+ showResults: Variable<boolean>;
+ entry: Widget.Entry;
+}) => (
+ <box vertical className={bind(mode).as(m => `launcher ${m}`)}>
+ <box className="search-bar">
+ <label className="icon" label="search" />
+ <SearchEntry entry={entry} />
+ <label className="icon" label={bind(mode).as(getIconFromMode)} />
+ </box>
+ <revealer
+ revealChild={bind(showResults).as(s => !s)}
+ transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
+ transitionDuration={150}
+ >
+ <PinnedApps />
+ </revealer>
+ <revealer
+ revealChild={bind(showResults)}
+ transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
+ transitionDuration={150}
+ >
+ <Results entry={entry} mode={mode} />
+ </revealer>
+ </box>
+);
+
+@register()
+export default class Launcher extends PopupWindow {
+ readonly mode: Variable<Mode>;
+
+ constructor() {
+ const entry = (<entry name="entry" />) as Widget.Entry;
+ const mode = Variable<Mode>("apps");
+ const showResults = Variable.derive([bind(entry, "textLength"), mode], (t, m) => t > 0 || m !== "apps");
+
+ super({
+ name: "launcher",
+ anchor: Astal.WindowAnchor.TOP,
+ keymode: Astal.Keymode.EXCLUSIVE,
+ 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: <LauncherContent mode={mode} showResults={showResults} entry={entry} />,
+ });
+
+ this.mode = mode;
+
+ this.connect("show", () => (this.marginTop = AstalHyprland.get_default().focusedMonitor.height / 4));
+
+ // Clear search on hide if not in math mode
+ this.connect("hide", () => mode.get() !== "math" && entry.set_text(""));
+
+ this.connect("destroy", () => showResults.drop());
+ }
+
+ open(mode: Mode) {
+ this.mode.set(mode);
+ this.show();
+ }
+}
diff --git a/src/modules/notifications.tsx b/src/modules/notifications.tsx
new file mode 100644
index 0000000..66188a1
--- /dev/null
+++ b/src/modules/notifications.tsx
@@ -0,0 +1,57 @@
+import { Gtk } from "astal/gtk3";
+import AstalNotifd from "gi://AstalNotifd";
+import { PopupWindow, setupChildClickthrough } from "../utils/widgets";
+
+const List = () => (
+ <box
+ vertical
+ valign={Gtk.Align.START}
+ className="list"
+ setup={self => {
+ const notifd = AstalNotifd.get_default();
+ const map = new Map<number, NotifPopup>();
+ self.hook(notifd, "notified", (self, id) => {
+ const notification = notifd.get_notification(id);
+
+ const popup = (<NotifPopup notification={notification} />) as NotifPopup;
+ popup.connect("destroy", () => map.get(notification.id) === popup && map.delete(notification.id));
+ map.get(notification.id)?.destroyWithAnims();
+ map.set(notification.id, popup);
+
+ self.add(
+ <eventbox
+ // Dismiss on middle click
+ onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()}
+ // Close on hover lost
+ onHoverLost={() => popup.destroyWithAnims()}
+ >
+ {popup}
+ </eventbox>
+ );
+
+ // Limit number of popups
+ if (config.maxPopups > 0 && self.children.length > config.maxPopups)
+ map.values().next().value?.destroyWithAnims();
+ });
+ self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims());
+
+ // Change input region to child region so can click through empty space
+ setupChildClickthrough(self);
+ }}
+ />
+);
+
+export default class Notifications extends PopupWindow {
+ constructor() {
+ super({
+ name: "notifications",
+ child: (
+ <box>
+ <List />
+ </box>
+ ),
+ });
+
+ setupChildClickthrough(self);
+ }
+}
diff --git a/src/modules/notifpopups.tsx b/src/modules/notifpopups.tsx
new file mode 100644
index 0000000..5da3092
--- /dev/null
+++ b/src/modules/notifpopups.tsx
@@ -0,0 +1,49 @@
+import { Astal, Gtk } from "astal/gtk3";
+import AstalNotifd from "gi://AstalNotifd";
+import { notifpopups as config } from "../../config";
+import { setupChildClickthrough } from "../utils/widgets";
+import Notification from "../widgets/notification";
+
+export default () => (
+ <window
+ namespace="caelestia-notifpopups"
+ anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM}
+ >
+ <box
+ vertical
+ valign={Gtk.Align.START}
+ className="notifpopups"
+ setup={self => {
+ const notifd = AstalNotifd.get_default();
+ const map = new Map<number, Notification>();
+ self.hook(notifd, "notified", (self, id) => {
+ const notification = notifd.get_notification(id);
+
+ const popup = (<Notification popup notification={notification} />) as Notification;
+ popup.connect("destroy", () => map.get(notification.id) === popup && map.delete(notification.id));
+ map.get(notification.id)?.destroyWithAnims();
+ map.set(notification.id, popup);
+
+ self.add(
+ <eventbox
+ // Dismiss on middle click
+ onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()}
+ // Close on hover lost
+ onHoverLost={() => popup.destroyWithAnims()}
+ >
+ {popup}
+ </eventbox>
+ );
+
+ // Limit number of popups
+ if (config.maxPopups > 0 && self.children.length > config.maxPopups)
+ map.values().next().value?.destroyWithAnims();
+ });
+ self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims());
+
+ // Change input region to child region so can click through empty space
+ setupChildClickthrough(self);
+ }}
+ />
+ </window>
+);
diff --git a/src/modules/osds.tsx b/src/modules/osds.tsx
new file mode 100644
index 0000000..a87fcc3
--- /dev/null
+++ b/src/modules/osds.tsx
@@ -0,0 +1,326 @@
+import { execAsync, register, timeout, Variable, type Time } from "astal";
+import { App, Astal, Gtk, Widget } from "astal/gtk3";
+import cairo from "cairo";
+import AstalWp from "gi://AstalWp";
+import Cairo from "gi://cairo";
+import Pango from "gi://Pango";
+import PangoCairo from "gi://PangoCairo";
+import { osds as config } from "../../config";
+import Monitors, { type Monitor } from "../services/monitors";
+import PopupWindow from "../widgets/popupwindow";
+
+const getStyle = (context: Gtk.StyleContext, prop: string) => context.get_property(prop, Gtk.StateFlags.NORMAL);
+const getNumStyle = (context: Gtk.StyleContext, prop: string) => getStyle(context, prop) as number;
+
+const mix = (a: number, b: number, r: number) => a * r + b * (1 - r);
+
+const pangoWeightToStr = (weight: Pango.Weight) => {
+ switch (weight) {
+ case Pango.Weight.ULTRALIGHT:
+ return "UltraLight";
+ case Pango.Weight.LIGHT:
+ return "Light";
+ case Pango.Weight.BOLD:
+ return "Bold";
+ case Pango.Weight.ULTRABOLD:
+ return "UltraBold";
+ case Pango.Weight.HEAVY:
+ return "Heavy";
+ default:
+ return "Normal";
+ }
+};
+
+const SliderOsd = ({
+ fillIcons,
+ monitor,
+ type,
+ windowSetup,
+ className = "",
+ initValue,
+ drawAreaSetup,
+}: {
+ fillIcons?: boolean;
+ monitor?: Monitor;
+ type: "volume" | "brightness";
+ windowSetup: (self: Widget.Window, show: () => void) => void;
+ className?: string;
+ initValue: number;
+ drawAreaSetup: (self: Widget.DrawingArea, icon: Variable<string>) => void;
+}) => (
+ <PopupWindow
+ name={type}
+ monitor={monitor?.id}
+ keymode={Astal.Keymode.NONE}
+ anchor={config[type].position}
+ margin={config[type].margin}
+ setup={self => {
+ let time: Time | null = null;
+ const hideAfterTimeout = () => {
+ time?.cancel();
+ time = timeout(config[type].hideDelay, () => self.hide());
+ };
+ self.connect("show", hideAfterTimeout);
+ windowSetup(self, () => {
+ self.show();
+ hideAfterTimeout();
+ });
+ }}
+ >
+ <box className={type}>
+ <drawingarea
+ className={`inner ${className}`}
+ css={"font-size: " + initValue + "px;"}
+ setup={self => {
+ const halfPi = Math.PI / 2;
+ const vertical =
+ config[type].position === Astal.WindowAnchor.LEFT ||
+ config[type].position === Astal.WindowAnchor.RIGHT;
+
+ const icon = Variable("");
+ drawAreaSetup(self, icon);
+ self.hook(icon, () => self.queue_draw());
+
+ // Init size
+ const styleContext = self.get_style_context();
+ const width = getNumStyle(styleContext, "min-width");
+ const height = getNumStyle(styleContext, "min-height");
+ if (vertical) self.set_size_request(height, width);
+ else self.set_size_request(width, height);
+
+ let fontDesc: Pango.FontDescription | null = null;
+
+ self.connect("draw", (_, cr: cairo.Context) => {
+ const parent = self.get_parent();
+ if (!parent) return;
+
+ const styleContext = self.get_style_context();
+ const pContext = parent.get_style_context();
+
+ let width = getNumStyle(styleContext, "min-width");
+ let height = getNumStyle(styleContext, "min-height");
+
+ const progressValue = getNumStyle(styleContext, "font-size");
+ let radius = getNumStyle(pContext, "border-radius");
+ // Flatten when near 0, do before swap cause its simpler
+ radius = Math.min(radius, Math.min(width * progressValue, height) / 2);
+
+ if (vertical) [width, height] = [height, width]; // Swap if vertical
+ self.set_size_request(width, height);
+
+ const progressPosition = vertical
+ ? height * (1 - progressValue) + radius // Top is 0, but we want it to start from the bottom
+ : width * progressValue - radius;
+
+ const bg = styleContext.get_background_color(Gtk.StateFlags.NORMAL);
+ cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
+
+ // Background
+ if (vertical) {
+ cr.arc(radius, progressPosition, radius, -Math.PI, -halfPi); // Top left
+ cr.arc(width - radius, progressPosition, radius, -halfPi, 0); // Top right
+ cr.arc(width - radius, height - radius, radius, 0, halfPi); // Bottom right
+ } else {
+ cr.arc(radius, radius, radius, -Math.PI, -halfPi); // Top left
+ cr.arc(progressPosition, radius, radius, -halfPi, 0); // Top right
+ cr.arc(progressPosition, height - radius, radius, 0, halfPi); // Bottom right
+ }
+ cr.arc(radius, height - radius, radius, halfPi, Math.PI); // Bottom left
+ cr.fill();
+
+ const fg = pContext.get_background_color(Gtk.StateFlags.NORMAL);
+ cr.setAntialias(cairo.Antialias.BEST);
+
+ // Progress number, at top/right
+ let nw = 0;
+ let nh = 0;
+ if (config[type].showValue) {
+ const numLayout = parent.create_pango_layout(String(Math.round(progressValue * 100)));
+ [nw, nh] = numLayout.get_pixel_size();
+ let diff;
+ if (vertical) {
+ diff = ((1 - progressValue) * height) / nh;
+ cr.moveTo((width - nw) / 2, radius / 2);
+ } else {
+ diff = ((1 - progressValue) * width) / nw;
+ cr.moveTo(width - nw - radius, (height - nh) / 2);
+ }
+ diff = Math.max(0, Math.min(1, diff));
+
+ cr.setSourceRGBA(
+ mix(bg.red, fg.red, diff),
+ mix(bg.green, fg.green, diff),
+ mix(bg.blue, fg.blue, diff),
+ mix(bg.alpha, fg.alpha, diff)
+ );
+
+ PangoCairo.show_layout(cr, numLayout);
+ }
+
+ // Progress icon, follows progress
+ if (fontDesc === null) {
+ const weight = pangoWeightToStr(getStyle(pContext, "font-weight") as Pango.Weight);
+ const size = getNumStyle(pContext, "font-size") * 1.5;
+ fontDesc = Pango.font_description_from_string(
+ `Material Symbols Rounded ${weight} ${size}px`
+ );
+ // Ugh GTK CSS doesn't support font-variations, so you need to manually create the layout and font desc instead of using Gtk.Widget#create_pango_layout
+ if (fillIcons) fontDesc.set_variations("FILL=1");
+ }
+
+ const iconLayout = PangoCairo.create_layout(cr);
+ iconLayout.set_font_description(fontDesc);
+ iconLayout.set_text(icon.get(), -1);
+
+ const [iw, ih] = iconLayout.get_pixel_size();
+ let diff;
+ if (vertical) {
+ diff = (progressValue * height) / ih;
+ cr.moveTo(
+ (width - iw) / 2,
+ Math.max(nh, Math.min(height - ih, progressPosition - ih / 2 + radius))
+ );
+ } else {
+ diff = (progressValue * width) / iw;
+ cr.moveTo(
+ Math.min(
+ width - nw * 1.1 - iw - radius,
+ Math.max(0, progressPosition - iw / 2 - radius)
+ ),
+ (height - ih) / 2
+ );
+ }
+ diff = Math.max(0, Math.min(1, diff));
+
+ cr.setSourceRGBA(
+ mix(fg.red, bg.red, diff),
+ mix(fg.green, bg.green, diff),
+ mix(fg.blue, bg.blue, diff),
+ mix(fg.alpha, bg.alpha, diff)
+ );
+
+ PangoCairo.show_layout(cr, iconLayout);
+ });
+ }}
+ />
+ </box>
+ </PopupWindow>
+);
+
+const Volume = ({ audio }: { audio: AstalWp.Audio }) => (
+ <SliderOsd
+ fillIcons
+ type="volume"
+ windowSetup={(self, show) => {
+ self.hook(audio.defaultSpeaker, "notify::volume", show);
+ self.hook(audio.defaultSpeaker, "notify::mute", show);
+ }}
+ className={audio.defaultSpeaker.mute ? "mute" : ""}
+ initValue={audio.defaultSpeaker.volume}
+ drawAreaSetup={(self, icon) => {
+ const updateIcon = () => {
+ if (/head(phone|set)/i.test(audio.defaultSpeaker.icon)) icon.set("headphones");
+ else if (audio.defaultSpeaker.mute) icon.set("no_sound");
+ else if (audio.defaultSpeaker.volume === 0) icon.set("volume_mute");
+ else if (audio.defaultSpeaker.volume <= 0.5) icon.set("volume_down");
+ else icon.set("volume_up");
+ };
+ updateIcon();
+ self.hook(audio.defaultSpeaker, "notify::icon", updateIcon);
+ self.hook(audio.defaultSpeaker, "notify::mute", () => {
+ updateIcon();
+ self.toggleClassName("mute", audio.defaultSpeaker.mute);
+ });
+ self.hook(audio.defaultSpeaker, "notify::volume", () => {
+ updateIcon();
+ self.css = `font-size: ${audio.defaultSpeaker.volume}px`;
+ });
+ }}
+ />
+);
+
+const Brightness = ({ monitor }: { monitor: Monitor }) => (
+ <SliderOsd
+ monitor={monitor}
+ type="brightness"
+ windowSetup={(self, show) => self.hook(monitor, "notify::brightness", show)}
+ initValue={monitor.brightness}
+ drawAreaSetup={(self, icon) => {
+ const update = () => {
+ if (monitor.brightness > 0.66) icon.set("brightness_high");
+ else if (monitor.brightness > 0.33) icon.set("brightness_medium");
+ else if (monitor.brightness > 0) icon.set("brightness_low");
+ else icon.set("brightness_empty");
+ self.css = `font-size: ${monitor.brightness}px`;
+ };
+ self.hook(monitor, "notify::brightness", update);
+ update();
+ }}
+ />
+);
+
+@register()
+class LockOsd extends Widget.Window {
+ readonly lockType: "caps" | "num";
+
+ #timeout: Time | null = null;
+
+ constructor({ type, icon, right }: { type: "caps" | "num"; icon: string; right?: boolean }) {
+ super({
+ visible: false,
+ name: `lock-${type}`,
+ application: App,
+ namespace: `caelestia-lock-${type}`,
+ anchor:
+ Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT,
+ exclusivity: Astal.Exclusivity.IGNORE,
+ });
+
+ this.lockType = type;
+ this.#update();
+
+ this.add(
+ <box vertical halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER} className={`lock ${type}`}>
+ <label vexpand className="icon" label={icon} />
+ <label vexpand className="text" label={type.slice(0, 1).toUpperCase() + type.slice(1) + "lock"} />
+ </box>
+ );
+
+ // Clickthrough
+ this.connect("size-allocate", () => this.input_shape_combine_region(new Cairo.Region()));
+
+ // Move over when other indicator opens/closes
+ this.hook(App, "window-toggled", (_, window) => {
+ if (window !== this && window instanceof LockOsd) {
+ const child = this.get_child();
+ if (!child) return;
+ this[right ? "marginLeft" : "marginRight"] = window.visible
+ ? child.get_preferred_width()[1] + config.lock.spacing
+ : 0;
+ }
+ });
+ }
+
+ #update() {
+ execAsync(`fish -c 'cat /sys/class/leds/input*::${this.lockType}lock/brightness'`)
+ .then(out => (this.get_child() as Widget.Box | null)?.toggleClassName("enabled", out.includes("1")))
+ .catch(console.error);
+ }
+
+ show() {
+ super.show();
+ this.#update();
+ this.#timeout?.cancel();
+ this.#timeout = timeout(config.lock[this.lockType].hideDelay, () => this.hide());
+ }
+}
+
+export default () => {
+ if (AstalWp.get_default()) <Volume audio={AstalWp.get_default()!.audio} />;
+ Monitors.get_default().forEach(monitor => <Brightness monitor={monitor} />);
+
+ <LockOsd type="caps" icon="keyboard_capslock" />;
+ <LockOsd right type="num" icon="filter_1" />;
+
+ return null;
+};
diff --git a/src/services/apps.ts b/src/services/apps.ts
new file mode 100644
index 0000000..5396ac7
--- /dev/null
+++ b/src/services/apps.ts
@@ -0,0 +1,3 @@
+import AstalApps from "gi://AstalApps";
+
+export const Apps = new AstalApps.Apps();
diff --git a/src/services/math.ts b/src/services/math.ts
new file mode 100644
index 0000000..c66798c
--- /dev/null
+++ b/src/services/math.ts
@@ -0,0 +1,151 @@
+import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
+import { derivative, evaluate, rationalize, simplify } from "mathjs/number";
+
+export interface HistoryItem {
+ equation: string;
+ result: string;
+ icon: string;
+}
+
+@register({ GTypeName: "Math" })
+export default class Math extends GObject.Object {
+ static instance: Math;
+ static get_default() {
+ if (!this.instance) this.instance = new Math();
+
+ return this.instance;
+ }
+
+ readonly #maxHistory = 20;
+ readonly #path = `${CACHE}/math-history.json`;
+ readonly #history: HistoryItem[] = [];
+
+ #variables: Record<string, string> = {};
+ #lastExpression: HistoryItem | null = null;
+
+ @property(Object)
+ get history() {
+ return this.#history;
+ }
+
+ #save() {
+ writeFileAsync(this.#path, JSON.stringify(this.#history)).catch(console.error);
+ }
+
+ /**
+ * Commits the last evaluated expression to the history
+ */
+ commit() {
+ if (!this.#lastExpression) return;
+
+ // Try select first to prevent duplicates, if it fails, add it
+ if (!this.select(this.#lastExpression)) {
+ this.#history.unshift(this.#lastExpression);
+ if (this.#history.length > this.#maxHistory) this.#history.pop();
+ this.notify("history");
+ this.#save();
+ }
+ this.#lastExpression = null;
+ }
+
+ /**
+ * Moves an item in the history to the top
+ * @param item The item to select
+ * @returns If the item was successfully selected
+ */
+ select(item: HistoryItem) {
+ const idx = this.#history.findIndex(i => i.equation === item.equation && i.result === item.result);
+ if (idx >= 0) {
+ this.#history.splice(idx, 1);
+ this.#history.unshift(item);
+ this.notify("history");
+ this.#save();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Clears the history and variables
+ */
+ clear() {
+ if (this.#history.length > 0) {
+ this.#history.length = 0;
+ this.notify("history");
+ this.#save();
+ }
+ this.#lastExpression = null;
+ this.#variables = {};
+ }
+
+ /**
+ * Evaluates an equation and adds it to the history
+ * @param equation The equation to evaluate
+ * @returns A {@link HistoryItem} representing the result of the equation
+ */
+ evaluate(equation: string): HistoryItem {
+ if (equation.startsWith("clear"))
+ return {
+ equation: "Clear history",
+ result: "Delete history and previously set variables",
+ icon: "delete_forever",
+ };
+
+ let result: string, icon: string;
+ try {
+ if (equation.startsWith("help")) {
+ equation = "Help";
+ result =
+ "This is a calculator powered by Math.js.\nAvailable functions:\n\thelp: show help\n\tclear: clear history\n\t<x> = <equation>: sets <x> to <equation>\n\tsimplify <equation>: simplifies <equation>\n\tderive <x> <equation>: derives <equation> with respect to <x>\n\tdd<x> <equation>: short form of derive\n\trationalize <equation>: rationalizes <equation>\n\t<equation>: evaluates <equation>\nSee the documentation for syntax and inbuilt functions.";
+ icon = "help";
+ } else if (equation.includes("=")) {
+ const [left, right] = equation.split("=");
+ try {
+ this.#variables[left.trim()] = simplify(right).toString();
+ } catch {
+ this.#variables[left.trim()] = right.trim();
+ }
+ result = this.#variables[left.trim()];
+ icon = "equal";
+ } else if (equation.startsWith("simplify")) {
+ result = simplify(equation.slice(8), this.#variables).toString();
+ icon = "function";
+ } else if (equation.startsWith("derive") || equation.startsWith("dd")) {
+ const isShortForm = equation.startsWith("dd");
+ const respectTo = isShortForm ? equation.split(" ")[0].slice(2) : equation.split(" ")[1];
+ if (!respectTo) throw new Error(`Format: ${isShortForm ? "dd" : "derive "}<respect-to> <equation>`);
+ result = derivative(equation.slice((isShortForm ? 2 : 7) + respectTo.length), respectTo).toString();
+ icon = "function";
+ } else if (equation.startsWith("rationalize")) {
+ result = rationalize(equation.slice(11), this.#variables).toString();
+ icon = "function";
+ } else {
+ result = evaluate(equation, this.#variables).toString();
+ icon = "calculate";
+ }
+ } catch (e) {
+ equation = "Invalid equation: " + equation;
+ result = String(e);
+ icon = "error";
+ }
+
+ return (this.#lastExpression = { equation, result, icon });
+ }
+
+ constructor() {
+ super();
+
+ // Load history
+ if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) {
+ try {
+ this.#history = JSON.parse(readFile(this.#path));
+ // Init eval to create variables and last expression
+ for (const item of this.#history) this.evaluate(item.equation);
+ } catch (e) {
+ console.error("Math - Unable to load history", e);
+ }
+ }
+ }
+}
diff --git a/src/services/monitors.ts b/src/services/monitors.ts
new file mode 100644
index 0000000..78a0161
--- /dev/null
+++ b/src/services/monitors.ts
@@ -0,0 +1,127 @@
+import { GObject, execAsync, property, register } from "astal";
+import AstalHyprland from "gi://AstalHyprland";
+
+@register({ GTypeName: "Monitor" })
+export class Monitor extends GObject.Object {
+ readonly monitor: AstalHyprland.Monitor;
+ readonly width: number;
+ readonly height: number;
+ readonly id: number;
+ readonly serial: string;
+ readonly name: string;
+ readonly description: string;
+
+ @property(AstalHyprland.Workspace)
+ get activeWorkspace() {
+ return this.monitor.activeWorkspace;
+ }
+
+ isDdc: boolean = false;
+ busNum?: string;
+
+ #brightness: number = 0;
+
+ @property(Number)
+ get brightness() {
+ return this.#brightness;
+ }
+
+ set brightness(value) {
+ value = Math.min(1, Math.max(0, value));
+
+ this.#brightness = value;
+ this.notify("brightness");
+ execAsync(
+ this.isDdc
+ ? `ddcutil -b ${this.busNum} setvcp 10 ${Math.round(value * 100)}`
+ : `brightnessctl set ${Math.floor(value * 100)}% -q`
+ ).catch(console.error);
+ }
+
+ constructor(monitor: AstalHyprland.Monitor) {
+ super();
+
+ this.monitor = monitor;
+ this.width = monitor.width;
+ this.height = monitor.height;
+ this.id = monitor.id;
+ this.serial = monitor.serial;
+ this.name = monitor.name;
+ this.description = monitor.description;
+
+ monitor.connect("notify::active-workspace", () => this.notify("active-workspace"));
+
+ execAsync("ddcutil detect --brief")
+ .then(out => {
+ this.isDdc = out.split("\n\n").some(display => {
+ if (!/^Display \d+/.test(display)) return false;
+ const lines = display.split("\n");
+ if (lines[3].split(":")[3] !== monitor.serial) return false;
+ this.busNum = lines[1].split("/dev/i2c-")[1];
+ return true;
+ });
+ })
+ .catch(() => (this.isDdc = false))
+ .finally(async () => {
+ if (this.isDdc) {
+ const info = (await execAsync(`ddcutil -b ${this.busNum} getvcp 10 --brief`)).split(" ");
+ this.#brightness = Number(info[3]) / Number(info[4]);
+ } else
+ this.#brightness =
+ Number(await execAsync("brightnessctl get")) / Number(await execAsync("brightnessctl max"));
+ });
+ }
+}
+
+@register({ GTypeName: "Monitors" })
+export default class Monitors extends GObject.Object {
+ static instance: Monitors;
+ static get_default() {
+ if (!this.instance) this.instance = new Monitors();
+
+ return this.instance;
+ }
+
+ readonly #map: Map<number, Monitor> = new Map();
+
+ @property(Object)
+ get map() {
+ return this.#map;
+ }
+
+ @property(Object)
+ get list() {
+ return Array.from(this.#map.values());
+ }
+
+ @property(Monitor)
+ get active() {
+ return this.#map.get(AstalHyprland.get_default().focusedMonitor.id)!;
+ }
+
+ #notify() {
+ this.notify("map");
+ this.notify("list");
+ }
+
+ forEach(fn: (monitor: Monitor) => void) {
+ for (const monitor of this.#map.values()) fn(monitor);
+ }
+
+ constructor() {
+ super();
+
+ const hyprland = AstalHyprland.get_default();
+
+ for (const monitor of hyprland.monitors) this.#map.set(monitor.id, new Monitor(monitor));
+ if (this.#map.size > 0) this.#notify();
+
+ hyprland.connect("monitor-added", (_, monitor) => {
+ this.#map.set(monitor.id, new Monitor(monitor));
+ this.#notify();
+ });
+ hyprland.connect("monitor-removed", (_, id) => this.#map.delete(id) && this.#notify());
+
+ hyprland.connect("notify::focused-monitor", () => this.notify("active"));
+ }
+}
diff --git a/src/services/players.ts b/src/services/players.ts
new file mode 100644
index 0000000..b81d4b5
--- /dev/null
+++ b/src/services/players.ts
@@ -0,0 +1,154 @@
+import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
+import AstalMpris from "gi://AstalMpris";
+import { isRealPlayer } from "../utils/mpris";
+
+@register({ GTypeName: "Players" })
+export default class Players extends GObject.Object {
+ static instance: Players;
+ static get_default() {
+ if (!this.instance) this.instance = new Players();
+
+ return this.instance;
+ }
+
+ readonly #path = `${CACHE}/players.txt`;
+ readonly #players: AstalMpris.Player[] = [];
+ readonly #subs = new Map<
+ JSX.Element,
+ { signals: string[]; callback: () => void; ids: number[]; player: AstalMpris.Player | null }
+ >();
+
+ @property(AstalMpris.Player)
+ get lastPlayer(): AstalMpris.Player | null {
+ return this.#players.length > 0 && this.#players[0].identity !== null ? this.#players[0] : null;
+ }
+
+ /**
+ * List of real players.
+ */
+ @property(Object)
+ get list() {
+ return this.#players;
+ }
+
+ hookLastPlayer(widget: JSX.Element, signal: string, callback: () => void): this;
+ hookLastPlayer(widget: JSX.Element, signals: string[], callback: () => void): this;
+ hookLastPlayer(widget: JSX.Element, signals: string | string[], callback: () => void) {
+ if (!Array.isArray(signals)) signals = [signals];
+ // Add subscription
+ if (this.lastPlayer)
+ this.#subs.set(widget, {
+ signals,
+ callback,
+ ids: signals.map(s => this.lastPlayer!.connect(s, callback)),
+ player: this.lastPlayer,
+ });
+ else this.#subs.set(widget, { signals, callback, ids: [], player: null });
+
+ // Remove subscription on widget destroyed
+ widget.connect("destroy", () => {
+ const sub = this.#subs.get(widget);
+ if (sub?.player) sub.ids.forEach(id => sub.player!.disconnect(id));
+ this.#subs.delete(widget);
+ });
+
+ // Initial run of callback
+ callback();
+
+ // For chaining
+ return this;
+ }
+
+ makeCurrent(player: AstalMpris.Player) {
+ const index = this.#players.indexOf(player);
+ // Ignore if already current
+ if (index === 0) return;
+ // Remove if present
+ else if (index > 0) this.#players.splice(index, 1);
+ // Connect signals if not already in list (i.e. new player)
+ else this.#connectPlayerSignals(player);
+
+ // Add to front
+ this.#players.unshift(player);
+ this.#updatePlayer();
+
+ // Save to file
+ this.#save();
+ }
+
+ #updatePlayer() {
+ this.notify("last-player");
+
+ for (const sub of this.#subs.values()) {
+ sub.callback();
+ if (sub.player) sub.ids.forEach(id => sub.player!.disconnect(id));
+ sub.ids = this.lastPlayer ? sub.signals.map(s => this.lastPlayer!.connect(s, sub.callback)) : [];
+ sub.player = this.lastPlayer;
+ }
+ }
+
+ #save() {
+ writeFileAsync(this.#path, this.#players.map(p => p.busName).join("\n")).catch(console.error);
+ }
+
+ #connectPlayerSignals(player: AstalMpris.Player) {
+ // Change order on attribute change
+ for (const signal of [
+ "notify::playback-status",
+ "notify::shuffle-status",
+ "notify::loop-status",
+ "notify::volume",
+ "notify::rate",
+ ])
+ player.connect(signal, () => this.makeCurrent(player));
+ }
+
+ constructor() {
+ super();
+
+ const mpris = AstalMpris.get_default();
+
+ // Load players
+ if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) {
+ this.#players = readFile(this.#path)
+ .split("\n")
+ .map(p => mpris.players.find(p2 => p2.busName === p))
+ .filter(isRealPlayer) as AstalMpris.Player[];
+ // Add new players from in between sessions
+ for (const player of mpris.players)
+ if (!this.#players.includes(player) && isRealPlayer(player)) this.#players.push(player);
+ } else {
+ const sortOrder = [
+ AstalMpris.PlaybackStatus.PLAYING,
+ AstalMpris.PlaybackStatus.PAUSED,
+ AstalMpris.PlaybackStatus.STOPPED,
+ ];
+ this.#players = mpris.players
+ .filter(isRealPlayer)
+ .sort((a, b) => sortOrder.indexOf(a.playbackStatus) - sortOrder.indexOf(b.playbackStatus));
+ }
+ this.#updatePlayer();
+ this.#save();
+ // Connect signals to loaded players
+ for (const player of this.#players) this.#connectPlayerSignals(player);
+
+ // Add and connect signals when added
+ mpris.connect("player-added", (_, player) => {
+ if (isRealPlayer(player)) {
+ this.makeCurrent(player);
+ this.notify("list");
+ }
+ });
+
+ // Remove when closed
+ mpris.connect("player-closed", (_, player) => {
+ const index = this.#players.indexOf(player);
+ if (index >= 0) {
+ this.#players.splice(index, 1);
+ this.notify("list");
+ if (index === 0) this.#updatePlayer();
+ this.#save();
+ }
+ });
+ }
+}
diff --git a/src/services/updates.ts b/src/services/updates.ts
new file mode 100644
index 0000000..5bb6bd1
--- /dev/null
+++ b/src/services/updates.ts
@@ -0,0 +1,148 @@
+import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal";
+import { updates as config } from "../../config";
+
+interface Update {
+ name: string;
+ full: string;
+}
+
+interface Repo {
+ repo?: string[];
+ updates: Update[];
+ icon: string;
+ name: string;
+}
+
+interface Data {
+ cached?: boolean;
+ repos: Repo[];
+ errors: string[];
+}
+
+@register({ GTypeName: "Updates" })
+export default class Updates extends GObject.Object {
+ static instance: Updates;
+ static get_default() {
+ if (!this.instance) this.instance = new Updates();
+
+ return this.instance;
+ }
+
+ readonly #cachePath = `${CACHE}/updates.txt`;
+
+ #timeout?: GLib.Source;
+ #loading = false;
+ #data: Data = { cached: true, repos: [], errors: [] };
+
+ @property(Boolean)
+ get loading() {
+ return this.#loading;
+ }
+
+ @property(Object)
+ get data() {
+ return this.#data;
+ }
+
+ @property(Object)
+ get list() {
+ return this.#data.repos.map(r => r.updates).flat();
+ }
+
+ @property(Number)
+ get numUpdates() {
+ return this.#data.repos.reduce((acc, repo) => acc + repo.updates.length, 0);
+ }
+
+ async #updateFromCache() {
+ this.#data = JSON.parse(await readFileAsync(this.#cachePath));
+ this.notify("data");
+ this.notify("list");
+ this.notify("num-updates");
+ }
+
+ async #getRepo(repo: string) {
+ return (await execAsync(`bash -c "comm -12 <(pacman -Qq | sort) <(pacman -Slq '${repo}' | sort)"`)).split("\n");
+ }
+
+ getUpdates() {
+ // Return if already getting updates
+ if (this.#loading) return;
+
+ this.#loading = true;
+ this.notify("loading");
+
+ // Get new updates
+ Promise.allSettled([execAsync("checkupdates"), execAsync("yay -Qua")])
+ .then(async ([pacman, yay]) => {
+ const data: Data = { repos: [], errors: [] };
+
+ // Pacman updates (checkupdates)
+ if (pacman.status === "fulfilled") {
+ const repos: Repo[] = [
+ { repo: await this.#getRepo("core"), updates: [], icon: "hub", name: "Core repository" },
+ {
+ repo: await this.#getRepo("extra"),
+ updates: [],
+ icon: "add_circle",
+ name: "Extra repository",
+ },
+ {
+ repo: await this.#getRepo("multilib"),
+ updates: [],
+ icon: "account_tree",
+ name: "Multilib repository",
+ },
+ ];
+
+ for (const update of pacman.value.split("\n")) {
+ const pkg = update.split(" ")[0];
+ for (const repo of repos)
+ if (repo.repo?.includes(pkg)) repo.updates.push({ name: pkg, full: update });
+ }
+
+ for (const repo of repos) if (repo.updates.length > 0) data.repos.push(repo);
+ }
+
+ // AUR and devel updates (yay -Qua)
+ if (yay.status === "fulfilled") {
+ const aur: Repo = { updates: [], icon: "deployed_code_account", name: "AUR" };
+
+ for (const update of yay.value.split("\n")) {
+ if (/^\s*->/.test(update)) data.errors.push(update); // Error
+ else aur.updates.push({ name: update.split(" ")[0], full: update });
+ }
+
+ if (aur.updates.length > 0) data.repos.push(aur);
+ }
+
+ if (data.errors.length > 0 && data.repos.length === 0) {
+ this.#updateFromCache().catch(console.error);
+ } else {
+ // Cache and set
+ writeFileAsync(this.#cachePath, JSON.stringify({ cached: true, ...data })).catch(console.error);
+ this.#data = data;
+ this.notify("data");
+ this.notify("list");
+ this.notify("num-updates");
+ }
+
+ this.#loading = false;
+ this.notify("loading");
+
+ this.#timeout?.destroy();
+ this.#timeout = setTimeout(() => this.getUpdates(), config.interval);
+ })
+ .catch(console.error);
+ }
+
+ constructor() {
+ super();
+
+ // Initial update from cache, if fail then write valid data to cache so future reads don't fail
+ this.#updateFromCache().catch(() =>
+ writeFileAsync(this.#cachePath, JSON.stringify(this.#data)).catch(console.error)
+ );
+ this.getUpdates();
+ }
+}
diff --git a/src/utils/icons.ts b/src/utils/icons.ts
new file mode 100644
index 0000000..f12aee0
--- /dev/null
+++ b/src/utils/icons.ts
@@ -0,0 +1,86 @@
+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
+export const osIcons: Record<string, number> = {
+ almalinux: 0xf31d,
+ alpine: 0xf300,
+ arch: 0xf303,
+ arcolinux: 0xf346,
+ centos: 0x304,
+ debian: 0xf306,
+ elementary: 0xf309,
+ endeavouros: 0xf322,
+ fedora: 0xf30a,
+ gentoo: 0xf30d,
+ kali: 0xf327,
+ linuxmint: 0xf30e,
+ mageia: 0xf310,
+ manjaro: 0xf312,
+ nixos: 0xf313,
+ opensuse: 0xf314,
+ suse: 0xf314,
+ sles: 0xf314,
+ sles_sap: 0xf314,
+ pop: 0xf32a,
+ raspbian: 0xf315,
+ rhel: 0xf316,
+ rocky: 0xf32b,
+ slackware: 0xf318,
+ ubuntu: 0xf31b,
+};
+
+export const desktopEntrySubs: Record<string, string> = {
+ Firefox: "firefox",
+};
+
+const categoryIcons: Record<string, string> = {
+ WebBrowser: "web",
+ Printing: "print",
+ Security: "security",
+ Network: "chat",
+ Archiving: "archive",
+ Compression: "archive",
+ Development: "code",
+ IDE: "code",
+ TextEditor: "edit_note",
+ Audio: "music_note",
+ Music: "music_note",
+ Player: "music_note",
+ Recorder: "mic",
+ Game: "sports_esports",
+ FileTools: "files",
+ FileManager: "files",
+ Filesystem: "files",
+ FileTransfer: "files",
+ Settings: "settings",
+ DesktopSettings: "settings",
+ HardwareSettings: "settings",
+ TerminalEmulator: "terminal",
+ ConsoleOnly: "terminal",
+ Utility: "build",
+ Monitor: "monitor_heart",
+ Midi: "graphic_eq",
+ Mixer: "graphic_eq",
+ AudioVideoEditing: "video_settings",
+ AudioVideo: "music_video",
+ Video: "videocam",
+ Building: "construction",
+ Graphics: "photo_library",
+ "2DGraphics": "photo_library",
+ RasterGraphics: "photo_library",
+ TV: "tv",
+ System: "host",
+};
+
+export const getAppCategoryIcon = (nameOrApp: string | AstalApps.Application) => {
+ const 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";
+};
diff --git a/src/utils/mpris.ts b/src/utils/mpris.ts
new file mode 100644
index 0000000..e0cc111
--- /dev/null
+++ b/src/utils/mpris.ts
@@ -0,0 +1,16 @@
+import { GLib } from "astal";
+import AstalMpris from "gi://AstalMpris";
+
+const hasPlasmaIntegration = GLib.find_program_in_path("plasma-browser-integration-host") !== null;
+
+export const isRealPlayer = (player?: AstalMpris.Player) =>
+ player !== undefined &&
+ // Player closed
+ player.identity !== null &&
+ // Remove unecessary native buses from browsers if there's plasma integration
+ !(hasPlasmaIntegration && player.busName.startsWith("org.mpris.MediaPlayer2.firefox")) &&
+ !(hasPlasmaIntegration && player.busName.startsWith("org.mpris.MediaPlayer2.chromium")) &&
+ // playerctld just copies other buses and we don't need duplicates
+ !player.busName.startsWith("org.mpris.MediaPlayer2.playerctld") &&
+ // Non-instance mpd bus
+ !(player.busName.endsWith(".mpd") && !player.busName.endsWith("MediaPlayer2.mpd"));
diff --git a/src/utils/strings.ts b/src/utils/strings.ts
new file mode 100644
index 0000000..e5bc43e
--- /dev/null
+++ b/src/utils/strings.ts
@@ -0,0 +1 @@
+export const ellipsize = (str: string, len = 40) => (str.length > len ? `${str.slice(0, len - 1)}…` : str);
diff --git a/src/utils/system.ts b/src/utils/system.ts
new file mode 100644
index 0000000..5d77908
--- /dev/null
+++ b/src/utils/system.ts
@@ -0,0 +1,21 @@
+import { execAsync, GLib } from "astal";
+import type AstalApps from "gi://AstalApps";
+import { osIcons } from "./icons";
+
+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(
+ (() => {
+ if (osIcons.hasOwnProperty(osId)) return osIcons[osId];
+ if (osIdLike) for (const id of osIdLike.split(" ")) if (osIcons.hasOwnProperty(id)) return osIcons[id];
+ return 0xf31a;
+ })()
+);
diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts
new file mode 100644
index 0000000..08f9740
--- /dev/null
+++ b/src/utils/widgets.ts
@@ -0,0 +1,45 @@
+import { Binding } from "astal";
+import { Astal, Widget } from "astal/gtk3";
+import AstalHyprland from "gi://AstalHyprland";
+
+export const setupCustomTooltip = (self: any, text: string | Binding<string>) => {
+ if (!text) return null;
+
+ const window = new Widget.Window({
+ visible: false,
+ namespace: "caelestia-tooltip",
+ keymode: Astal.Keymode.NONE,
+ exclusivity: Astal.Exclusivity.IGNORE,
+ anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT,
+ child: new Widget.Label({ className: "tooltip", label: text }),
+ });
+ self.set_tooltip_window(window);
+
+ let dirty = true;
+ let lastX = 0;
+ self.connect("size-allocate", () => (dirty = true));
+ window.connect("size-allocate", () => {
+ window.marginLeft = lastX + (self.get_allocated_width() - window.get_preferred_width()[1]) / 2;
+ });
+ if (text instanceof Binding) self.hook(text, (_: any, v: string) => !v && window.hide());
+
+ self.connect("query-tooltip", (_: any, x: number, y: number) => {
+ if (text instanceof Binding && !text.get()) return false;
+ if (dirty) {
+ const { width, height } = self.get_allocation();
+ const { x: cx, y: cy } = AstalHyprland.get_default().get_cursor_position();
+ window.marginLeft = cx + ((width - window.get_preferred_width()[1]) / 2 - x);
+ window.marginTop = cy + (height - y);
+ lastX = cx - x;
+ dirty = false;
+ }
+ return true;
+ });
+
+ self.connect("destroy", () => window.destroy());
+
+ return window;
+};
+
+export const setupChildClickthrough = (self: any) =>
+ self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes());
diff --git a/src/widgets/notification.tsx b/src/widgets/notification.tsx
new file mode 100644
index 0000000..0bef5ca
--- /dev/null
+++ b/src/widgets/notification.tsx
@@ -0,0 +1,132 @@
+import { GLib, register, timeout } from "astal";
+import { Astal, Gtk, Widget } from "astal/gtk3";
+import AstalNotifd from "gi://AstalNotifd";
+import { notifpopups as config } from "../../config";
+import { desktopEntrySubs } from "../utils/icons";
+
+const urgencyToString = (urgency: AstalNotifd.Urgency) => {
+ switch (urgency) {
+ case AstalNotifd.Urgency.LOW:
+ return "low";
+ case AstalNotifd.Urgency.NORMAL:
+ return "normal";
+ case AstalNotifd.Urgency.CRITICAL:
+ return "critical";
+ }
+};
+
+const getTime = (time: number) => {
+ const messageTime = GLib.DateTime.new_from_unix_local(time);
+ const todayDay = GLib.DateTime.new_now_local().get_day_of_year();
+ if (messageTime.get_day_of_year() === todayDay) {
+ const aMinuteAgo = GLib.DateTime.new_now_local().add_seconds(-60);
+ return aMinuteAgo !== null && messageTime.compare(aMinuteAgo) > 0 ? "Now" : messageTime.format("%H:%M");
+ } else if (messageTime.get_day_of_year() === todayDay - 1) return "Yesterday";
+ return messageTime.format("%d/%m");
+};
+
+const AppIcon = ({ appIcon, desktopEntry }: { appIcon: string; desktopEntry: string }) => {
+ // Try app icon
+ let icon = Astal.Icon.lookup_icon(appIcon) && appIcon;
+ // Try desktop entry
+ if (!icon) {
+ if (desktopEntrySubs.hasOwnProperty(desktopEntry)) icon = desktopEntrySubs[desktopEntry];
+ else if (Astal.Icon.lookup_icon(desktopEntry)) icon = desktopEntry;
+ }
+ return icon ? <icon className="app-icon" icon={icon} /> : null;
+};
+
+const Image = ({ icon }: { icon: string }) => {
+ if (GLib.file_test(icon, GLib.FileTest.EXISTS))
+ return (
+ <box
+ valign={Gtk.Align.START}
+ className="image"
+ css={`
+ background-image: url("${icon}");
+ `}
+ />
+ );
+ if (Astal.Icon.lookup_icon(icon)) return <icon valign={Gtk.Align.START} className="image" icon={icon} />;
+ return null;
+};
+
+@register()
+export default class Notification extends Widget.Box {
+ readonly #revealer;
+ #destroyed = false;
+
+ constructor({ notification, popup }: { notification: AstalNotifd.Notification; popup?: boolean }) {
+ super();
+
+ this.#revealer = (
+ <revealer revealChild transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} transitionDuration={150}>
+ <box className="wrapper">
+ <box vertical className={`notification ${urgencyToString(notification.urgency)}`}>
+ <box className="header">
+ <AppIcon appIcon={notification.appIcon} desktopEntry={notification.appName} />
+ <label className="app-name" label={notification.appName ?? "Unknown"} />
+ <box hexpand />
+ <label
+ className="time"
+ label={getTime(notification.time)!}
+ setup={self =>
+ timeout(60000, () => !this.#destroyed && (self.label = getTime(notification.time)!))
+ }
+ />
+ </box>
+ <box hexpand className="separator" />
+ <box className="content">
+ {notification.image && <Image icon={notification.image} />}
+ <box vertical>
+ <label className="summary" xalign={0} label={notification.summary} truncate />
+ <label className="body" xalign={0} label={notification.body} wrap useMarkup />
+ </box>
+ </box>
+ <box className="actions">
+ <button hexpand cursor="pointer" onClicked={() => notification.dismiss()} label="Close" />
+ {notification.actions.map(a => (
+ <button hexpand cursor="pointer" onClicked={() => notification.invoke(a.id)}>
+ {notification.actionIcons ? <icon icon={a.label} /> : a.label}
+ </button>
+ ))}
+ </box>
+ </box>
+ </box>
+ </revealer>
+ ) as Widget.Revealer;
+ this.add(this.#revealer);
+
+ // Init animation
+ const width = this.get_preferred_width()[1];
+ this.css = `margin-left: ${width}px; margin-right: -${width}px;`;
+ timeout(1, () => {
+ this.css = `transition: 300ms cubic-bezier(0.05, 0.9, 0.1, 1.1); margin-left: 0; margin-right: 0;`;
+ });
+
+ // Close popup after timeout if transient or expire enabled in config
+ if (popup && (config.expire || notification.transient))
+ timeout(
+ notification.expireTimeout > 0
+ ? notification.expireTimeout
+ : notification.urgency === AstalNotifd.Urgency.CRITICAL
+ ? 10000
+ : 5000,
+ () => this.destroyWithAnims()
+ );
+ }
+
+ destroyWithAnims() {
+ if (this.#destroyed) return;
+ this.#destroyed = true;
+
+ const animTime = 120;
+ const animMargin = this.get_allocated_width();
+ this.css = `transition: ${animTime}ms cubic-bezier(0.85, 0, 0.15, 1);
+ margin-left: ${animMargin}px; margin-right: -${animMargin}px;`;
+ timeout(animTime, () => {
+ this.#revealer.revealChild = false;
+ timeout(this.#revealer.transitionDuration, () => this.destroy());
+ });
+ }
+}
diff --git a/src/widgets/popupwindow.ts b/src/widgets/popupwindow.ts
new file mode 100644
index 0000000..67aa0ff
--- /dev/null
+++ b/src/widgets/popupwindow.ts
@@ -0,0 +1,39 @@
+import { Binding, register } from "astal";
+import { App, Astal, Gdk, Widget } from "astal/gtk3";
+import AstalHyprland from "gi://AstalHyprland?version=0.1";
+
+const extendProp = <T>(
+ prop: T | Binding<T | undefined> | undefined,
+ override: (prop: T | undefined) => T | undefined
+) => prop && (prop instanceof Binding ? prop.as(override) : override(prop));
+
+@register()
+export default class PopupWindow extends Widget.Window {
+ constructor(props: Widget.WindowProps) {
+ super({
+ keymode: Astal.Keymode.ON_DEMAND,
+ exclusivity: Astal.Exclusivity.IGNORE,
+ ...props,
+ visible: false,
+ application: App,
+ name: props.monitor ? extendProp(props.name, n => (n ? n + props.monitor : undefined)) : props.name,
+ namespace: extendProp(props.name, n => `caelestia-${n}`),
+ onKeyPressEvent: (self, event) => {
+ // Close window on escape
+ if (event.get_keyval()[1] === Gdk.KEY_Escape) self.hide();
+
+ return props.onKeyPressEvent?.(self, event);
+ },
+ borderWidth: 20, // To allow shadow, cause if not it gets cut off
+ });
+ }
+
+ popup_at_widget(widget: JSX.Element, event: Gdk.Event) {
+ const { width, height } = widget.get_allocation();
+ const [_, x, y] = event.get_coords();
+ const { x: cx, y: cy } = AstalHyprland.get_default().get_cursor_position();
+ this.marginLeft = cx + ((width - this.get_preferred_width()[1]) / 2 - x);
+ this.marginTop = cy + (height - y);
+ this.show();
+ }
+}