Add AGS notification support

This commit is contained in:
Joshua Yuen 2026-09-12 13:47:23 -04:00
parent 6e3c39b00c
commit d341d83381
Signed by: josh
GPG Key ID: 530DA0331EFE99F5
6 changed files with 209 additions and 0 deletions

View File

@ -1,10 +1,12 @@
import { App } from "astal/gtk3" import { App } from "astal/gtk3"
import style from "./style.scss" import style from "./style.scss"
import Bar from "./widget/Bar" import Bar from "./widget/Bar"
import Notifications from "./widget/Notifications"
App.start({ App.start({
css: style, css: style,
main() { main() {
App.get_monitors().map(Bar) App.get_monitors().map(Bar)
Notifications(App.get_monitors()[0])
}, },
}) })

View File

@ -0,0 +1 @@
export const MAX_TIMEOUT = 10000

View File

@ -193,3 +193,66 @@
.power-row:hover { .power-row:hover {
background: transparentize($base05, 0.88); background: transparentize($base05, 0.88);
} }
.notifications {
.notif {
background: $base01;
border-radius: 10px;
padding: 8px 12px;
color: $base05;
&:hover {
background: transparentize($base05, 0.88);
}
&.critical {
border: 1px solid $base08;
}
}
.notif-header {
margin-bottom: 4px;
.notif-app-name {
font-weight: bold;
margin: 0 6px;
}
.notif-time {
font-size: 0.85em;
opacity: 0.7;
margin-right: 4px;
}
.notif-close {
border-radius: 6px;
padding: 0 2px;
}
}
.notif-content {
.notif-image {
margin-right: 8px;
}
.notif-summary {
font-weight: bold;
}
.notif-text {
font-size: 0.9em;
opacity: 0.85;
}
}
.notif-action {
background: $base02;
border-radius: 6px;
margin-top: 6px;
padding: 2px 8px;
&:hover {
background: $base03;
}
}
}

View File

@ -0,0 +1,128 @@
import GLib from "gi://GLib"
import { App, Astal, Gdk, Gtk } from "astal/gtk3"
import Notifd from "gi://AstalNotifd"
import { bind } from "astal"
import { MAX_TIMEOUT } from "../notif-config"
const notifd = Notifd.get_default()
notifd.default_timeout = MAX_TIMEOUT
// notifications restored from the daemon's cache on startup are stale
for (const n of notifd.notifications) {
n.dismiss()
}
// the daemon honors app-requested timeouts, so enforce our own cap for
// notifications that ask for more (or none at all). critical notifications
// are exempt and stay until dismissed.
notifd.connect("notified", (_, id: number) => {
const n = notifd.get_notification(id)
if (!n || n.urgency === Notifd.Urgency.CRITICAL) return
if (n.expire_timeout <= 0 || n.expire_timeout > MAX_TIMEOUT) {
GLib.timeout_add(GLib.PRIORITY_DEFAULT, MAX_TIMEOUT, () => {
if (notifd.notifications.includes(n)) n.dismiss()
return GLib.SOURCE_REMOVE
})
}
})
function formatTime(n: Notifd.Notification) {
return GLib.DateTime.new_from_unix_local(n.time).format("%H:%M")!
}
function NotificationCard(n: Notifd.Notification) {
const critical = n.urgency === Notifd.Urgency.CRITICAL
return (
<button
className={`notif${critical ? " critical" : ""}`}
onClicked={() => n.dismiss()}
>
<box vertical>
<box className="notif-header">
<icon
pixelSize={16}
icon={n.app_icon || "preferences-system-notifications-symbolic"}
/>
<label
className="notif-app-name"
label={n.app_name || "Notification"}
halign={Gtk.Align.START}
hexpand
/>
<label className="notif-time" label={formatTime(n)} />
<button
className="notif-close"
onClicked={() => n.dismiss()}
>
<icon icon="window-close-symbolic" pixelSize={12} />
</button>
</box>
<box className="notif-content">
{n.image !== "" && (
<icon className="notif-image" pixelSize={36} icon={n.image} />
)}
<box vertical hexpand>
<label
className="notif-summary"
label={n.summary}
halign={Gtk.Align.START}
visible={n.summary !== ""}
/>
<label
className="notif-text"
label={n.body}
wrap
halign={Gtk.Align.START}
visible={n.body !== ""}
/>
</box>
</box>
{n.actions.filter((a) => a.label).length > 0 && (
<box className="notif-actions" homogeneous spacing={4}>
{n.actions
.filter((a) => a.label)
.map((a) => (
<button
className="notif-action"
onClicked={() => a.invoke()}
>
<label label={a.label} />
</button>
))}
</box>
)}
</box>
</button>
)
}
export default function Notifications(gdkmonitor: Gdk.Monitor) {
const { TOP, RIGHT } = Astal.WindowAnchor
return (
<window
className="popup-window"
namespace="ags-notifications"
application={App}
gdkmonitor={gdkmonitor}
layer={Astal.Layer.OVERLAY}
anchor={TOP | RIGHT}
exclusivity={Astal.Exclusivity.IGNORE}
keymode={Astal.Keymode.NONE}
marginTop={48}
marginRight={8}
visible={bind(notifd, "notifications").as((ns) => ns.length > 0)}
>
<box className="notifications" vertical spacing={8} widthRequest={360}>
{bind(notifd, "notifications").as((ns) =>
[...ns]
.sort((a, b) => b.time - a.time)
.map((n) => NotificationCard(n)),
)}
</box>
</window>
)
}

View File

@ -39,6 +39,10 @@ let
) )
); );
notifConfig = pkgs.writeText "notif-config.ts" ''
export const MAX_TIMEOUT = ${toString cfg.timeout};
'';
astalDeps = astalDeps =
with pkgs.astal; with pkgs.astal;
[ [
@ -48,6 +52,7 @@ let
network network
battery battery
wireplumber wireplumber
notifd
] ]
++ [ pkgs.networkmanager ]; ++ [ pkgs.networkmanager ];
@ -59,6 +64,7 @@ let
dependencies = astalDeps; dependencies = astalDeps;
postUnpack = '' postUnpack = ''
cp ${palette} "$sourceRoot/palette.scss" cp ${palette} "$sourceRoot/palette.scss"
cp ${notifConfig} "$sourceRoot/notif-config.ts"
''; '';
}; };
in in

View File

@ -9,5 +9,14 @@ with lib;
default = false; default = false;
description = "Enable the AGS status bar"; description = "Enable the AGS status bar";
}; };
timeout = mkOption {
type = types.ints.positive;
default = 10000;
description = ''
Maximum duration notification popups stay on screen, in milliseconds.
Critical notifications are exempt and stay until dismissed.
'';
};
}; };
} }