summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--app.tsx28
-rw-r--r--assets/icons/caelestia-media-generic-symbolic.svg2
-rw-r--r--assets/icons/caelestia-media-none-symbolic.svg19
-rw-r--r--assets/icons/caelestia-spotify-symbolic.svg21
-rw-r--r--modules/bar.tsx283
-rw-r--r--scss/_font.scss21
-rw-r--r--scss/_lib.scss29
-rw-r--r--scss/bar.scss103
-rw-r--r--scss/scheme/_mocha.scss26
-rw-r--r--scss/widgets.scss120
-rw-r--r--services/apps.ts3
-rw-r--r--services/players.ts157
-rw-r--r--style.scss9
-rw-r--r--utils/constants.ts3
-rw-r--r--utils/icons.ts118
-rw-r--r--utils/mpris.ts16
-rw-r--r--utils/strings.ts1
-rw-r--r--utils/system.ts21
-rw-r--r--utils/widgets.tsx45
20 files changed, 1026 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index c5d317c..89341d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
@girs/
node_modules/
+scss/scheme/_index.scss
diff --git a/app.tsx b/app.tsx
new file mode 100644
index 0000000..4f61699
--- /dev/null
+++ b/app.tsx
@@ -0,0 +1,28 @@
+import { execAsync, GLib, writeFileAsync } from "astal";
+import { App } from "astal/gtk3";
+import AstalHyprland from "gi://AstalHyprland";
+import Bar from "./modules/bar";
+
+const loadStyleAsync = async () => {
+ if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS))
+ await writeFileAsync(`${SRC}/scss/scheme/_index.scss`, '@forward "mocha";');
+ App.apply_css(await execAsync(`sass ${SRC}/style.scss`), true);
+};
+
+App.start({
+ instanceName: "caelestia",
+ icons: "assets/icons",
+ main() {
+ loadStyleAsync().catch(console.error);
+
+ AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />);
+
+ console.log("Caelestia started");
+ },
+ requestHandler(request, res) {
+ if (request === "reload css") loadStyleAsync().catch(console.error);
+ else return res("Unknown command: " + request);
+ console.log(`Request handled: ${request}`);
+ res("OK");
+ },
+});
diff --git a/assets/icons/caelestia-media-generic-symbolic.svg b/assets/icons/caelestia-media-generic-symbolic.svg
new file mode 100644
index 0000000..8ff60ed
--- /dev/null
+++ b/assets/icons/caelestia-media-generic-symbolic.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.772 4.28c.56-.144 1.097.246 1.206.814.1.517-.263 1.004-.771 1.14A7 7 0 1 0 19 12.9c.009-.5.4-.945.895-1 .603-.067 1.112.371 1.106.977L21 13c0 .107-.002.213-.006.32a.898.898 0 0 1 0 .164l-.008.122a9 9 0 0 1-9.172 8.392A9 9 0 0 1 9.772 4.28z" fill="#000000"/><path d="M15.93 13.753a4.001 4.001 0 1 1-6.758-3.581A4 4 0 0 1 12 9c.75 0 1.3.16 2 .53 0 0 .15.09.25.17-.1-.35-.228-1.296-.25-1.7a58.75 58.75 0 0 1-.025-2.035V2.96c0-.52.432-.94.965-.94.103 0 .206.016.305.048l4.572 1.689c.446.145.597.23.745.353.148.122.258.27.33.446.073.176.108.342.108.801v1.16c0 .518-.443.94-.975.94a.987.987 0 0 1-.305-.049l-1.379-.447-.151-.05c-.437-.14-.618-.2-.788-.26a5.697 5.697 0 0 1-.514-.207 3.53 3.53 0 0 1-.213-.107c-.098-.05-.237-.124-.521-.263L16 6l.011 7c0 .255-.028.507-.082.753h.001z" fill="#000000"/></svg> \ No newline at end of file
diff --git a/assets/icons/caelestia-media-none-symbolic.svg b/assets/icons/caelestia-media-none-symbolic.svg
new file mode 100644
index 0000000..20ea19a
--- /dev/null
+++ b/assets/icons/caelestia-media-none-symbolic.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="800px" height="800px" viewBox="0 0 45.793 45.793"
+ xml:space="preserve">
+<g>
+ <g>
+ <circle cx="22.899" cy="12.692" r="2.524"/>
+ <path d="M22.899,26.661c-2.893,0-5.245,2.354-5.245,5.245c0,2.893,2.353,5.244,5.245,5.244s5.246-2.353,5.246-5.244
+ C28.145,29.016,25.791,26.661,22.899,26.661z"/>
+ <path d="M30.701,0H15.093c-4.647,0-8.415,3.768-8.415,8.414v28.965c0,4.646,3.768,8.414,8.415,8.414H30.7
+ c4.647,0,8.415-3.768,8.415-8.414V8.414C39.116,3.768,35.348,0,30.701,0z M22.899,7.182c3.042,0,5.511,2.467,5.511,5.511
+ c0,3.043-2.469,5.511-5.511,5.511c-3.044,0-5.511-2.468-5.511-5.511C17.388,9.648,19.855,7.182,22.899,7.182z M22.899,42.13
+ c-5.646,0-10.223-4.577-10.223-10.224s4.576-10.223,10.223-10.223c5.646,0,10.223,4.577,10.223,10.223S28.544,42.13,22.899,42.13z
+ "/>
+ </g>
+</g>
+</svg> \ No newline at end of file
diff --git a/assets/icons/caelestia-spotify-symbolic.svg b/assets/icons/caelestia-spotify-symbolic.svg
new file mode 100644
index 0000000..bf01823
--- /dev/null
+++ b/assets/icons/caelestia-spotify-symbolic.svg
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 305 305" xml:space="preserve">
+<g id="XMLID_85_">
+ <path id="XMLID_86_" d="M152.441,0C68.385,0,0,68.39,0,152.453C0,236.568,68.385,305,152.441,305
+ C236.562,305,305,236.568,305,152.453C305,68.39,236.562,0,152.441,0z M75.08,208.47c17.674-5.38,35.795-8.108,53.857-8.108
+ c30.676,0,60.96,7.774,87.592,22.49c1.584,0.863,3.024,3.717,3.67,7.27c0.646,3.552,0.389,7.205-0.648,9.105
+ c-1.309,2.438-3.965,4.014-6.768,4.014c-1.389,0-2.61-0.312-3.831-0.972c-24.448-13.438-52.116-20.542-80.015-20.542
+ c-16.855,0-33.402,2.495-49.167,7.409c-0.768,0.233-1.558,0.352-2.348,0.352c-3.452,0.001-6.448-2.198-7.453-5.461
+ C68.612,219.566,71.419,209.667,75.08,208.47z M68.43,152.303c19.699-5.355,40.057-8.071,60.508-8.071
+ c36.765,0,73.273,8.896,105.601,25.739c2.266,1.15,3.936,3.1,4.701,5.49c0.776,2.421,0.542,5.024-0.669,7.347
+ c-2.885,5.646-6.257,9.44-8.393,9.44c-1.514,0-2.975-0.363-4.43-1.09c-30.019-15.632-62.59-23.558-96.811-23.558
+ c-19.035,0-37.71,2.503-55.489,7.435c-0.827,0.224-1.676,0.337-2.521,0.337c-4.277,0.001-8.046-2.888-9.162-7.013
+ C60.336,162.994,63.601,153.616,68.43,152.303z M66.727,115.606c-0.903,0.223-1.826,0.335-2.744,0.335
+ c-5.169,0.001-9.648-3.492-10.892-8.487c-1.559-6.323,2.397-13.668,8.126-15.111c22.281-5.473,45.065-8.248,67.72-8.248
+ c43.856,0,85.857,9.86,124.851,29.312c2.708,1.336,4.727,3.642,5.687,6.493c0.96,2.854,0.748,5.926-0.592,8.64
+ c-1.826,3.655-5.772,7.59-10.121,7.59c-1.677,0-3.399-0.393-4.924-1.109c-35.819-17.921-74.477-27.008-114.9-27.008
+ C108.164,108.014,87.234,110.568,66.727,115.606z"/>
+</g>
+</svg> \ No newline at end of file
diff --git a/modules/bar.tsx b/modules/bar.tsx
new file mode 100644
index 0000000..1db5e82
--- /dev/null
+++ b/modules/bar.tsx
@@ -0,0 +1,283 @@
+import { GLib, register, Variable } from "astal";
+import { bind, kebabify } from "astal/binding";
+import { App, Astal, astalify, Gdk, Gtk, type ConstructProps } from "astal/gtk3";
+import AstalHyprland from "gi://AstalHyprland";
+import AstalNotifd from "gi://AstalNotifd";
+import AstalTray from "gi://AstalTray";
+import Players from "../services/players";
+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 wsPerGroup = 10;
+
+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(hyprland.focusedClient?.title ?? "");
+ hookFocusedClientProp(self, "title", c => title.set(c?.title ?? ""));
+
+ 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 ? getAppCategoryIcon(c.class) : "desktop_windows";
+ })
+ }
+ />
+ <label
+ setup={self => hookFocusedClientProp(self, "title", c => (self.label = c ? 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 (
+ <box
+ className="module media-playing"
+ setup={self => {
+ const label = Variable(getLabel());
+ players.hookLastPlayer(self, ["notify::title", "notify::artist"], () => label.set(getLabel()));
+ setupCustomTooltip(self, bind(label));
+ }}
+ >
+ <icon
+ setup={self =>
+ players.hookLastPlayer(self, "notify::entry", () => {
+ const icon = `caelestia-${players.lastPlayer?.entry}-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 = getLabel("No media");
+ })
+ }
+ />
+ </box>
+ );
+};
+
+const Workspace = ({ idx }: { idx: number }) => {
+ let wsId = Math.floor((hyprland.focusedWorkspace.id - 1) / wsPerGroup) * 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.toggleClassName("focused", hyprland.focusedWorkspace.id === wsId);
+ };
+
+ self.hook(hyprland, "notify::focused-workspace", () => {
+ wsId = Math.floor((hyprland.focusedWorkspace.id - 1) / wsPerGroup) * wsPerGroup + idx;
+ update();
+ });
+ self.hook(hyprland, "client-added", update);
+ self.hook(hyprland, "client-moved", update);
+ self.hook(hyprland, "client-removed", update);
+
+ 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: wsPerGroup }).map((_, idx) => (
+ <Workspace idx={idx + 1} /> // Start from 1
+ ))}
+ </box>
+ </eventbox>
+);
+
+@register()
+class TrayItemMenu extends astalify(Gtk.Menu) {
+ constructor(props: ConstructProps<TrayItemMenu, Gtk.Menu.ConstructorProps> & { item: AstalTray.TrayItem }) {
+ const { item, ...sProps } = props;
+ super(sProps as any);
+
+ 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);
+ }
+}
+
+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(self, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null);
+ else item.activate(0, 0);
+ } else if (event.button === Astal.MouseButton.SECONDARY)
+ menu.popup_at_widget(self, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null);
+ }}
+ 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 Notifications = () => {
+ const unreadCount = Variable(0);
+ return (
+ <box
+ className="module notifications"
+ setup={self =>
+ setupCustomTooltip(
+ self,
+ bind(unreadCount).as(n => n + " unread notifications")
+ )
+ }
+ >
+ <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("%d/%m/%y %R") ?? new Date().toLocaleString();
+ return null;
+ });
+ self.connect("destroy", () => pollVar.drop());
+ }}
+ />
+ </box>
+);
+
+const Power = () => (
+ <button
+ className="module power"
+ label="power_settings_new"
+ cursor="pointer"
+ onClicked={() => {
+ // TODO: Power menu
+ }}
+ />
+);
+
+export default ({ monitor }: { monitor: AstalHyprland.Monitor }) => (
+ <window
+ namespace="bar"
+ monitor={monitor.id}
+ anchor={Astal.WindowAnchor.TOP}
+ exclusivity={Astal.Exclusivity.EXCLUSIVE}
+ visible
+ >
+ <centerbox className="bar" css={"min-width: " + monitor.width * 0.8 + "px;"}>
+ <box halign={Gtk.Align.START}>
+ <OSIcon />
+ <ActiveWindow />
+ <MediaPlaying />
+ </box>
+ <Workspaces />
+ <box halign={Gtk.Align.END}>
+ <Tray />
+ <Notifications />
+ <DateTime />
+ <Power />
+ </box>
+ </centerbox>
+ </window>
+);
diff --git a/scss/_font.scss b/scss/_font.scss
new file mode 100644
index 0000000..405a850
--- /dev/null
+++ b/scss/_font.scss
@@ -0,0 +1,21 @@
+@mixin title {
+ font-family: "Gabarito", "Poppins", "Readex Pro", "Lexend", sans-serif;
+}
+
+@mixin main {
+ font-family: "Rubik", "Geist", "AR One Sans", "Reddit Sans", "Inter", "Roboto", "Ubuntu", "Noto Sans", sans-serif;
+}
+
+@mixin icon {
+ font-family: "Material Symbols Rounded", "MaterialSymbolsRounded", "Material Symbols Outlined",
+ "Material Symbols Sharp";
+}
+
+@mixin mono {
+ font-family: "JetBrains Mono NF", "JetBrains Mono Nerd Font", "JetBrains Mono NL", "SpaceMono NF",
+ "SpaceMono Nerd Font", monospace;
+}
+
+@mixin reading {
+ font-family: "Readex Pro", "Lexend", "Noto Sans", sans-serif;
+}
diff --git a/scss/_lib.scss b/scss/_lib.scss
new file mode 100644
index 0000000..19a2867
--- /dev/null
+++ b/scss/_lib.scss
@@ -0,0 +1,29 @@
+$scale: 0.068rem;
+@function s($value: 1) {
+ @return $value * $scale;
+}
+
+@mixin rounded($all, $tl: $all, $tr: $all, $br: $all, $bl: $all) {
+ border-radius: s($tl) s($tr) s($br) s($bl);
+ -gtk-outline-radius: s($tl) s($tr) s($br) s($bl);
+}
+
+@mixin border($colour, $width: 1, $style: solid) {
+ border: s($width) $style $colour;
+}
+
+@mixin spacing($val: 5, $vertical: false) {
+ $dir: if($vertical, bottom, right);
+
+ & > * {
+ margin-#{$dir}: s($val);
+
+ &:last-child {
+ margin-#{$dir}: 0;
+ }
+ }
+}
+
+@mixin element-decel {
+ transition: 200ms cubic-bezier(0, 0.55, 0.45, 1);
+}
diff --git a/scss/bar.scss b/scss/bar.scss
new file mode 100644
index 0000000..618944a
--- /dev/null
+++ b/scss/bar.scss
@@ -0,0 +1,103 @@
+@use "sass:color";
+@use "lib";
+@use "scheme";
+@use "font";
+
+.bar {
+ @include lib.rounded(10, $tl: 0, $tr: 0);
+ @include lib.border(color.change(scheme.$rosewater, $alpha: 0.7), 2);
+ @include lib.spacing(10);
+ @include font.mono;
+
+ border-top: none;
+ background-color: scheme.$base;
+ padding: lib.s(8) lib.s(20);
+ font-size: lib.s(14);
+
+ & > * {
+ @include lib.spacing(10);
+ }
+
+ .module {
+ @include lib.rounded(5);
+ @include lib.spacing;
+
+ padding: lib.s(3) lib.s(8);
+ background-color: scheme.$surface0;
+ }
+
+ label.icon {
+ @include font.icon;
+
+ font-size: lib.s(18);
+ }
+
+ .os-icon {
+ @include lib.border(scheme.$yellow);
+
+ color: scheme.$yellow;
+ font-size: lib.s(14);
+ padding-right: lib.s(12);
+ }
+
+ .active-window {
+ color: scheme.$pink;
+ }
+
+ .media-playing {
+ @include lib.spacing(8);
+
+ color: scheme.$lavender;
+
+ icon {
+ font-size: lib.s(16);
+ }
+ }
+
+ .workspaces {
+ @include lib.border(scheme.$maroon);
+
+ padding: lib.s(3) lib.s(18);
+
+ & > * {
+ @include lib.rounded(2);
+ @include lib.element-decel;
+
+ min-width: lib.s(20);
+ min-height: lib.s(4);
+ background-color: scheme.$surface1;
+
+ &.occupied {
+ background-color: scheme.$sky;
+ }
+
+ &.focused {
+ min-width: lib.s(30);
+ background-color: scheme.$mauve;
+ }
+ }
+ }
+
+ .tray {
+ @include lib.spacing(10);
+
+ font-size: lib.s(15);
+ }
+
+ .notifications {
+ color: scheme.$mauve;
+ }
+
+ .date-time {
+ color: scheme.$peach;
+ }
+
+ .power {
+ @include lib.border(scheme.$red);
+ @include font.icon;
+
+ color: scheme.$red;
+ font-weight: bold;
+ font-size: lib.s(16);
+ }
+}
diff --git a/scss/scheme/_mocha.scss b/scss/scheme/_mocha.scss
new file mode 100644
index 0000000..728949d
--- /dev/null
+++ b/scss/scheme/_mocha.scss
@@ -0,0 +1,26 @@
+$rosewater: #f5e0dc;
+$flamingo: #f2cdcd;
+$pink: #f5c2e7;
+$mauve: #cba6f7;
+$red: #f38ba8;
+$maroon: #eba0ac;
+$peach: #fab387;
+$yellow: #f9e2af;
+$green: #a6e3a1;
+$teal: #94e2d5;
+$sky: #89dceb;
+$sapphire: #74c7ec;
+$blue: #89b4fa;
+$lavender: #b4befe;
+$text: #cdd6f4;
+$subtext1: #bac2de;
+$subtext0: #a6adc8;
+$overlay2: #9399b2;
+$overlay1: #7f849c;
+$overlay0: #6c7086;
+$surface2: #585b70;
+$surface1: #45475a;
+$surface0: #313244;
+$base: #1e1e2e;
+$mantle: #181825;
+$crust: #11111b;
diff --git a/scss/widgets.scss b/scss/widgets.scss
new file mode 100644
index 0000000..0e11f46
--- /dev/null
+++ b/scss/widgets.scss
@@ -0,0 +1,120 @@
+@use "sass:color";
+@use "scheme";
+@use "lib";
+@use "font";
+
+@keyframes appear {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@mixin -appear($duration: 100ms) {
+ animation-name: appear;
+ animation-duration: $duration;
+ animation-timing-function: ease-out;
+ animation-iteration-count: 1;
+}
+
+menu {
+ @include -appear;
+ @include lib.rounded(5);
+ @include lib.border(color.change(scheme.$blue, $alpha: 0.7), 2);
+ @include font.main;
+
+ padding: lib.s(10);
+ background-color: scheme.$surface0;
+ color: scheme.$text;
+
+ & > menuitem {
+ @include lib.rounded(8);
+
+ padding: lib.s(8) lib.s(16);
+ background: transparent;
+ transition: 0.2s ease background-color;
+
+ &:hover,
+ &:focus {
+ background-color: scheme.$surface1;
+ }
+
+ &:active {
+ background-color: scheme.$surface2;
+ }
+
+ &:disabled {
+ color: scheme.$subtext0;
+ }
+
+ & > arrow {
+ @include lib.rounded(1000);
+
+ min-width: lib.s(5);
+ min-height: lib.s(5);
+ background-color: scheme.$blue;
+
+ &.right {
+ margin-left: lib.s(12);
+ }
+
+ &.left {
+ margin-right: lib.s(12);
+ }
+ }
+ }
+
+ & > separator {
+ @include lib.rounded(1);
+
+ background-color: scheme.$blue;
+ min-width: lib.s(0.5);
+ min-height: lib.s(0.5);
+ margin: lib.s(8) 0;
+ }
+}
+
+tooltip,
+.tooltip {
+ @include lib.rounded(5);
+ @include lib.border(color.change(scheme.$teal, $alpha: 0.7));
+ @include font.reading;
+
+ background-color: scheme.$surface0;
+ color: scheme.$text;
+ padding: lib.s(4) lib.s(8);
+}
+
+tooltip {
+ @include -appear(200ms);
+}
+
+scrollbar {
+ trough {
+ @include lib.rounded(1000);
+
+ min-width: lib.s(12);
+ background-color: transparent;
+ }
+
+ slider {
+ @include lib.rounded(1000);
+ @include lib.element-decel;
+
+ min-width: lib.s(6);
+ min-height: lib.s(30);
+ background-color: color.change(scheme.$overlay0, $alpha: 0.3);
+
+ &:hover,
+ &:focus {
+ background-color: color.change(scheme.$overlay0, $alpha: 0.4);
+ }
+
+ &:active {
+ background-color: color.change(scheme.$overlay1, $alpha: 0.5);
+ }
+ }
+}
diff --git a/services/apps.ts b/services/apps.ts
new file mode 100644
index 0000000..5396ac7
--- /dev/null
+++ b/services/apps.ts
@@ -0,0 +1,3 @@
+import AstalApps from "gi://AstalApps";
+
+export const Apps = new AstalApps.Apps();
diff --git a/services/players.ts b/services/players.ts
new file mode 100644
index 0000000..2a32960
--- /dev/null
+++ b/services/players.ts
@@ -0,0 +1,157 @@
+import { execAsync, GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
+import AstalMpris from "gi://AstalMpris";
+import { CACHE_DIR } from "../utils/constants";
+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_DIR}/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() {
+ execAsync(`mkdir -p ${CACHE_DIR}`)
+ .then(() => writeFileAsync(this.#path, this.#players.map(p => p.busName).join("\n")).catch(console.error))
+ .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/style.scss b/style.scss
new file mode 100644
index 0000000..8359137
--- /dev/null
+++ b/style.scss
@@ -0,0 +1,9 @@
+// Common widgets
+@use "scss/widgets";
+
+// Modules
+@use "scss/bar";
+
+* {
+ all: unset; // Remove GTK theme styles
+}
diff --git a/utils/constants.ts b/utils/constants.ts
new file mode 100644
index 0000000..d907014
--- /dev/null
+++ b/utils/constants.ts
@@ -0,0 +1,3 @@
+import { GLib } from "astal";
+
+export const CACHE_DIR = GLib.get_user_cache_dir() + "/caelestia";
diff --git a/utils/icons.ts b/utils/icons.ts
new file mode 100644
index 0000000..0293611
--- /dev/null
+++ b/utils/icons.ts
@@ -0,0 +1,118 @@
+import { Gio } from "astal";
+import { Astal } from "astal/gtk3";
+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,
+};
+
+const appIcons: Record<string, string> = {
+ "code-url-handler": "visual-studio-code",
+ code: "visual-studio-code",
+ "codium-url-handler": "vscodium",
+ codium: "vscodium",
+ "GitHub Desktop": "github-desktop",
+ "gnome-tweaks": "org.gnome.tweaks",
+ "org.pulseaudio.pavucontrol": "pavucontrol",
+ "pavucontrol-qt": "pavucontrol",
+ "jetbrains-pycharm-ce": "pycharm-community",
+ "Spotify Free": "Spotify",
+ safeeyes: "io.github.slgobinath.SafeEyes",
+ "yad-icon-browser": "yad",
+ xterm: "uxterm",
+ "com-atlauncher-App": "atlauncher",
+ avidemux3_qt5: "avidemux",
+};
+
+const appRegex = [
+ { regex: /^steam_app_(\d+)$/, replace: "steam_icon_$1" },
+ { regex: /^Minecraft\* [0-9\.]+$/, replace: "minecraft" },
+];
+
+export const getAppIcon = (name: string) => {
+ if (appIcons.hasOwnProperty(name)) return appIcons[name];
+ for (const { regex, replace } of appRegex) {
+ const postSub = name.replace(regex, replace);
+ if (postSub !== name) return postSub;
+ }
+
+ if (Astal.Icon.lookup_icon(name)) return name;
+
+ const apps = Apps.fuzzy_query(name);
+ if (apps.length > 0) return apps[0].iconName;
+
+ return "image";
+};
+
+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 = (name: string) => {
+ const categories =
+ Gio.DesktopAppInfo.new(`${name}.desktop`)?.get_categories()?.split(";") ??
+ Apps.fuzzy_query(name)[0]?.categories;
+ if (categories)
+ for (const [key, value] of Object.entries(categoryIcons)) if (categories.includes(key)) return value;
+ return "terminal";
+};
diff --git a/utils/mpris.ts b/utils/mpris.ts
new file mode 100644
index 0000000..8f6923a
--- /dev/null
+++ b/utils/mpris.ts
@@ -0,0 +1,16 @@
+import AstalMpris from "gi://AstalMpris";
+import { inPath } from "./system";
+
+const hasPlasmaIntegration = inPath("plasma-browser-integration-host");
+
+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/utils/strings.ts b/utils/strings.ts
new file mode 100644
index 0000000..e5bc43e
--- /dev/null
+++ b/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/utils/system.ts b/utils/system.ts
new file mode 100644
index 0000000..9a328d5
--- /dev/null
+++ b/utils/system.ts
@@ -0,0 +1,21 @@
+import { exec, GLib } from "astal";
+import { osIcons } from "./icons";
+
+export const inPath = (bin: string) => {
+ try {
+ exec(`which ${bin}`);
+ } catch {
+ return false;
+ }
+ return true;
+};
+
+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/utils/widgets.tsx b/utils/widgets.tsx
new file mode 100644
index 0000000..2078aad
--- /dev/null
+++ b/utils/widgets.tsx
@@ -0,0 +1,45 @@
+import { Binding } from "astal";
+import { Astal, type Widget } from "astal/gtk3";
+import AstalHyprland from "gi://AstalHyprland";
+
+export const setupCustomTooltip = (self: any, text: string | Binding<string>) => {
+ if (!text) return null;
+
+ const window = (
+ <window
+ visible={false}
+ namespace="tooltip"
+ keymode={Astal.Keymode.NONE}
+ exclusivity={Astal.Exclusivity.IGNORE}
+ anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT}
+ >
+ <label className="tooltip" label={text} />
+ </window>
+ ) as Widget.Window;
+ 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;
+};