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

View file

@ -15,7 +15,7 @@ $scale: 0.068rem;
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);
}

View file

@ -92,7 +92,7 @@
color: scheme.$blue;
}
.notifications {
.unread {
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);
padding-left: lib.s(10); // So notifications can overshoot for init animation
.wrapper {
padding-top: lib.s(10);
}
.notification {
@include lib.rounded(8, $tr: 0, $br: 0);
@include lib.shadow;
@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);
.wrapper {
padding-top: lib.s(10);
}
&.critical {
@include popup(scheme.$red);
}
}
.inner {
@include lib.rounded(8, $tr: 0, $br: 0);
@include lib.shadow;
@include popup(scheme.$lavender);
.header,
.content {
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;
&.low {
@include popup(scheme.$overlay0);
}
&:active {
background-color: scheme.$surface2;
&.critical {
@include popup(scheme.$red);
}
}
}

View file

@ -17,6 +17,78 @@ label.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 {
@include lib.rounded(2);
@ -112,9 +184,6 @@ tooltip {
scrollbar {
trough {
@include lib.rounded(1000);
min-width: lib.s(12);
background-color: transparent;
}
@ -122,12 +191,13 @@ scrollbar {
@include lib.rounded(1000);
@include lib.element-decel;
min-width: lib.s(6);
min-width: lib.s(3);
min-height: lib.s(30);
background-color: color.change(scheme.$overlay0, $alpha: 0.3);
&:hover,
&:focus {
min-width: lib.s(6);
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 { osIcon } from "../utils/system";
import { setupCustomTooltip } from "../utils/widgets";
import type PopupWindow from "../widgets/popupwindow";
const hyprland = AstalHyprland.get_default();
@ -385,11 +386,19 @@ const PkgUpdates = () => (
</box>
);
const Notifications = () => {
const Unread = () => {
const unreadCount = Variable(0);
return (
<box
className="module notifications"
<button
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 =>
setupCustomTooltip(
self,
@ -397,39 +406,37 @@ const Notifications = () => {
)
}
>
<label className="icon" label="info" />
<label
label="0"
setup={self => {
const notifd = AstalNotifd.get_default();
let notifsOpen = false;
let unread = new Set<number>();
<box className="module unread">
<label className="icon" label="info" />
<label
label={bind(unreadCount).as(String)}
setup={self => {
const notifd = AstalNotifd.get_default();
let notifsOpen = false;
let unread = new Set<number>();
self.hook(unreadCount, (_, u) => u === 0 && unread.clear());
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);
self.hook(notifd, "notified", (_, id, replaced) => {
if (!notifsOpen && !replaced) {
unread.add(id);
unreadCount.set(unread.size);
}
}
});
}}
/>
</box>
});
self.hook(notifd, "resolved", (_, id) => {
if (unread.delete(id)) {
unreadCount.set(unread.size);
}
});
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 />
<StatusIcons />
<PkgUpdates />
<Notifications />
<Unread />
<DateTime />
<Power />
</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 { PopupWindow, setupChildClickthrough } from "../utils/widgets";
import Notification from "../widgets/notification";
import PopupWindow from "../widgets/popupwindow";
const List = () => (
<box
@ -9,49 +11,63 @@ const List = () => (
className="list"
setup={self => {
const notifd = AstalNotifd.get_default();
const map = new Map<number, NotifPopup>();
self.hook(notifd, "notified", (self, id) => {
const notification = notifd.get_notification(id);
const map = new Map<number, Notification>();
const popup = (<NotifPopup notification={notification} />) as NotifPopup;
popup.connect("destroy", () => map.get(notification.id) === popup && map.delete(notification.id));
const addNotification = (notification: AstalNotifd.Notification) => {
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.set(notification.id, popup);
map.set(notification.id, notif);
self.add(
self.pack_end(
<eventbox
// Dismiss on middle click
onClick={(_, event) => event.button === Astal.MouseButton.MIDDLE && notification.dismiss()}
// Close on hover lost
onHoverLost={() => popup.destroyWithAnims()}
>
{popup}
</eventbox>
{notif}
</eventbox>,
false,
false,
0
);
};
// Limit number of popups
if (config.maxPopups > 0 && self.children.length > config.maxPopups)
map.values().next().value?.destroyWithAnims();
});
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());
// Change input region to child region so can click through empty space
setupChildClickthrough(self);
}}
/>
);
export default class Notifications extends PopupWindow {
constructor() {
super({
name: "notifications",
child: (
<box>
<List />
</box>
),
});
setupChildClickthrough(self);
}
}
export default () => (
<PopupWindow name="notifications">
<box vertical className="notifications">
<box className="header">
<label
label={bind(AstalNotifd.get_default(), "notifications").as(
n => `${n.length} notification${n.length === 1 ? "" : "s"}`
)}
/>
<box hexpand />
<button
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 { notifpopups as config } from "../../config";
import { setupChildClickthrough } from "../utils/widgets";
@ -16,7 +16,11 @@ export default () => (
setup={self => {
const notifd = AstalNotifd.get_default();
const map = new Map<number, Notification>();
let notifsOpen = false;
self.hook(notifd, "notified", (self, id) => {
if (notifsOpen) return;
const notification = notifd.get_notification(id);
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(App, "window-toggled", (_, window) => {
if (window.name === "notifications") notifsOpen = window.visible;
});
// Change input region to child region so can click through empty space
setupChildClickthrough(self);
}}

View file

@ -57,12 +57,16 @@ export default class Notification extends Widget.Box {
#destroyed = false;
constructor({ notification, popup }: { notification: AstalNotifd.Notification; popup?: boolean }) {
super();
super({ className: "notification" });
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 vertical className={`notification ${urgencyToString(notification.urgency)}`}>
<box vertical className={`inner ${urgencyToString(notification.urgency)}`}>
<box className="header">
<AppIcon appIcon={notification.appIcon} desktopEntry={notification.appName} />
<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];
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;`;
});

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 [_, 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();
this.anchor = Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT;
this.marginLeft = cx + ((width - this.get_preferred_width()[1]) / 2 - x);
this.marginTop = cy + (height - y);
this.show();

View file

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