feat: sims notifications
This commit is contained in:
@@ -16,6 +16,15 @@ notmuch:
|
|||||||
screenrec:
|
screenrec:
|
||||||
enable: true
|
enable: true
|
||||||
output_dir: "~/Videos/wl-screenrec"
|
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:
|
stylix:
|
||||||
enable: true
|
enable: true
|
||||||
colors:
|
colors:
|
||||||
|
|||||||
52
flake.nix
52
flake.nix
@@ -151,6 +151,48 @@
|
|||||||
description = "argv for the Lock action in the power menu";
|
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 = {
|
default = {
|
||||||
@@ -176,6 +218,16 @@
|
|||||||
power = {
|
power = {
|
||||||
lock_command = [ "waylock" ];
|
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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ SCREENREC = app_config.get("screenrec", {
|
|||||||
POWER = app_config.get("power", {
|
POWER = app_config.get("power", {
|
||||||
"lock_command": ["waylock"],
|
"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)
|
BAR_HEIGHT = app_config.get("height", 40)
|
||||||
LOG_LEVEL = app_config.get("logLevel", "WARNING")
|
LOG_LEVEL = app_config.get("logLevel", "WARNING")
|
||||||
DEV = app_config.get("dev", False)
|
DEV = app_config.get("dev", False)
|
||||||
|
|||||||
44
sims/main.py
44
sims/main.py
@@ -25,11 +25,16 @@ from .modules.launcher.clipboard import ClipboardMenu
|
|||||||
from .modules.launcher.power import PowerMenu
|
from .modules.launcher.power import PowerMenu
|
||||||
from .modules.launcher.screenrec import ScreenrecMenu
|
from .modules.launcher.screenrec import ScreenrecMenu
|
||||||
from .modules.launcher.screenshot import ScreenshotMenu
|
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 .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.fenster import get_i3_connection
|
||||||
|
from .services.notification_history import NotificationHistoryService
|
||||||
from .services.screenrec import ScreenrecService
|
from .services.screenrec import ScreenrecService
|
||||||
|
|
||||||
|
from fabric.notifications import Notifications
|
||||||
|
|
||||||
|
|
||||||
tray = SystemTray(name="system-tray", spacing=4)
|
tray = SystemTray(name="system-tray", spacing=4)
|
||||||
get_i3_connection()
|
get_i3_connection()
|
||||||
@@ -49,12 +54,41 @@ if SCREENREC.get("enable", False):
|
|||||||
)
|
)
|
||||||
screenrec_menu = ScreenrecMenu(screenrec_service)
|
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 = []
|
bar_windows = []
|
||||||
notmuch_widget = None
|
notmuch_widget = None
|
||||||
|
|
||||||
_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu]
|
_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu]
|
||||||
if screenrec_menu is not None:
|
if screenrec_menu is not None:
|
||||||
_app_windows.append(screenrec_menu)
|
_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)
|
app = Application("sims", *_app_windows)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +147,12 @@ def screenrec_stop():
|
|||||||
screenrec_service.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):
|
def _set_all_bars_rounded(rounded: bool):
|
||||||
for bar in bar_windows:
|
for bar in bar_windows:
|
||||||
bar.set_corners_rounded(rounded)
|
bar.set_corners_rounded(rounded)
|
||||||
@@ -170,6 +210,8 @@ def spawn_bars():
|
|||||||
tray=tray if i == 0 else None,
|
tray=tray if i == 0 else None,
|
||||||
monitor=i,
|
monitor=i,
|
||||||
screenrec_service=screenrec_service if i == 0 else None,
|
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)
|
bar_windows.append(bar)
|
||||||
if i == 0 and bar.notmuch:
|
if i == 0 and bar.notmuch:
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ from sims.modules.vinyl import VinylButton
|
|||||||
from sims.modules.quick_menu import QuickMenuOpener
|
from sims.modules.quick_menu import QuickMenuOpener
|
||||||
from sims.modules.battery import Battery
|
from sims.modules.battery import Battery
|
||||||
from sims.modules.calendar import CalendarService, CalendarPopup
|
from sims.modules.calendar import CalendarService, CalendarPopup
|
||||||
|
from sims.modules.notification_center import NotificationCenter
|
||||||
from sims.modules.notmuch import NotmuchWidget
|
from sims.modules.notmuch import NotmuchWidget
|
||||||
from sims.modules.screenrec import ScreenrecWidget
|
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.widgets.wayland import WaylandWindow as Window
|
||||||
from fabric.system_tray.widgets import SystemTray
|
from fabric.system_tray.widgets import SystemTray
|
||||||
from sims.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow
|
from sims.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow
|
||||||
@@ -31,6 +34,8 @@ class StatusBar(Window):
|
|||||||
tray: SystemTray | None = None,
|
tray: SystemTray | None = None,
|
||||||
monitor: int = 1,
|
monitor: int = 1,
|
||||||
screenrec_service: ScreenrecService | None = None,
|
screenrec_service: ScreenrecService | None = None,
|
||||||
|
notification_history: NotificationHistoryService | None = None,
|
||||||
|
notification_center: NotificationCenter | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name="sims",
|
name="sims",
|
||||||
@@ -112,6 +117,13 @@ class StatusBar(Window):
|
|||||||
if screenrec_service is not None:
|
if screenrec_service is not None:
|
||||||
self.screenrec = ScreenrecWidget(screenrec_service)
|
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(
|
self.status_container = Box(
|
||||||
name="widgets-container",
|
name="widgets-container",
|
||||||
spacing=4,
|
spacing=4,
|
||||||
@@ -134,6 +146,9 @@ class StatusBar(Window):
|
|||||||
if self.screenrec:
|
if self.screenrec:
|
||||||
end_container_children.append(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
|
# Add quick menu button next to time
|
||||||
end_container_children.append(self.quick_menu)
|
end_container_children.append(self.quick_menu)
|
||||||
end_container_children.append(self.date_time)
|
end_container_children.append(self.date_time)
|
||||||
|
|||||||
129
sims/modules/notification_center.py
Normal file
129
sims/modules/notification_center.py
Normal 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()
|
||||||
54
sims/modules/notifications.py
Normal file
54
sims/modules/notifications.py
Normal 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
106
sims/services/notification_history.py
Normal file
106
sims/services/notification_history.py
Normal 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()
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
@import url("./launcher.css");
|
@import url("./launcher.css");
|
||||||
@import url("./calendar.css");
|
@import url("./calendar.css");
|
||||||
@import url("./notmuch.css");
|
@import url("./notmuch.css");
|
||||||
|
@import url("./notifications.css");
|
||||||
|
|
||||||
|
|
||||||
/* unset so we can style everything from the ground up. */
|
/* unset so we can style everything from the ground up. */
|
||||||
|
|||||||
144
sims/styles/notifications.css
Normal file
144
sims/styles/notifications.css
Normal 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;
|
||||||
|
}
|
||||||
132
sims/widgets/notification.py
Normal file
132
sims/widgets/notification.py
Normal 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,
|
||||||
|
)
|
||||||
50
sims/widgets/notification_bell.py
Normal file
50
sims/widgets/notification_bell.py
Normal 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)
|
||||||
100
sims/widgets/notification_history_entry.py
Normal file
100
sims/widgets/notification_history_entry.py
Normal 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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user