feat: sims notifications

This commit is contained in:
2026-05-04 21:31:09 +02:00
parent 4c8b9020b0
commit 047a85925a
13 changed files with 845 additions and 1 deletions

View File

@@ -16,6 +16,15 @@ notmuch:
screenrec:
enable: true
output_dir: "~/Videos/wl-screenrec"
notifications:
enable: true
anchor: "top center"
margin: "8px"
width: 360
timeout_ms: 10000
history_size: 50
image_max_px: 128
center_width: 380
stylix:
enable: true
colors:

View File

@@ -151,6 +151,48 @@
description = "argv for the Lock action in the power menu";
};
};
notifications = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the notification toast service. Owns org.freedesktop.Notifications, so other notification daemons (mako, dunst, swaync) must be disabled.";
};
anchor = lib.mkOption {
type = lib.types.str;
default = "top center";
description = "Layer-shell anchor for the toast stack";
};
margin = lib.mkOption {
type = lib.types.str;
default = "8px";
description = "Layer-shell margin for the toast stack";
};
width = lib.mkOption {
type = lib.types.int;
default = 360;
description = "Width of each notification toast in pixels";
};
timeout_ms = lib.mkOption {
type = lib.types.int;
default = 10000;
description = "Auto-close timeout for notifications in milliseconds";
};
history_size = lib.mkOption {
type = lib.types.int;
default = 50;
description = "How many past notifications the in-memory center keeps";
};
image_max_px = lib.mkOption {
type = lib.types.int;
default = 128;
description = "Max edge in pixels for stored notification thumbnails";
};
center_width = lib.mkOption {
type = lib.types.int;
default = 380;
description = "Width of the notification center side rail in pixels";
};
};
};
};
default = {
@@ -176,6 +218,16 @@
power = {
lock_command = [ "waylock" ];
};
notifications = {
enable = false;
anchor = "top center";
margin = "8px";
width = 360;
timeout_ms = 10000;
history_size = 50;
image_max_px = 128;
center_width = 380;
};
};
};
};

View File

@@ -62,6 +62,16 @@ SCREENREC = app_config.get("screenrec", {
POWER = app_config.get("power", {
"lock_command": ["waylock"],
})
NOTIFICATIONS = app_config.get("notifications", {
"enable": False,
"anchor": "top center",
"margin": "8px",
"width": 360,
"timeout_ms": 10_000,
"history_size": 50,
"image_max_px": 128,
"center_width": 380,
})
BAR_HEIGHT = app_config.get("height", 40)
LOG_LEVEL = app_config.get("logLevel", "WARNING")
DEV = app_config.get("dev", False)

View File

@@ -25,11 +25,16 @@ from .modules.launcher.clipboard import ClipboardMenu
from .modules.launcher.power import PowerMenu
from .modules.launcher.screenrec import ScreenrecMenu
from .modules.launcher.screenshot import ScreenshotMenu
from .modules.notification_center import NotificationCenter
from .modules.notifications import NotificationToasts
from .modules.stylix import get_stylix_css_path
from .config import POWER, SCREENREC, STYLIX
from .config import NOTIFICATIONS, POWER, SCREENREC, STYLIX
from .services.fenster import get_i3_connection
from .services.notification_history import NotificationHistoryService
from .services.screenrec import ScreenrecService
from fabric.notifications import Notifications
tray = SystemTray(name="system-tray", spacing=4)
get_i3_connection()
@@ -49,12 +54,41 @@ if SCREENREC.get("enable", False):
)
screenrec_menu = ScreenrecMenu(screenrec_service)
notifications_service: Notifications | None = None
notification_history: NotificationHistoryService | None = None
notification_toasts: NotificationToasts | None = None
notification_center: NotificationCenter | None = None
if NOTIFICATIONS.get("enable", False):
notifications_service = Notifications()
notification_history = NotificationHistoryService(
notifications_service,
history_size=NOTIFICATIONS.get("history_size", 50),
image_max_px=NOTIFICATIONS.get("image_max_px", 128),
)
notification_toasts = NotificationToasts(
notifications_service,
monitor=0,
anchor=NOTIFICATIONS.get("anchor", "top center"),
margin=NOTIFICATIONS.get("margin", "8px"),
width=NOTIFICATIONS.get("width", 360),
timeout_ms=NOTIFICATIONS.get("timeout_ms", 10_000),
)
notification_center = NotificationCenter(
notification_history,
monitor=0,
width=NOTIFICATIONS.get("center_width", 380),
)
bar_windows = []
notmuch_widget = None
_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu]
if screenrec_menu is not None:
_app_windows.append(screenrec_menu)
if notification_toasts is not None:
_app_windows.append(notification_toasts)
if notification_center is not None:
_app_windows.append(notification_center)
app = Application("sims", *_app_windows)
@@ -113,6 +147,12 @@ def screenrec_stop():
screenrec_service.stop()
@Application.action()
def toggle_notification_center():
if notification_center is not None:
notification_center.toggle()
def _set_all_bars_rounded(rounded: bool):
for bar in bar_windows:
bar.set_corners_rounded(rounded)
@@ -170,6 +210,8 @@ def spawn_bars():
tray=tray if i == 0 else None,
monitor=i,
screenrec_service=screenrec_service if i == 0 else None,
notification_history=notification_history if i == 0 else None,
notification_center=notification_center if i == 0 else None,
)
bar_windows.append(bar)
if i == 0 and bar.notmuch:

