feat: sims notifications
This commit is contained in:
@@ -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:
|
||||
|
||||
52
flake.nix
52
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
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.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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
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("./calendar.css");
|
||||
@import url("./notmuch.css");
|
||||
@import url("./notifications.css");
|
||||
|
||||
|
||||
/* 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