notifications: make popup window

This commit is contained in:
2 * r + 2 * t 2025-01-16 18:47:23 +11:00
parent 02fd2e97f2
commit ed05e9af25
12 changed files with 302 additions and 155 deletions

View file

@ -2,6 +2,7 @@ import { execAsync, GLib, writeFileAsync } from "astal";
import { App } from "astal/gtk3"; import { App } from "astal/gtk3";
import Bar from "./src/modules/bar"; import Bar from "./src/modules/bar";
import Launcher from "./src/modules/launcher"; import Launcher from "./src/modules/launcher";
import Notifications from "./src/modules/notifications";
import NotifPopups from "./src/modules/notifpopups"; import NotifPopups from "./src/modules/notifpopups";
import Osds from "./src/modules/osds"; import Osds from "./src/modules/osds";
import Monitors from "./src/services/monitors"; import Monitors from "./src/services/monitors";
@ -24,6 +25,7 @@ App.start({
<NotifPopups />; <NotifPopups />;
<Osds />; <Osds />;
Monitors.get_default().forEach(m => <Bar monitor={m} />); Monitors.get_default().forEach(m => <Bar monitor={m} />);
<Notifications />;
console.log("Caelestia started"); console.log("Caelestia started");
}, },

View file

@ -15,7 +15,7 @@ $scale: 0.068rem;
border: s($width) $style color.change($colour, $alpha: $alpha); border: s($width) $style color.change($colour, $alpha: $alpha);
} }
@mixin shadow($colour: scheme.$mantle, $alpha: 0.4, $x: 2, $y: 3, $blur: 8, $spread: 0) { @mixin shadow($colour: black, $alpha: 0.4, $x: 2, $y: 3, $blur: 8, $spread: 0) {
box-shadow: s($x) s($y) s($blur) s($spread) color.change($colour, $alpha: $alpha); box-shadow: s($x) s($y) s($blur) s($spread) color.change($colour, $alpha: $alpha);
} }

View file

@ -92,7 +92,7 @@
color: scheme.$blue; color: scheme.$blue;
} }
.notifications { .unread {
color: scheme.$mauve; color: scheme.$mauve;
} }

101
scss/notifications.scss Normal file
View file

@ -0,0 +1,101 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
@mixin popup($accent) {
.separator {
background-color: $accent;
}
.image {
@include lib.border($accent, 0.05);
}
}
.notifications {
@include lib.rounded(8);
@include lib.border(scheme.$mauve, 0.4, 2);
@include lib.shadow;
min-width: lib.s(400);
min-height: lib.s(600);
background-color: scheme.$base;
color: scheme.$mauve;
padding: lib.s(10) lib.s(12);
.header {
@include font.mono;
@include lib.spacing(8);
padding: 0 lib.s(5);
margin-bottom: lib.s(8);
button {
@include lib.rounded(5);
@include lib.element-decel;
padding: lib.s(3) lib.s(8);
&:hover,
&:focus {
background-color: scheme.$surface0;
}
&:active {
background-color: scheme.$surface1;
}
&.enabled {
background-color: scheme.$mauve;
color: scheme.$base;
&:hover,
&:focus {
background-color: color.mix(scheme.$mauve, scheme.$base, 80%);
}
&:active {
background-color: color.mix(scheme.$mauve, scheme.$base, 70%);
}
}
}
}
.notification {
.wrapper {
padding-bottom: lib.s(10);
}
.inner {
background-color: color.mix(scheme.$surface0, scheme.$base, 70%);
&.low {
@include popup(scheme.$overlay0);
}
&.normal {
@include lib.border(scheme.$lavender, 0.3);
@include popup(scheme.$lavender);
}
&.critical {
@include lib.border(scheme.$red, 0.5);
@include popup(scheme.$red);
}
}
.actions > * {
background-color: scheme.$surface1;
&:hover,
&:focus {
background-color: scheme.$surface2;
}
&:active {
background-color: scheme.$overlay0;
}
}
}
}

View file

