This commit is contained in:
2 * r + 2 * t 2025-01-12 18:00:54 +11:00
parent 0a7cc46048
commit b4aca729dd
20 changed files with 1026 additions and 0 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
@girs/
node_modules/
scss/scheme/_index.scss

28
app.tsx Normal file
View file

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

View file

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

After

Width:  |  Height:  |  Size: 1 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.8 KiB

283
modules/bar.tsx Normal file
View file

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

21
scss/_font.scss Normal file
View file

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

29
scss/_lib.scss Normal file
View file

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

103
scss/bar.scss Normal file
View file

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

26
scss/scheme/_mocha.scss Normal file
View file

@ -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;

120
scss/widgets.scss Normal file
View file

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

3
services/apps.ts Normal file
View file

@ -0,0 +1,3 @@
import AstalApps from "gi://AstalApps";
export const Apps = new AstalApps.Apps();

157
services/players.ts Normal file
View file

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

9
style.scss Normal file
View file

@ -0,0 +1,9 @@
// Common widgets
@use "scss/widgets";
// Modules
@use "scss/bar";
* {
all: unset; // Remove GTK theme styles
}

3
utils/constants.ts Normal file
View file

@ -0,0 +1,3 @@
import { GLib } from "astal";
export const CACHE_DIR = GLib.get_user_cache_dir() + "/caelestia";

118
utils/icons.ts Normal file
View file

@ -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";
};

16
utils/mpris.ts Normal file
View file

@ -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"));

1
utils/strings.ts Normal file
View file

@ -0,0 +1 @@
export const ellipsize = (str: string, len = 40) => (str.length > len ? `${str.slice(0, len - 1)}` : str);

21
utils/system.ts Normal file
View file

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

45
utils/widgets.tsx Normal file
View file

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