diff --git a/example-stylix-dev.yaml b/example-stylix-dev.yaml index 46d44e6..a8c1ff2 100644 --- a/example-stylix-dev.yaml +++ b/example-stylix-dev.yaml @@ -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: diff --git a/flake.nix b/flake.nix index 8559d54..64aa7c4 100644 --- a/flake.nix +++ b/flake.nix @@ -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; + }; }; }; }; diff --git a/sims/config.py b/sims/config.py index dd70487..286cf03 100644 --- a/sims/config.py +++ b/sims/config.py @@ -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) diff --git a/sims/main.py b/sims/main.py index 3b1c5d6..1a9694c 100644 --- a/sims/main.py +++ b/sims/main.py @@ -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: diff --git a/sims/modules/bar.py b/sims/modules/bar.py index abdeb85..e584dcf 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -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) diff --git a/sims/modules/notification_center.py b/sims/modules/notification_center.py new file mode 100644 index 0000000..224592f --- /dev/null +++ b/sims/modules/notification_center.py @@ -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() diff --git a/sims/modules/notifications.py b/sims/modules/notifications.py new file mode 100644 index 0000000..3041082 --- /dev/null +++ b/sims/modules/notifications.py @@ -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, + ) + ) diff --git a/sims/services/notification_history.py b/sims/services/notification_history.py new file mode 100644 index 0000000..bbcb934 --- /dev/null +++ b/sims/services/notification_history.py @@ -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() diff --git a/sims/styles/main.css b/sims/styles/main.css index 83ae04d..2a6593e 100644 --- a/sims/styles/main.css +++ b/sims/styles/main.css @@ -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. */ diff --git a/sims/styles/notifications.css b/sims/styles/notifications.css new file mode 100644 index 0000000..9dcc28a --- /dev/null +++ b/sims/styles/notifications.css @@ -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; +} diff --git a/sims/widgets/notification.py b/sims/widgets/notification.py new file mode 100644 index 0000000..be2b90e --- /dev/null +++ b/sims/widgets/notification.py @@ -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, + ) diff --git a/sims/widgets/notification_bell.py b/sims/widgets/notification_bell.py new file mode 100644 index 0000000..1b78fce --- /dev/null +++ b/sims/widgets/notification_bell.py @@ -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) diff --git a/sims/widgets/notification_history_entry.py b/sims/widgets/notification_history_entry.py new file mode 100644 index 0000000..7ce475a --- /dev/null +++ b/sims/widgets/notification_history_entry.py @@ -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, + )