@ -21,86 +21,22 @@
min-width: lib.s(410); min-width: lib.s(410);
padding-left: lib.s(10); // So notifications can overshoot for init animation padding-left: lib.s(10); // So notifications can overshoot for init animation
.wrapper {
padding-top: lib.s(10);
}
.notification { .notification {
@include lib.rounded(8, $tr: 0, $br: 0); .wrapper {
@include lib.shadow; padding-top: lib.s(10);
@include font.main;
@include popup(scheme.$lavender);
background-color: scheme.$base;
color: scheme.$text;
padding: lib.s(10) lib.s(12);
@include lib.spacing($vertical: true);
&.low {
@include popup(scheme.$overlay0);
} }
&.critical { .inner {
@include popup(scheme.$red); @include lib.rounded(8, $tr: 0, $br: 0);
} @include lib.shadow;
} @include popup(scheme.$lavender);
.header, &.low {
.content { @include popup(scheme.$overlay0);
padding: 0 lib.s(5);
}
.header {
@include font.mono;
@include lib.spacing(5);
}
.content {
@include lib.spacing(10);
}
.app-icon {
font-size: lib.s(18);
}
.image {
@include lib.rounded(10);
background-size: cover;
background-position: center;
min-width: lib.s(64);
min-height: lib.s(64);
margin-top: lib.s(3);
}
.summary {
@include font.title;
font-size: lib.s(16);
}
.body {
font-size: lib.s(14);
}
.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 { &.critical {
background-color: scheme.$surface2; @include popup(scheme.$red);
} }
} }
} }

View file