View File

@@ -10,8 +10,11 @@ from sims.modules.vinyl import VinylButton
from sims.modules.quick_menu import QuickMenuOpener
from sims.modules.battery import Battery
from sims.modules.calendar import CalendarService, CalendarPopup
from sims.modules.notification_center import NotificationCenter
from sims.modules.notmuch import NotmuchWidget
from sims.modules.screenrec import ScreenrecWidget
from sims.services.notification_history import NotificationHistoryService
from sims.widgets.notification_bell import NotificationBell
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray
from sims.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow
@@ -31,6 +34,8 @@ class StatusBar(Window):
tray: SystemTray | None = None,
monitor: int = 1,
screenrec_service: ScreenrecService | None = None,
notification_history: NotificationHistoryService | None = None,
notification_center: NotificationCenter | None = None,
):
super().__init__(
name="sims",
@@ -112,6 +117,13 @@ class StatusBar(Window):
if screenrec_service is not None:
self.screenrec = ScreenrecWidget(screenrec_service)
self.notification_bell = None
if notification_history is not None and notification_center is not None:
self.notification_bell = NotificationBell(
notification_history,
on_clicked=notification_center.toggle,
)
self.status_container = Box(
name="widgets-container",
spacing=4,
@@ -134,6 +146,9 @@ class StatusBar(Window):
if self.screenrec:
end_container_children.append(self.screenrec)
if self.notification_bell:
end_container_children.append(self.notification_bell)
# Add quick menu button next to time
end_container_children.append(self.quick_menu)
end_container_children.append(self.date_time)

View File

@@ -0,0 +1,129 @@
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gdk
from sims.services.notification_history import NotificationHistoryService
from sims.widgets.notification_history_entry import NotificationHistoryEntryWidget
class NotificationCenter(Window):
def __init__(
self,
history: NotificationHistoryService,
monitor: int = 0,
width: int = 380,
):
super().__init__(
name="notification-center",
anchor="top right bottom",
monitor=monitor,
margin="0",
exclusivity="none",
keyboard_mode="on-demand",
visible=False,
)
self._history = history
self._width = width
self._empty_label = Label(
name="notification-center-empty",
label="No notifications",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self._list = Box(
name="notification-center-list",
spacing=6,
orientation="v",
h_expand=True,
)
clear_button = Button(
name="notification-center-clear",
label="Clear all",
on_clicked=lambda *_: self._history.clear(),
)
close_button = Button(
name="notification-center-close",
image=Image(icon_name="window-close-symbolic", icon_size=16),
on_clicked=lambda *_: self.hide(),
)
header = Box(
name="notification-center-header",
orientation="h",
spacing=8,
)
header.pack_start(
Label(name="notification-center-title", label="Notifications", h_align="start"),
True,
True,
0,
)
header.pack_end(close_button, False, False, 0)
header.pack_end(clear_button, False, False, 4)
scroll = ScrolledWindow(
name="notification-center-scroll",
h_scrollbar_policy="never",
v_scrollbar_policy="automatic",
child=self._list,
h_expand=True,
v_expand=True,
)
body = Box(
name="notification-center-body",
orientation="v",
spacing=8,
children=[header, scroll],
h_expand=True,
v_expand=True,
)
body.set_size_request(self._width, -1)
self.add(body)
self.connect("key-press-event", self._on_key_press)
self._history.connect("changed", lambda *_: self._refresh())
self._refresh()
def toggle(self) -> None:
if self.get_visible():
self.hide()
else:
self.show()
def show(self) -> None: # type: ignore[override]
self._history.mark_all_seen()
super().show()
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.hide()
return True
return False
def _refresh(self) -> None:
for child in self._list.get_children():
self._list.remove(child)
if child is not self._empty_label:
child.destroy()
entries = self._history.entries
if not entries:
self._list.add(self._empty_label)
self._empty_label.show_all()
return
for entry in entries:
self._list.add(
NotificationHistoryEntryWidget(
entry, on_dismiss=self._history.remove
)
)
self._list.show_all()

View File

@@ -0,0 +1,54 @@
from typing import cast
from fabric.notifications import Notification, Notifications
from fabric.widgets.box import Box
from fabric.widgets.wayland import WaylandWindow as Window
from loguru import logger
from sims.widgets.notification import NotificationWidget
class NotificationToasts(Window):
def __init__(
self,
service: Notifications,
monitor: int = 0,
anchor: str = "top center",
margin: str = "8px",
width: int = 360,
timeout_ms: int = 10_000,
):
super().__init__(
name="notification-toasts",
anchor=anchor,
monitor=monitor,
margin=margin,
exclusivity="none",
visible=True,
all_visible=True,
)
self._width = width
self._timeout_ms = timeout_ms
self._stack = Box(
size=2, # so the compositor doesn't optimize the empty surface away
spacing=4,
orientation="v",
)
self.add(self._stack)
self._service = service
self._service.connect("notification-added", self._on_added)
def _on_added(self, service: Notifications, nid: int):
notification = cast(Notification, service.get_notification_from_id(nid))
if notification is None:
logger.warning(f"[Notifications] no notification for id {nid}")
return
self._stack.add(
NotificationWidget(
notification,
width=self._width,
timeout_ms=self._timeout_ms,
)
)

View File

@@ -0,0 +1,106 @@
"""In-memory notification history.
Subscribes to a fabric Notifications service and snapshots each incoming
notification before fabric drops it on close. Snapshots scale image pixbufs
down to a bounded edge so a flood of high-res previews can't balloon memory.
"""
import time
from collections import deque
from dataclasses import dataclass
from fabric.core.service import Service, Signal
from fabric.notifications import Notification, Notifications
from gi.repository import GdkPixbuf
@dataclass
class HistoryEntry:
id: int
summary: str
body: str
urgency: int
timestamp: float
pixbuf: GdkPixbuf.Pixbuf | None
def _scale_pixbuf(
pixbuf: GdkPixbuf.Pixbuf | None, max_edge: int
) -> GdkPixbuf.Pixbuf | None:
if pixbuf is None:
return None
w, h = pixbuf.get_width(), pixbuf.get_height()
if w <= max_edge and h <= max_edge:
return pixbuf
scale = max_edge / max(w, h)
return pixbuf.scale_simple(
max(1, int(w * scale)),
max(1, int(h * scale)),
GdkPixbuf.InterpType.BILINEAR,
)
class NotificationHistoryService(Service):
@Signal
def changed(self) -> None: ...
def __init__(
self,
notifications: Notifications,
history_size: int = 50,
image_max_px: int = 128,
):
super().__init__()
self._entries: deque[HistoryEntry] = deque(maxlen=history_size)
self._image_max_px = image_max_px
self._unseen_count = 0
notifications.connect("notification-added", self._on_added)
def _on_added(self, service: Notifications, nid: int) -> None:
notification: Notification | None = service.get_notification_from_id(nid)
if notification is None:
return
self._entries.appendleft(
HistoryEntry(
id=nid,
summary=notification.summary or "",
body=notification.body or "",
urgency=notification.urgency,
timestamp=time.time(),
pixbuf=_scale_pixbuf(
notification.image_pixbuf, self._image_max_px
),
)
)
self._unseen_count += 1
self.changed()
@property
def entries(self) -> list[HistoryEntry]:
return list(self._entries)
@property
def unseen_count(self) -> int:
return self._unseen_count
def mark_all_seen(self) -> None:
if self._unseen_count == 0:
return
self._unseen_count = 0
self.changed()
def remove(self, entry_id: int) -> None:
before = len(self._entries)
self._entries = deque(
(e for e in self._entries if e.id != entry_id),
maxlen=self._entries.maxlen,
)
if len(self._entries) != before:
self.changed()
def clear(self) -> None:
if not self._entries:
return
self._entries.clear()
self._unseen_count = 0
self.changed()

View File

@@ -6,6 +6,7 @@
@import url("./launcher.css");
@import url("./calendar.css");
@import url("./notmuch.css");
@import url("./notifications.css");
/* unset so we can style everything from the ground up. */

View File

@@ -0,0 +1,144 @@
#notification {
padding: 0.8rem;
border: solid 1px var(--border-color);
border-radius: 1rem;
background-color: var(--mid-bg);
}
#notification .summary {
font-size: 18px;
font-weight: bold;
}
#notification .body {
color: var(--mid-fg);
font-weight: normal;
}
#notification button {
padding: 0.4rem 0.8rem;
font-weight: 600;
border-radius: 0.6rem;
background-color: var(--light-bg);
}
#notification button:hover {
background-color: var(--dark-grey);
}
#notification.urgency-low {
border-color: var(--dark-grey);
}
#notification.urgency-low .summary {
color: var(--mid-fg);
}
#notification.urgency-critical {
border-color: var(--red);
border-width: 2px;
}
#notification.urgency-critical .summary {
color: var(--red);
}
/* Notification center side rail */
#notification-center {
background-color: transparent;
}
#notification-center-body {
background-color: var(--mid-bg);
border-left: solid 1px var(--border-color);
padding: 12px;
}
#notification-center-header {
padding: 0 0 8px 0;
border-bottom: solid 1px var(--border-color);
}
#notification-center-title {
font-size: 18px;
font-weight: bold;
}
#notification-center-clear,
#notification-center-close {
padding: 4px 10px;
border-radius: 0.5rem;
background-color: var(--light-bg);
}
#notification-center-clear:hover,
#notification-center-close:hover {
background-color: var(--dark-grey);
}
#notification-center-empty {
color: var(--light-grey);
padding: 24px;
}
#notification-history-entry {
padding: 10px;
border: solid 1px var(--border-color);
border-radius: 0.75rem;
background-color: var(--light-bg);
}
#notification-history-entry .summary {
font-weight: bold;
}
#notification-history-entry .body {
color: var(--mid-fg);
}
#notification-history-entry .timestamp {
color: var(--light-grey);
font-size: 12px;
padding-left: 8px;
}
#notification-history-entry.urgency-critical {
border-color: var(--red);
}
#notification-history-entry.urgency-critical .summary {
color: var(--red);
}
#notification-history-dismiss {
padding: 2px;
border-radius: 0.5rem;
}
#notification-history-dismiss:hover {
background-color: var(--dark-grey);
}
/* Bar bell */
#notification-bell {
padding: 4px;
border-radius: 0.5rem;
}
#notification-bell:hover {
background-color: var(--light-bg);
}
#notification-bell-badge {
background-color: var(--red);
color: var(--foreground);
font-size: 10px;
font-weight: bold;
padding: 0 4px;
border-radius: 8px;
min-width: 14px;
min-height: 14px;
margin: -4px -4px 0 0;
}

