sidebar: create dashboard

This commit is contained in:
2 * r + 2 * t 2025-03-25 12:59:51 +11:00
parent d1daba8aac
commit fe978092e8
12 changed files with 592 additions and 35 deletions

View file

@ -4,6 +4,7 @@ import NotifPopups from "@/modules/notifpopups";
import Osds from "@/modules/osds";
import Popdowns from "@/modules/popdowns";
import Session from "@/modules/session";
import SideBar from "@/modules/sidebar";
import Monitors from "@/services/monitors";
import Palette from "@/services/palette";
import Players from "@/services/players";
@ -73,6 +74,7 @@ App.start({
<NotifPopups />;
<Osds />;
<Session />;
Monitors.get_default().forEach(m => <SideBar monitor={m} />);
Monitors.get_default().forEach(m => <Bar monitor={m} />);
<Popdowns />;

View file

@ -61,25 +61,4 @@ label.icon {
font-size: lib.s(14);
color: scheme.$subtext0;
}
.actions {
@include lib.spacing;
& > * {
@include lib.rounded(5);
@include lib.element-decel;
padding: lib.s(5) lib.s(10);
background-color: scheme.$surface0;
&:hover,
&:focus {
background-color: scheme.$surface1;
}
&:active {
background-color: scheme.$surface2;
}
}
}
}

View file

@ -3,7 +3,7 @@
@use "lib";
@use "font";
@mixin popup($colour, $alpha) {
@mixin popup($colour) {
.separator {
background-color: $colour;
}
@ -29,16 +29,16 @@
@include lib.shadow;
&.low {
@include popup(scheme.$overlay0, 0.3);
@include popup(scheme.$overlay0);
}
&.normal {
@include popup(scheme.$primary, 0.3);
@include popup(scheme.$primary);
}
&.critical {
@include lib.border(scheme.$error, 0.5);
@include popup(scheme.$error, 0.8);
@include popup(scheme.$error);
}
}
}

221
scss/sidebar.scss Normal file
View file

@ -0,0 +1,221 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
@mixin notification($accent) {
.separator {
background-color: $accent;
}
.image {
@include lib.border($accent, 0.05);
}
}
.sidebar {
@include font.mono;
background-color: scheme.$mantle;
color: scheme.$text;
padding: lib.s(18) lib.s(20);
min-width: lib.s(380);
.pane {
@include lib.spacing(20, true);
}
.separator {
background-color: if(scheme.$light, scheme.$surface1, scheme.$overlay0);
margin: 0 lib.s(10);
}
.user {
@include lib.spacing(15);
.face {
@include lib.rounded(10);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
min-width: lib.s(96);
min-height: lib.s(96);
font-size: lib.s(48);
font-weight: bold;
background-color: scheme.$base;
}
.details {
font-size: lib.s(14);
color: scheme.$yellow;
@include lib.spacing(8, true);
.name {
font-size: lib.s(18);
color: scheme.$text;
margin-bottom: lib.s(10);
}
.uptime {
color: scheme.$blue;
}
}
}
.media {
@include lib.spacing(15);
.cover-art {
@include lib.rounded(10);
@include lib.element-decel;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
min-width: lib.s(128);
min-height: lib.s(128);
font-size: lib.s(64);
font-weight: bold;
background-color: scheme.$base;
}
.details {
font-size: lib.s(14);
.title {
font-size: lib.s(16);
color: scheme.$text;
}
.artist {
color: scheme.$green;
}
.controls {
margin-top: lib.s(20);
margin-bottom: lib.s(5);
font-size: lib.s(24);
& > * {
@include lib.element-decel;
&:disabled {
color: scheme.$overlay0;
}
&:hover,
&:focus {
color: scheme.$subtext0;
}
&:active {
color: scheme.$overlay2;
}
}
}
.slider {
@include lib.rounded(5);
@include lib.fluent-decel(1000ms);
min-height: lib.s(8);
background-color: scheme.$surface0;
color: scheme.$overlay0;
}
.time {
margin-top: lib.s(5);
font-size: lib.s(13);
color: scheme.$subtext0;
}
}
}
.notifications {
.header-bar {
margin-bottom: lib.s(10);
margin-right: lib.s(-10);
@include lib.spacing;
& > button {
@include lib.element-decel;
@include lib.rounded(10);
padding: lib.s(3) lib.s(8);
&:hover,
&:focus {
color: scheme.$subtext0;
}
&:active {
color: scheme.$overlay2;
}
&.enabled {
background-color: scheme.$primary;
color: scheme.$base;
&:hover,
&:focus {
background-color: color.mix(scheme.$primary, scheme.$base, 80%);
}
&:active {
background-color: color.mix(scheme.$primary, scheme.$base, 70%);
}
}
}
}
.notification {
.wrapper {
padding-bottom: lib.s(10);
}
.inner {
@include lib.rounded(20);
background-color: color.change(scheme.$surface1, $alpha: 0.4);
&.low {
@include notification(if(scheme.$light, scheme.$surface1, scheme.$overlay0));
}
&.normal {
@include lib.border(scheme.$primary, if(scheme.$light, 0.5, 0.3));
@include notification(scheme.$primary);
}
&.critical {
@include lib.border(scheme.$error, 0.8);
@include notification(scheme.$error);
}
}
.actions {
@include lib.spacing;
& > button {
@include lib.rounded(10);
@include lib.element-decel;
padding: lib.s(5) lib.s(10);
background-color: color.change(scheme.$surface1, $alpha: 0.5);
&:hover,
&:focus {
background-color: color.change(scheme.$surface2, $alpha: 0.5);
}
&:active {
background-color: color.change(scheme.$overlay0, $alpha: 0.5);
}
}
}
}
}
}

View file

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

View file

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

View file

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

View file

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

0
src/services/calendar.ts Normal file
View file

View file

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

53
src/widgets/slider.tsx Normal file
View file

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

View file

@ -10,8 +10,8 @@
@use "scss/notifpopups";
@use "scss/launcher";
@use "scss/osds";
@use "scss/popdowns";
@use "scss/session";
@use "scss/sidebar";
* {
all: unset; // Remove GTK theme styles