@ -17,6 +17,78 @@ label.icon {
@include font.icon; @include font.icon;
} }
.notification {
.inner {
@include lib.rounded(8);
@include font.main;
background-color: scheme.$base;
color: scheme.$text;
padding: lib.s(10) lib.s(12);
@include lib.spacing($vertical: true);
}
.header,
.content {
padding: 0 lib.s(5);
}
.header {
@include font.mono;
@include lib.spacing(8);
}
.content {
@include lib.spacing(10);
}
.app-icon {
font-size: lib.s(18);
}
.image {
@include lib.rounded(10);
background-size: cover;
background-position: center;
min-width: lib.s(64);
min-height: lib.s(64);
margin-top: lib.s(3);
}
.summary {
@include font.title;
font-size: lib.s(16);
}
.body {
font-size: lib.s(14);
}
.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;
}
}
}
}
separator, separator,
.separator { .separator {
@include lib.rounded(2); @include lib.rounded(2);
@ -112,9 +184,6 @@ tooltip {
scrollbar { scrollbar {
trough { trough {
@include lib.rounded(1000);
min-width: lib.s(12);
background-color: transparent; background-color: transparent;
} }
@ -122,12 +191,13 @@ scrollbar {
@include lib.rounded(1000); @include lib.rounded(1000);
@include lib.element-decel; @include lib.element-decel;
min-width: lib.s(6); min-width: lib.s(3);
min-height: lib.s(30); min-height: lib.s(30);
background-color: color.change(scheme.$overlay0, $alpha: 0.3); background-color: color.change(scheme.$overlay0, $alpha: 0.3);
&:hover, &:hover,
&:focus { &:focus {
min-width: lib.s(6);
background-color: color.change(scheme.$overlay0, $alpha: 0.4); background-color: color.change(scheme.$overlay0, $alpha: 0.4);
} }

View file

@ -15,6 +15,7 @@ import { getAppCategoryIcon } from "../utils/icons";
import { ellipsize } from "../utils/strings"; import { ellipsize } from "../utils/strings";
import { osIcon } from "../utils/system"; import { osIcon } from "../utils/system";
import { setupCustomTooltip } from "../utils/widgets"; import { setupCustomTooltip } from "../utils/widgets";
import type PopupWindow from "../widgets/popupwindow";
const hyprland = AstalHyprland.get_default(); const hyprland = AstalHyprland.get_default();
@ -385,11 +386,19 @@ const PkgUpdates = () => (
</box> </box>
); );
const Notifications = () => { const Unread = () => {
const unreadCount = Variable(0); const unreadCount = Variable(0);
return ( return (
<box <button
className="module notifications" onClick={(self, event) => {
if (event.button === Astal.MouseButton.PRIMARY) {
const popup = App.get_window("notifications") as PopupWindow | null;
if (popup) {
if (popup.visible) popup.hide();
else popup.popup_at_widget(self, event);
}
} else if (event.button === Astal.MouseButton.SECONDARY) unreadCount.set(0);
}}
setup={self => setup={self =>
setupCustomTooltip( setupCustomTooltip(
self, self,
@ -397,39 +406,37 @@ const Notifications = () => {
) )
} }
> >
<label className="icon" label="info" /> <box className="module unread">
<label <label className="icon" label="info" />
label="0" <label
setup={self => { label={bind(unreadCount).as(String)}
const notifd = AstalNotifd.get_default(); setup={self => {
let notifsOpen = false; const notifd = AstalNotifd.get_default();
let unread = new Set<number>(); let notifsOpen = false;
let unread = new Set<number>();
self.hook(unreadCount, (_, u) => u === 0 && unread.clear());
self.hook(notifd, "notified", (self, id, replaced) => { self.hook(notifd, "notified", (_, id, replaced) => {
if (!notifsOpen && !replaced) { if (!notifsOpen && !replaced) {
unread.add(id); unread.add(id);
unreadCount.set(unread.size); 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);
} }
} });
}); self.hook(notifd, "resolved", (_, id) => {
}} if (unread.delete(id)) {
/> unreadCount.set(unread.size);
</box> }
});
self.hook(App, "window-toggled", (_, window) => {
if (window.name === "notifications") {
notifsOpen = window.visible;
if (notifsOpen) unreadCount.set(0);
}
});
}}
/>
</box>
</button>
); );
}; };
@ -491,7 +498,7 @@ export default ({ monitor }: { monitor: Monitor }) => (
<Tray /> <Tray />
<StatusIcons /> <StatusIcons />
<PkgUpdates /> <PkgUpdates />
<Notifications /> <Unread />
<DateTime /> <DateTime />
<Power /> <Power />
</box> </box>

View file

@ -1,6 +1,8 @@
import { Gtk } from "astal/gtk3"; import { bind } from "astal";
import { Astal, Gtk } from "astal/gtk3";
import AstalNotifd from "gi://AstalNotifd"; import AstalNotifd from "gi://AstalNotifd";
import { PopupWindow, setupChildClickthrough } from "../utils/widgets"; import Notification from "../widgets/notification";
import PopupWindow from "../widgets/popupwindow";
const List = () => ( const List = () => (
<box <box
@ -9,49 +11,63 @@ const List = () => (
className="list" className="list"
setup={self => { setup={self => {
const notifd = AstalNotifd.get_default(); const notifd = AstalNotifd.get_default();
const map = new Map<number, NotifPopup>(); const map = new Map<number, Notification>();
self.hook(notifd, "notified", (self, id) => {
const notification = notifd.get_notification(id);
const popup = (<NotifPopup notification={notification} />) as NotifPopup; const addNotification = (notification: AstalNotifd.Notification) => {
popup.connect("destroy", () => map.get(notification.id) === popup && map.delete(notification.id)); const notif = (<Notification notification={notification} />) as Notification;
notif.connect("destroy", () => map.get(notification.id) === notif && map.delete(notification.id));
map.get(notification.id)?.destroyWithAnims(); map.get(notification.id)?.destroyWithAnims();
map.set(notification.id, popup); map.set(notification.id, notif);
self.add( self.pack_end(
<eventbox <eventbox
// Dismiss on middle click // Dismiss on middle click
onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()} onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()}
// Close on hover lost
onHoverLost={() => popup.destroyWithAnims()}
> >
{popup} {notif}
</eventbox> </eventbox>,
false,
false,
0
); );
};
// Limit number of popups notifd
if (config.maxPopups > 0 && self.children.length > config.maxPopups) .get_notifications()
map.values().next().value?.destroyWithAnims(); .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()); self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims());
// Change input region to child region so can click through empty space
setupChildClickthrough(self);
}} }}
/> />
); );
export default class Notifications extends PopupWindow { export default () => (
constructor() { <PopupWindow name="notifications">
super({ <box vertical className="notifications">
name: "notifications", <box className="header">
child: ( <label
<box> label={bind(AstalNotifd.get_default(), "notifications").as(
<List /> n => `${n.length} notification${n.length === 1 ? "" : "s"}`
</box> )}
), />
}); <box hexpand />
<button
setupChildClickthrough(self); cursor="pointer"
} onClicked={() => (AstalNotifd.get_default().dontDisturb = !AstalNotifd.get_default().dontDisturb)}
} label="Silence"
className={bind(AstalNotifd.get_default(), "dontDisturb").as(d => (d ? "enabled" : ""))}
/>
<button
cursor="pointer"
onClicked={() => AstalNotifd.get_default().notifications.forEach(n => n.dismiss())}
label="Clear"
/>
</box>
<scrollable expand hscroll={Gtk.PolicyType.NEVER}>
<List />
</scrollable>
</box>
</PopupWindow>
);

View file

@ -1,4 +1,4 @@
import { Astal, Gtk } from "astal/gtk3"; import { App, Astal, Gtk } from "astal/gtk3";
import AstalNotifd from "gi://AstalNotifd"; import AstalNotifd from "gi://AstalNotifd";
import { notifpopups as config } from "../../config"; import { notifpopups as config } from "../../config";
import { setupChildClickthrough } from "../utils/widgets"; import { setupChildClickthrough } from "../utils/widgets";
@ -16,7 +16,11 @@ export default () => (
setup={self => { setup={self => {
const notifd = AstalNotifd.get_default(); const notifd = AstalNotifd.get_default();
const map = new Map<number, Notification>(); const map = new Map<number, Notification>();
let notifsOpen = false;
self.hook(notifd, "notified", (self, id) => { self.hook(notifd, "notified", (self, id) => {
if (notifsOpen) return;
const notification = notifd.get_notification(id); const notification = notifd.get_notification(id);
const popup = (<Notification popup notification={notification} />) as Notification; const popup = (<Notification popup notification={notification} />) as Notification;
@ -41,6 +45,10 @@ export default () => (
}); });
self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims()); self.hook(notifd, "resolved", (_, id) => map.get(id)?.destroyWithAnims());
self.hook(App, "window-toggled", (_, window) => {
if (window.name === "notifications") notifsOpen = window.visible;
});
// Change input region to child region so can click through empty space // Change input region to child region so can click through empty space
setupChildClickthrough(self); setupChildClickthrough(self);
}} }}

View file

@ -57,12 +57,16 @@ export default class Notification extends Widget.Box {
#destroyed = false; #destroyed = false;
constructor({ notification, popup }: { notification: AstalNotifd.Notification; popup?: boolean }) { constructor({ notification, popup }: { notification: AstalNotifd.Notification; popup?: boolean }) {
super(); super({ className: "notification" });
this.#revealer = ( this.#revealer = (
<revealer revealChild transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} transitionDuration={150}> <revealer
revealChild={popup}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
>
<box className="wrapper"> <box className="wrapper">
<box vertical className={`notification ${urgencyToString(notification.urgency)}`}> <box vertical className={`inner ${urgencyToString(notification.urgency)}`}>
<box className="header"> <box className="header">
<AppIcon appIcon={notification.appIcon} desktopEntry={notification.appName} /> <AppIcon appIcon={notification.appIcon} desktopEntry={notification.appName} />
<label className="app-name" label={notification.appName ?? "Unknown"} /> <label className="app-name" label={notification.appName ?? "Unknown"} />
@ -101,6 +105,7 @@ export default class Notification extends Widget.Box {
const width = this.get_preferred_width()[1]; const width = this.get_preferred_width()[1];
this.css = `margin-left: ${width}px; margin-right: -${width}px;`; this.css = `margin-left: ${width}px; margin-right: -${width}px;`;
timeout(1, () => { 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;`; this.css = `transition: 300ms cubic-bezier(0.05, 0.9, 0.1, 1.1); margin-left: 0; margin-right: 0;`;
}); });

View file

@ -28,10 +28,11 @@ export default class PopupWindow extends Widget.Window {
}); });
} }
popup_at_widget(widget: JSX.Element, event: Gdk.Event) { popup_at_widget(widget: JSX.Element, event: Gdk.Event | Astal.ClickEvent) {
const { width, height } = widget.get_allocation(); const { width, height } = widget.get_allocation();
const [_, x, y] = event.get_coords(); const [_, x, y] = event instanceof Gdk.Event ? event.get_coords() : [null, event.x, event.y];
const { x: cx, y: cy } = AstalHyprland.get_default().get_cursor_position(); const { x: cx, y: cy } = AstalHyprland.get_default().get_cursor_position();
this.anchor = Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT;
this.marginLeft = cx + ((width - this.get_preferred_width()[1]) / 2 - x); this.marginLeft = cx + ((width - this.get_preferred_width()[1]) / 2 - x);
this.marginTop = cy + (height - y); this.marginTop = cy + (height - y);
this.show(); this.show();

View file

@ -6,6 +6,7 @@
@use "scss/notifpopups"; @use "scss/notifpopups";
@use "scss/launcher"; @use "scss/launcher";
@use "scss/osds"; @use "scss/osds";
@use "scss/notifications";
* { * {
all: unset; // Remove GTK theme styles all: unset; // Remove GTK theme styles