View File

@@ -0,0 +1,132 @@
from fabric.notifications import Notification
from fabric.utils import invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from gi.repository import GdkPixbuf
NOTIFICATION_IMAGE_SIZE = 64
class NotificationWidget(Box):
def __init__(
self,
notification: Notification,
width: int = 360,
timeout_ms: int = 10_000,
**kwargs,
):
super().__init__(
size=(width, -1),
name="notification",
spacing=8,
orientation="v",
**kwargs,
)
self._notification = notification
urgency_class = {0: "urgency-low", 1: "urgency-normal", 2: "urgency-critical"}.get(
notification.urgency, "urgency-normal"
)
self.get_style_context().add_class(urgency_class)
body_container = Box(spacing=4, orientation="h")
if image_pixbuf := self._notification.image_pixbuf:
body_container.add(
Image(
pixbuf=image_pixbuf.scale_simple(
NOTIFICATION_IMAGE_SIZE,
NOTIFICATION_IMAGE_SIZE,
GdkPixbuf.InterpType.BILINEAR,
)
)
)
text_children = []
summary = self._notification.summary or ""
body = self._notification.body or ""
text_children.append(
Box(
orientation="h",
children=[
Label(label=summary, ellipsization="middle")
.build()
.add_style_class("summary")
.unwrap(),
],
h_expand=True,
v_expand=True,
).build(
lambda box, _: box.pack_end(
Button(
image=Image(icon_name="window-close-symbolic", icon_size=18),
v_align="center",
h_align="end",
on_clicked=lambda *_: self._notification.close(),
),
False,
False,
0,
)
)
)
if body:
text_children.append(
Label(
label=body,
line_wrap="word-char",
v_align="start",
h_align="start",
)
.build()
.add_style_class("body")
.unwrap()
)
body_container.add(
Box(
spacing=4,
orientation="v",
children=text_children,
h_expand=True,
v_expand=True,
)
)
self.add(body_container)
if actions := self._notification.actions:
self.add(
Box(
spacing=4,
orientation="h",
children=[
Button(
h_expand=True,
v_expand=True,
label=action.label,
on_clicked=lambda *_, action=action: action.invoke(),
)
for action in actions
],
)
)
self._notification.connect(
"closed",
lambda *_: (
parent.remove(self) if (parent := self.get_parent()) else None, # type: ignore
self.destroy(),
),
)
invoke_repeater(
timeout_ms,
lambda: self._notification.close("expired"),
initial_call=False,
)

