diff --git a/nixos/configuration.nix b/nixos/configuration.nix
index 86ada91..9a489e9 100644
--- a/nixos/configuration.nix
+++ b/nixos/configuration.nix
@@ -68,6 +68,7 @@
gcc
astal.hyprland
btop
+ libnotify
];
environment.sessionVariables = {
diff --git a/packages/ags/custom/app.ts b/packages/ags/custom/app.ts
index 4b7ea48..ac17f43 100644
--- a/packages/ags/custom/app.ts
+++ b/packages/ags/custom/app.ts
@@ -1,6 +1,7 @@
import { App } from "astal/gtk3"
import style from "./style.scss"
import Bar from "./widget/Bar"
+// import NotificationPopups from "./notification/NotificationPopups"
App.start({
css: style,
diff --git a/packages/ags/custom/notification/Notification.scss b/packages/ags/custom/notification/Notification.scss
new file mode 100644
index 0000000..a32f08b
--- /dev/null
+++ b/packages/ags/custom/notification/Notification.scss
@@ -0,0 +1,125 @@
+@use "sass:string";
+
+@function gtkalpha($c, $a) {
+ @return string.unquote("alpha(#{$c},#{$a})");
+}
+
+// https://gitlab.gnome.org/GNOME/gtk/-/blob/gtk-3-24/gtk/theme/Adwaita/_colors-public.scss
+$fg-color: #{"@theme_fg_color"};
+$bg-color: #{"@theme_bg_color"};
+$error: red;
+
+window.NotificationPopups {
+ all: unset;
+}
+
+eventbox.Notification {
+
+ &:first-child>box {
+ margin-top: 1rem;
+ }
+
+ &:last-child>box {
+ margin-bottom: 1rem;
+ }
+
+ // eventboxes can not take margins so we style its inner box instead
+ >box {
+ min-width: 400px;
+ border-radius: 13px;
+ background-color: $bg-color;
+ margin: .5rem 1rem .5rem 1rem;
+ box-shadow: 2px 3px 8px 0 gtkalpha(black, .4);
+ border: 1pt solid gtkalpha($fg-color, .03);
+ }
+
+ &.critical>box {
+ border: 1pt solid gtkalpha($error, .4);
+
+ .header {
+
+ .app-name {
+ color: gtkalpha($error, .8);
+
+ }
+
+ .app-icon {
+ color: gtkalpha($error, .6);
+ }
+ }
+ }
+
+ .header {
+ padding: .5rem;
+ color: gtkalpha($fg-color, 0.5);
+
+ .app-icon {
+ margin: 0 .4rem;
+ }
+
+ .app-name {
+ margin-right: .3rem;
+ font-weight: bold;
+
+ &:first-child {
+ margin-left: .4rem;
+ }
+ }
+
+ .time {
+ margin: 0 .4rem;
+ }
+
+ button {
+ padding: .2rem;
+ min-width: 0;
+ min-height: 0;
+ }
+ }
+
+ separator {
+ margin: 0 .4rem;
+ background-color: gtkalpha($fg-color, .1);
+ }
+
+ .content {
+ margin: 1rem;
+ margin-top: .5rem;
+
+ .summary {
+ font-size: 1.2em;
+ color: $fg-color;
+ }
+
+ .body {
+ color: gtkalpha($fg-color, 0.8);
+ }
+
+ .image {
+ border: 1px solid gtkalpha($fg-color, .02);
+ margin-right: .5rem;
+ border-radius: 9px;
+ min-width: 100px;
+ min-height: 100px;
+ background-size: cover;
+ background-position: center;
+ }
+ }
+
+ .actions {
+ margin: 1rem;
+ margin-top: 0;
+
+ button {
+ margin: 0 .3rem;
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+}
diff --git a/packages/ags/custom/notification/Notification.tsx b/packages/ags/custom/notification/Notification.tsx
new file mode 100644
index 0000000..5149d5b
--- /dev/null
+++ b/packages/ags/custom/notification/Notification.tsx
@@ -0,0 +1,107 @@
+import { GLib } from "astal"
+import { Gtk, Astal } from "astal/gtk3"
+import { type EventBox } from "astal/gtk3/widget"
+import Notifd from "gi://AstalNotifd"
+
+const isIcon = (icon: string) =>
+ !!Astal.Icon.lookup_icon(icon)
+
+const fileExists = (path: string) =>
+ GLib.file_test(path, GLib.FileTest.EXISTS)
+
+const time = (time: number, format = "%H:%M") => GLib.DateTime
+ .new_from_unix_local(time)
+ .format(format)!
+
+const urgency = (n: Notifd.Notification) => {
+ const { LOW, NORMAL, CRITICAL } = Notifd.Urgency
+ // match operator when?
+ switch (n.urgency) {
+ case LOW: return "low"
+ case CRITICAL: return "critical"
+ case NORMAL:
+ default: return "normal"
+ }
+}
+
+type Props = {
+ setup(self: EventBox): void
+ onHoverLost(self: EventBox): void
+ notification: Notifd.Notification
+}
+
+export default function Notification(props: Props) {
+ const { notification: n, onHoverLost, setup } = props
+ const { START, CENTER, END } = Gtk.Align
+
+ return
+
+
+ {(n.appIcon || n.desktopEntry) && }
+
+
+
+
+
+
+ {n.image && fileExists(n.image) && }
+ {n.image && isIcon(n.image) &&
+
+ }
+
+
+ {n.body && }
+
+
+ {n.get_actions().length > 0 &&
+ {n.get_actions().map(({ label, id }) => (
+
+ ))}
+ }
+
+
+}
diff --git a/packages/ags/custom/notification/NotificationPopups.tsx b/packages/ags/custom/notification/NotificationPopups.tsx
new file mode 100644
index 0000000..13fdd88
--- /dev/null
+++ b/packages/ags/custom/notification/NotificationPopups.tsx
@@ -0,0 +1,105 @@
+import { Astal, Gtk, Gdk } from "astal/gtk3"
+import Notifd from "gi://AstalNotifd"
+import Notification from "./Notification"
+import { type Subscribable } from "astal/binding"
+import { Variable, bind, timeout } from "astal"
+
+// see comment below in constructor
+const TIMEOUT_DELAY = 5000
+
+// The purpose if this class is to replace Variable>
+// with a Map type in order to track notification widgets
+// by their id, while making it conviniently bindable as an array
+class NotifiationMap implements Subscribable {
+ // the underlying map to keep track of id widget pairs
+ private map: Map = new Map()
+
+ // it makes sense to use a Variable under the hood and use its
+ // reactivity implementation instead of keeping track of subscribers ourselves
+ private var: Variable> = Variable([])
+
+ // notify subscribers to rerender when state changes
+ private notifiy() {
+ this.var.set([...this.map.values()].reverse())
+ }
+
+ constructor() {
+ const notifd = Notifd.get_default()
+
+ /**
+ * uncomment this if you want to
+ * ignore timeout by senders and enforce our own timeout
+ * note that if the notification has any actions
+ * they might not work, since the sender already treats them as resolved
+ */
+ // notifd.ignoreTimeout = true
+
+ notifd.connect("notified", (_, id) => {
+ this.set(id, Notification({
+ notification: notifd.get_notification(id)!,
+
+ // once hovering over the notification is done
+ // destroy the widget without calling notification.dismiss()
+ // so that it acts as a "popup" and we can still display it
+ // in a notification center like widget
+ // but clicking on the close button will close it
+ onHoverLost: () => this.delete(id),
+
+ // notifd by default does not close notifications
+ // until user input or the timeout specified by sender
+ // which we set to ignore above
+ setup: () => timeout(TIMEOUT_DELAY, () => {
+ /**
+ * uncomment this if you want to "hide" the notifications
+ * after TIMEOUT_DELAY
+ */
+ // this.delete(id)
+ })
+ }))
+ })
+
+ // notifications can be closed by the outside before
+ // any user input, which have to be handled too
+ notifd.connect("resolved", (_, id) => {
+ this.delete(id)
+ })
+ }
+
+ private set(key: number, value: Gtk.Widget) {
+ // in case of replacecment destroy previous widget
+ this.map.get(key)?.destroy()
+ this.map.set(key, value)
+ this.notifiy()
+ }
+
+ private delete(key: number) {
+ this.map.get(key)?.destroy()
+ this.map.delete(key)
+ this.notifiy()
+ }
+
+ // needed by the Subscribable interface
+ get() {
+ return this.var.get()
+ }
+
+ // needed by the Subscribable interface
+ subscribe(callback: (list: Array) => void) {
+ return this.var.subscribe(callback)
+ }
+}
+
+export default function NotificationPopups(gdkmonitor: Gdk.Monitor) {
+ const { TOP, RIGHT } = Astal.WindowAnchor
+ const notifs = new NotifiationMap()
+
+ return
+
+ {bind(notifs)}
+
+
+}
diff --git a/packages/ags/custom/style.scss b/packages/ags/custom/style.scss
index f5f771a..5c20382 100644
--- a/packages/ags/custom/style.scss
+++ b/packages/ags/custom/style.scss
@@ -104,3 +104,4 @@ window.Bar {
}
}
}
+
diff --git a/packages/ags/custom/widget/Bar.tsx b/packages/ags/custom/widget/Bar.tsx
index 8310495..59e68f9 100644
--- a/packages/ags/custom/widget/Bar.tsx
+++ b/packages/ags/custom/widget/Bar.tsx
@@ -122,7 +122,7 @@ function FocusedClient() {
}
-function Time({ format = "%H:%M - %A %e." }) {
+function Time({ format = "%H:%M - %A %e" }) {
const time = Variable("").poll(1000, () =>
GLib.DateTime.new_now_local().format(format)!)
diff --git a/packages/ags/default.nix b/packages/ags/default.nix
index 06336ff..180dc6a 100644
--- a/packages/ags/default.nix
+++ b/packages/ags/default.nix
@@ -24,6 +24,7 @@
inputs.ags.packages.${pkgs.system}.io
inputs.ags.packages.${pkgs.system}.network
inputs.ags.packages.${pkgs.system}.tray
+ inputs.ags.packages.${pkgs.system}.notifd
fzf
];
};