summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-03-25 12:59:51 +1100
committer2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-03-25 12:59:51 +1100
commitfe978092e8c13b337eb4e58b9b08b9ea5cc93413 (patch)
tree3138398d1a9386f858948d97eb33a69c0a9ea4c1 /src
parentnotifpopups: destroy event wrapper (diff)
downloadcaelestia-shell-fe978092e8c13b337eb4e58b9b08b9ea5cc93413.tar.gz
caelestia-shell-fe978092e8c13b337eb4e58b9b08b9ea5cc93413.tar.bz2
caelestia-shell-fe978092e8c13b337eb4e58b9b08b9ea5cc93413.zip
sidebar: create dashboard
Diffstat (limited to 'src')
-rw-r--r--src/modules/sidebar/dashboard.tsx123
-rw-r--r--src/modules/sidebar/index.tsx34
-rw-r--r--src/modules/sidebar/modules/hwresources.tsx67
-rw-r--r--src/modules/sidebar/modules/notifications.tsx70
-rw-r--r--src/services/calendar.ts0
-rw-r--r--src/widgets/notification.tsx26
-rw-r--r--src/widgets/slider.tsx53
7 files changed, 364 insertions, 9 deletions
diff --git a/src/modules/sidebar/dashboard.tsx b/src/modules/sidebar/dashboard.tsx
new file mode 100644
index 0000000..b7d03d0
--- /dev/null
+++ b/src/modules/sidebar/dashboard.tsx
@@ -0,0 +1,123 @@
+import Players from "@/services/players";
+import { osIcon, osId } from "@/utils/system";
+import Slider from "@/widgets/slider";
+import { bind, GLib, monitorFile, Variable } from "astal";
+import { Gtk } from "astal/gtk3";
+import AstalMpris from "gi://AstalMpris";
+import Notifications from "./modules/notifications";
+
+const lengthStr = (length: number) =>
+ `${Math.floor(length / 60)}:${Math.floor(length % 60)
+ .toString()
+ .padStart(2, "0")}`;
+
+const FaceFallback = () => (
+ <label
+ setup={self => {
+ const name = GLib.get_real_name();
+ if (name !== "Unknown")
+ self.label = name
+ .split(" ")
+ .map(s => s[0].toUpperCase())
+ .join("");
+ else {
+ self.label = "";
+ self.xalign = 0.44;
+ }
+ }}
+ />
+);
+
+const User = () => {
+ const uptime = Variable("").poll(5000, "uptime -p");
+ const hasFace = Variable(GLib.file_test(HOME + "/.face", GLib.FileTest.EXISTS));
+ monitorFile(HOME + "/.face", () => hasFace.set(GLib.file_test(HOME + "/.face", GLib.FileTest.EXISTS)));
+
+ return (
+ <box className="user">
+ <box
+ homogeneous
+ className="face"
+ setup={self => {
+ self.css = `background-image: url("${HOME}/.face");`;
+ monitorFile(HOME + "/.face", () => (self.css = `background-image: url("${HOME}/.face");`));
+ }}
+ onDestroy={() => hasFace.drop()}
+ >
+ {bind(hasFace).as(h => (h ? <box visible={false} /> : <FaceFallback />))}
+ </box>
+ <box vertical hexpand valign={Gtk.Align.CENTER} className="details">
+ <label xalign={0} className="name" label={`${osIcon} ${GLib.get_user_name()}`} />
+ <label xalign={0} label={(GLib.getenv("XDG_CURRENT_DESKTOP") ?? osId).toUpperCase()} />
+ <label truncate xalign={0} className="uptime" label={bind(uptime)} onDestroy={() => uptime.drop()} />
+ </box>
+ </box>
+ );
+};
+
+const QuickToggles = () => <box></box>;
+
+const Media = ({ player }: { player: AstalMpris.Player }) => {
+ const position = Variable.derive([bind(player, "position"), bind(player, "length")], (p, l) => p / l);
+
+ return (
+ <box className="media" onDestroy={() => position.drop()}>
+ <box
+ homogeneous
+ className="cover-art"
+ css={bind(player, "coverArt").as(a => `background-image: url("${a}");`)}
+ >
+ {bind(player, "coverArt").as(a => (a ? <box visible={false} /> : <label xalign={0.31} label="" />))}
+ </box>
+ <box vertical className="details">
+ <label truncate className="title" label={bind(player, "title")} />
+ <label truncate className="artist" label={bind(player, "artist")} />
+ <box hexpand className="controls">
+ <button
+ hexpand
+ sensitive={bind(player, "canGoPrevious")}
+ cursor="pointer"
+ onClicked={() => player.next()}
+ label="󰒮"
+ />
+ <button
+ hexpand
+ sensitive={bind(player, "canControl")}
+ cursor="pointer"
+ onClicked={() => player.play_pause()}
+ label={bind(player, "playbackStatus").as(s =>
+ s === AstalMpris.PlaybackStatus.PLAYING ? "󰏤" : "󰐊"
+ )}
+ />
+ <button
+ hexpand
+ sensitive={bind(player, "canGoNext")}
+ cursor="pointer"
+ onClicked={() => player.next()}
+ label="󰒭"
+ />
+ </box>
+ <Slider value={bind(position)} />
+ <box className="time">
+ <label label={bind(player, "position").as(lengthStr)} />
+ <box hexpand />
+ <label label={bind(player, "length").as(lengthStr)} />
+ </box>
+ </box>
+ </box>
+ );
+};
+
+const Today = () => <box></box>;
+
+export default () => (
+ <box vertical className="pane dashboard" name="dashboard">
+ <User />
+ <box className="separator" />
+ {bind(Players.get_default(), "lastPlayer").as(p => (
+ <Media player={p} />
+ ))}
+ <box className="separator" />
+ <Notifications />
+ </box>
+);
diff --git a/src/modules/sidebar/index.tsx b/src/modules/sidebar/index.tsx
new file mode 100644
index 0000000..3b62d82
--- /dev/null
+++ b/src/modules/sidebar/index.tsx
@@ -0,0 +1,34 @@
+import type { Monitor } from "@/services/monitors";
+import { bind, register, Variable } from "astal";
+import { App, Astal, Gtk, Widget } from "astal/gtk3";
+import Dashboard from "./dashboard";
+
+@register()
+export default class SideBar extends Widget.Window {
+ readonly shown: Variable<string> = Variable("dashboard");
+
+ constructor({ monitor }: { monitor: Monitor }) {
+ super({
+ application: App,
+ name: "sidebar",
+ namespace: "caelestia-sidebar",
+ monitor: monitor.id,
+ anchor: Astal.WindowAnchor.LEFT | Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM,
+ exclusivity: Astal.Exclusivity.EXCLUSIVE,
+ // visible: false,
+ });
+
+ this.add(
+ <box vertical className="sidebar">
+ <stack
+ vexpand
+ transitionType={Gtk.StackTransitionType.SLIDE_UP_DOWN}
+ transitionDuration={200}
+ shown={bind(this.shown)}
+ >
+ <Dashboard />
+ </stack>
+ </box>
+ );
+ }
+}
diff --git a/src/modules/sidebar/modules/hwresources.tsx b/src/modules/sidebar/modules/hwresources.tsx
new file mode 100644
index 0000000..768d8bd
--- /dev/null
+++ b/src/modules/sidebar/modules/hwresources.tsx
@@ -0,0 +1,67 @@
+import Cpu from "@/services/cpu";
+import Gpu from "@/services/gpu";
+import Memory from "@/services/memory";
+import Storage from "@/services/storage";
+import Slider from "@/widgets/slider";
+import { bind, type Binding } from "astal";
+import { Gtk, type Widget } from "astal/gtk3";
+
+const fmt = (bytes: number, pow: number) => +(bytes / 1024 ** pow).toFixed(2);
+const format = ({ total, used }: { total: number; used: number }) => {
+ if (total >= 1024 ** 4) return `${fmt(used, 4)}/${fmt(total, 4)} TiB`;
+ if (total >= 1024 ** 3) return `${fmt(used, 3)}/${fmt(total, 3)} GiB`;
+ if (total >= 1024 ** 2) return `${fmt(used, 2)}/${fmt(total, 2)} MiB`;
+ if (total >= 1024) return `${fmt(used, 1)}/${fmt(total, 1)} KiB`;
+ return `${used}/${total} B`;
+};
+
+const Resource = ({
+ icon,
+ name,
+ value,
+ labelSetup,
+}: {
+ icon: string;
+ name: string;
+ value: Binding<number>;
+ labelSetup?: (self: Widget.Label) => void;
+}) => (
+ <box vertical className={`resource ${name}`}>
+ <box className="inner">
+ <label label={icon} />
+ <Slider value={value.as(v => v / 100)} />
+ </box>
+ <label halign={Gtk.Align.END} label={labelSetup ? "" : value.as(v => `${+v.toFixed(2)}%`)} setup={labelSetup} />
+ </box>
+);
+
+export default () => (
+ <box vertical className="hw-resources">
+ {Gpu.get_default().available && <Resource icon="󰢮" name="gpu" value={bind(Gpu.get_default(), "usage")} />}
+ <Resource icon="" name="cpu" value={bind(Cpu.get_default(), "usage")} />
+ <Resource
+ icon=""
+ name="memory"
+ value={bind(Memory.get_default(), "usage")}
+ labelSetup={self => {
+ const mem = Memory.get_default();
+ const update = () => (self.label = format(mem));
+ self.hook(mem, "notify::used", update);
+ self.hook(mem, "notify::total", update);
+ update();
+ }}
+ />
+ <Resource
+ icon="󰋊"
+ name="storage"
+ value={bind(Storage.get_default(), "usage")}
+ labelSetup={self => {
+ const storage = Storage.get_default();
+ const update = () => (self.label = format(storage));
+ self.hook(storage, "notify::used", update);
+ self.hook(storage, "notify::total", update);
+ update();
+ }}
+ />
+ </box>
+);
diff --git a/src/modules/sidebar/modules/notifications.tsx b/src/modules/sidebar/modules/notifications.tsx
new file mode 100644
index 0000000..eb8f0aa
--- /dev/null
+++ b/src/modules/sidebar/modules/notifications.tsx
@@ -0,0 +1,70 @@
+import Notification from "@/widgets/notification";
+import { bind } from "astal";
+import { Astal, Gtk } from "astal/gtk3";
+import AstalNotifd from "gi://AstalNotifd";
+
+const List = () => (
+ <box
+ vertical
+ valign={Gtk.Align.START}
+ className="list"
+ setup={self => {
+ const notifd = AstalNotifd.get_default();
+ const map = new Map<number, Notification>();
+
+ const addNotification = (notification: AstalNotifd.Notification) => {
+ const notif = (<Notification notification={notification} compact />) as Notification;
+ notif.connect("destroy", () => map.get(notification.id) === notif && map.delete(notification.id));
+ map.get(notification.id)?.destroyWithAnims();
+ map.set(notification.id, notif);
+
+ const widget = (
+ <eventbox
+ // Dismiss on middle click
+ onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()}
+ setup={self => self.hook(notif, "destroy", () => self.destroy())}
+ >
+ {notif}
+ </eventbox>
+ );
+
+ self.pack_end(widget, false, false, 0);
+ };
+
+ notifd
+ .get_notifications()
+ .sort((a, b) => a.time - b.time)
+ .forEach(addNotification);
+
+ self.hook(notifd, "notified", (_, id) => addNotification(notifd.get_notification(id)));
+ self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims());
+ }}
+ />
+);
+
+export default () => (
+ <box vertical className="notifications">
+ <box className="header-bar">
+ <label
+ label={bind(AstalNotifd.get_default(), "notifications").as(
+ n => `${n.length} notification${n.length === 1 ? "" : "s"}`
+ )}
+ />
+ <box hexpand />
+ <button
+ className={bind(AstalNotifd.get_default(), "dontDisturb").as(d => (d ? "enabled" : ""))}
+ cursor="pointer"
+ onClicked={() => (AstalNotifd.get_default().dontDisturb = !AstalNotifd.get_default().dontDisturb)}
+ label="󰂛 Silence"
+ />
+ <button
+ cursor="pointer"
+ onClicked={() => AstalNotifd.get_default().notifications.forEach(n => n.dismiss())}
+ label="󰎟 Clear"
+ />
+ </box>
+ <scrollable expand hscroll={Gtk.PolicyType.NEVER}>
+ <List />
+ </scrollable>
+ </box>
+);
diff --git a/src/services/calendar.ts b/src/services/calendar.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/services/calendar.ts
diff --git a/src/widgets/notification.tsx b/src/widgets/notification.tsx
index 1048826..368b99b 100644
--- a/src/widgets/notification.tsx
+++ b/src/widgets/notification.tsx
@@ -51,19 +51,19 @@ const AppIcon = ({ appIcon, desktopEntry }: { appIcon: string; desktopEntry: str
return icon ? <icon className="app-icon" icon={icon} /> : null;
};
-const Image = ({ popup, icon }: { popup?: boolean; icon: string }) => {
+const Image = ({ compact, icon }: { compact?: boolean; icon: string }) => {
if (GLib.file_test(icon, GLib.FileTest.EXISTS))
return (
<box
valign={Gtk.Align.START}
- className={`image ${popup ? "small" : ""}`}
+ className={`image ${compact ? "small" : ""}`}
css={`
background-image: url("${icon}");
`}
/>
);
if (Astal.Icon.lookup_icon(icon))
- return <icon valign={Gtk.Align.START} className={`image ${popup ? "small" : ""}`} icon={icon} />;
+ return <icon valign={Gtk.Align.START} className={`image ${compact ? "small" : ""}`} icon={icon} />;
return null;
};
@@ -72,7 +72,15 @@ export default class Notification extends Widget.Box {
readonly #revealer;
#destroyed = false;
- constructor({ notification, popup }: { notification: AstalNotifd.Notification; popup?: boolean }) {
+ constructor({
+ notification,
+ popup,
+ compact = popup,
+ }: {
+ notification: AstalNotifd.Notification;
+ popup?: boolean;
+ compact?: boolean;
+ }) {
super({ className: "notification" });
const time = Variable(getTime(notification.time)).poll(60000, () => getTime(notification.time));
@@ -94,17 +102,17 @@ export default class Notification extends Widget.Box {
</box>
<box hexpand className="separator" />
<box className="content">
- {notification.image && <Image popup={popup} icon={notification.image} />}
+ {notification.image && <Image compact={compact} icon={notification.image} />}
<box vertical>
<label className="summary" xalign={0} label={notification.summary} truncate />
{notification.body && (
<label
className="body"
xalign={0}
- label={popup ? notification.body.split("\n")[0] : notification.body}
+ label={compact ? notification.body.split("\n")[0] : notification.body}
wrap
- lines={popup ? 1 : -1}
- truncate={popup}
+ lines={compact ? 1 : -1}
+ truncate={compact}
/>
)}
</box>
@@ -132,7 +140,7 @@ export default class Notification extends Widget.Box {
// Init animation
const width = this.get_preferred_width()[1];
- this.css = `margin-left: ${width}px; margin-right: -${width}px;`;
+ if (popup) this.css = `margin-left: ${width}px; margin-right: -${width}px;`;
timeout(1, () => {
this.#revealer.revealChild = true;
this.css = `transition: 300ms cubic-bezier(0.05, 0.9, 0.1, 1.1); margin-left: 0; margin-right: 0;`;
diff --git a/src/widgets/slider.tsx b/src/widgets/slider.tsx
new file mode 100644
index 0000000..fb219bd
--- /dev/null
+++ b/src/widgets/slider.tsx
@@ -0,0 +1,53 @@
+import { bind, type Binding } from "astal";
+import { Gtk } from "astal/gtk3";
+import type cairo from "cairo";
+
+export default ({ value }: { value: Binding<number> }) => (
+ <drawingarea
+ hexpand
+ valign={Gtk.Align.CENTER}
+ className="slider"
+ css={bind(value).as(v => `font-size: ${v}px;`)}
+ setup={self => {
+ const halfPi = Math.PI / 2;
+
+ const styleContext = self.get_style_context();
+ self.set_size_request(-1, styleContext.get_property("min-height", Gtk.StateFlags.NORMAL) as number);
+
+ self.connect("draw", (_, cr: cairo.Context) => {
+ const styleContext = self.get_style_context();
+
+ const width = self.get_allocated_width();
+ const height = styleContext.get_property("min-height", Gtk.StateFlags.NORMAL) as number;
+ self.set_size_request(-1, height);
+
+ const progressValue = styleContext.get_property("font-size", Gtk.StateFlags.NORMAL) as number;
+ let radius = styleContext.get_property("border-radius", Gtk.StateFlags.NORMAL) as number;
+
+ const bg = styleContext.get_background_color(Gtk.StateFlags.NORMAL);
+ cr.setSourceRGBA(bg.red, bg.green, bg.blue, bg.alpha);
+
+ // Background
+ cr.arc(radius, radius, radius, -Math.PI, -halfPi); // Top left
+ cr.arc(width - radius, radius, radius, -halfPi, 0); // Top right
+ cr.arc(width - radius, height - radius, radius, 0, halfPi); // Bottom right
+ cr.arc(radius, height - radius, radius, halfPi, Math.PI); // Bottom left
+ cr.fill();
+
+ // Flatten when near 0
+ radius = Math.min(radius, Math.min(width * progressValue, height) / 2);
+
+ const progressPosition = width * progressValue - radius;
+ const fg = styleContext.get_color(Gtk.StateFlags.NORMAL);
+ cr.setSourceRGBA(fg.red, fg.green, fg.blue, fg.alpha);
+
+ // Foreground
+ 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();
+ });
+ }}
+ />
+);