View File

@@ -0,0 +1,50 @@
from typing import Callable
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from sims.services.notification_history import NotificationHistoryService
class NotificationBell(Button):
def __init__(
self,
history: NotificationHistoryService,
on_clicked: Callable[[], None],
**kwargs,
):
self._badge = Label(
name="notification-bell-badge",
label="",
visible=False,
v_align="start",
h_align="end",
)
self._badge.set_no_show_all(True)
super().__init__(
name="notification-bell",
child=Overlay(
child=Image(
icon_name="preferences-system-notifications-symbolic",
icon_size=18,
),
overlays=[self._badge],
),
on_clicked=lambda *_: on_clicked(),
**kwargs,
)
self._history = history
self._history.connect("changed", lambda *_: self._refresh())
self._refresh()
def _refresh(self) -> None:
count = self._history.unseen_count
if count <= 0:
self._badge.set_visible(False)
return
self._badge.set_label(str(count) if count < 10 else "9+")
self._badge.set_visible(True)

View File

@@ -0,0 +1,100 @@
import time
from typing import Callable
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from sims.services.notification_history import HistoryEntry
def _time_ago(ts: float, now: float | None = None) -> str:
delta = int((now if now is not None else time.time()) - ts)
if delta < 60:
return "just now"
if delta < 3600:
return f"{delta // 60}m ago"
if delta < 86400:
return f"{delta // 3600}h ago"
return f"{delta // 86400}d ago"
class NotificationHistoryEntryWidget(Box):
THUMB_SIZE = 40
def __init__(
self,
entry: HistoryEntry,
on_dismiss: Callable[[int], None],
**kwargs,
):
super().__init__(
name="notification-history-entry",
spacing=8,
orientation="h",
**kwargs,
)
urgency_class = {0: "urgency-low", 1: "urgency-normal", 2: "urgency-critical"}.get(
entry.urgency, "urgency-normal"
)
self.get_style_context().add_class(urgency_class)
if entry.pixbuf is not None:
self.add(Image(pixbuf=entry.pixbuf, h_align="start", v_align="start"))
text_children: list = []
header = Box(orientation="h", h_expand=True)
header.pack_start(
Label(
label=entry.summary,
ellipsization="end",
h_align="start",
)
.build()
.add_style_class("summary")
.unwrap(),
True,
True,
0,
)
header.pack_end(
Label(label=_time_ago(entry.timestamp), h_align="end")
.build()
.add_style_class("timestamp")
.unwrap(),
False,
False,
0,
)
text_children.append(header)
if entry.body:
text_children.append(
Label(
label=entry.body,
line_wrap="word-char",
h_align="start",
v_align="start",
)
.build()
.add_style_class("body")
.unwrap()
)
text_box = Box(orientation="v", spacing=2, children=text_children, h_expand=True)
self.add(text_box)
self.pack_end(
Button(
name="notification-history-dismiss",
image=Image(icon_name="window-close-symbolic", icon_size=14),
v_align="start",
h_align="end",
on_clicked=lambda *_: on_dismiss(entry.id),
),
False,
False,
0,
)