565 lines
20 KiB
Python
565 lines
20 KiB
Python
from typing import cast
|
|
|
|
from fabric.notifications import Notification, Notifications
|
|
from fabric.utils import (
|
|
GdkPixbuf,
|
|
GLib,
|
|
Gtk,
|
|
idle_add,
|
|
invoke_repeater,
|
|
os,
|
|
re,
|
|
remove_handler,
|
|
)
|
|
from fabric.widgets.box import Box
|
|
from fabric.widgets.button import Button
|
|
from fabric.widgets.flowbox import FlowBox
|
|
from fabric.widgets.image import Image
|
|
from fabric.widgets.label import Label
|
|
from fabric.widgets.overlay import Overlay
|
|
from fabric.widgets.revealer import Revealer
|
|
from fabric.widgets.wayland import WaylandWindow as Window
|
|
|
|
from services.config_watcher import (
|
|
get_all_config,
|
|
get_config_value,
|
|
has_config_changed,
|
|
on_config_change,
|
|
)
|
|
from utils.animatedscrollable import AnimatedScrollable
|
|
from utils.clippingbox import ClippingBox
|
|
from utils.corners import expanded_corner
|
|
from utils.functions import add_style_class_lazy, get_children_height_limit
|
|
|
|
DEFAULT_ICONS_THEME = Gtk.IconTheme.get_default()
|
|
|
|
|
|
def parse_timeout_to_ms(timeout: str | int) -> int:
|
|
if isinstance(timeout, int):
|
|
return timeout
|
|
|
|
if isinstance(timeout, str):
|
|
timeout_str = timeout.strip().lower()
|
|
# Match patterns like "5s", "1m", "1h", "30s", etc.
|
|
match = re.match(r"^(\d+(?:\.\d+)?)([hms]?)$", timeout_str)
|
|
if match:
|
|
value = float(match.group(1))
|
|
unit = match.group(2) or "s"
|
|
# Convert to milliseconds
|
|
multipliers = {"h": 3600 * 1000, "m": 60 * 1000, "s": 1000, "": 1000}
|
|
return int(value * multipliers.get(unit, 1000))
|
|
|
|
raise ValueError(
|
|
f"Invalid timeout format: {timeout}. Expected integer (ms) or string like '5s', '1m', '1h'"
|
|
)
|
|
|
|
|
|
class LimitBox(Box):
|
|
"""A hack for replicating CSS's `max-*` properties"""
|
|
|
|
def __init__(self, max_width: int, max_height: int, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.max_width: int = max_width
|
|
self.max_height: int = max_height
|
|
|
|
def do_size_allocate(self, allocation):
|
|
if self.max_width >= 0:
|
|
allocation.width = min(self.max_width, allocation.width)
|
|
if self.max_height >= 0:
|
|
allocation.height = min(self.max_height, allocation.height)
|
|
return Box.do_size_allocate(self, allocation)
|
|
|
|
|
|
class NotificationItem(Box):
|
|
def __init__(
|
|
self,
|
|
notification: Notification,
|
|
image_size: int,
|
|
timeout: int,
|
|
width: int,
|
|
buttons_per_row: int,
|
|
**kwargs,
|
|
):
|
|
super().__init__(
|
|
name="notification",
|
|
spacing=8,
|
|
orientation="v",
|
|
size=(width, -1),
|
|
**kwargs,
|
|
)
|
|
|
|
self.notification = notification
|
|
self._image_size = image_size
|
|
self._timeout = timeout
|
|
self._width = width
|
|
self._buttons_per_row = buttons_per_row
|
|
self._timeout_source_id: int | None = None
|
|
|
|
body_container = Box(spacing=4, orientation="h")
|
|
|
|
if image_pixbuf := self._load_notification_image():
|
|
body_container.add(
|
|
ClippingBox(
|
|
children=(
|
|
Image(
|
|
pixbuf=image_pixbuf,
|
|
v_align="fill",
|
|
h_align="fill",
|
|
v_expand=True,
|
|
h_expand=True,
|
|
)
|
|
),
|
|
v_align="start",
|
|
h_align="start",
|
|
v_expand=False,
|
|
h_expand=False,
|
|
size=(self._image_size, self._image_size),
|
|
style=f"border-radius: {self._image_size // 2}px;",
|
|
)
|
|
)
|
|
|
|
body_container.add(
|
|
Box(
|
|
spacing=4,
|
|
orientation="v",
|
|
children=[
|
|
# a box for holding both the "summary" label and the "close" button
|
|
Box(
|
|
orientation="h",
|
|
children=[
|
|
Overlay(
|
|
child=Label(
|
|
label=self.notification.summary,
|
|
style_classes="summary",
|
|
ellipsization="middle",
|
|
)
|
|
.build()
|
|
.set_xalign(0.0)
|
|
.unwrap(),
|
|
# notification's source icon
|
|
overlays=[
|
|
Image(
|
|
# render app's icon if found
|
|
pixbuf=icn.load_icon()
|
|
if (
|
|
icn := DEFAULT_ICONS_THEME.lookup_icon(
|
|
self.notification.app_icon
|
|
or self.notification.app_name,
|
|
12,
|
|
Gtk.IconLookupFlags.FORCE_SIZE,
|
|
)
|
|
)
|
|
is not None
|
|
else None,
|
|
v_align="start",
|
|
h_align="start",
|
|
)
|
|
],
|
|
),
|
|
],
|
|
h_expand=True,
|
|
v_expand=True,
|
|
v_align="start",
|
|
)
|
|
# add the "close" button
|
|
.build(
|
|
lambda box, _: box.pack_end(
|
|
Button(
|
|
image=Image(
|
|
icon_name="close-symbolic",
|
|
icon_size=18,
|
|
),
|
|
v_align="center",
|
|
h_align="end",
|
|
on_clicked=lambda *_: self.notification.close(),
|
|
on_state_flags_changed=lambda btn, *_: (
|
|
btn.set_cursor("pointer")
|
|
if btn.get_state_flags() & 2 and btn.get_realized()
|
|
else btn.set_cursor("default")
|
|
if btn.get_realized()
|
|
else None,
|
|
),
|
|
),
|
|
False,
|
|
False,
|
|
0,
|
|
)
|
|
),
|
|
Label(
|
|
label=self.notification.body,
|
|
style_classes="body",
|
|
line_wrap="word-char",
|
|
v_align="start",
|
|
h_align="start",
|
|
)
|
|
.build()
|
|
.set_xalign(0.0)
|
|
.unwrap(),
|
|
],
|
|
h_expand=True,
|
|
v_expand=True,
|
|
)
|
|
)
|
|
|
|
self.add(body_container)
|
|
|
|
if actions := self.notification.actions:
|
|
self.add(
|
|
FlowBox(
|
|
spacing=4,
|
|
row_spacing=4,
|
|
column_spacing=4,
|
|
orientation="h",
|
|
v_expand=True,
|
|
h_expand=True,
|
|
children=[
|
|
Button(
|
|
h_expand=True,
|
|
v_expand=True,
|
|
label=action.label,
|
|
on_clicked=lambda *_, action=action: action.invoke(),
|
|
on_state_flags_changed=lambda btn, *_: (
|
|
btn.set_cursor("pointer")
|
|
if btn.get_state_flags() & 2 and btn.get_realized()
|
|
else btn.set_cursor("default")
|
|
if btn.get_realized()
|
|
else None,
|
|
),
|
|
)
|
|
for action in actions
|
|
],
|
|
)
|
|
.build()
|
|
.set_max_children_per_line(min(len(actions), self._buttons_per_row))
|
|
.unwrap()
|
|
)
|
|
|
|
# automatically close the notification after the timeout period
|
|
self._start_timeout()
|
|
|
|
add_style_class_lazy(self, "shine")
|
|
|
|
def _start_timeout(self):
|
|
if self._timeout_source_id is not None:
|
|
remove_handler(self._timeout_source_id)
|
|
self._timeout_source_id = None
|
|
|
|
self._timeout_source_id = invoke_repeater(
|
|
self._timeout,
|
|
self.notification.close,
|
|
"expired",
|
|
initial_call=False,
|
|
)
|
|
|
|
def update_timeout(self, new_timeout: int):
|
|
if self._timeout != new_timeout:
|
|
self._timeout = new_timeout
|
|
self._start_timeout()
|
|
|
|
def _load_notification_image(self) -> GdkPixbuf.Pixbuf | None:
|
|
if image_pixbuf := self.notification.image_pixbuf:
|
|
return self._scale_pixbuf(image_pixbuf)
|
|
|
|
if image_file := self._get_image_file_path():
|
|
return self._load_image_from_file(image_file)
|
|
|
|
return None
|
|
|
|
def _get_image_file_path(self) -> str | None:
|
|
try:
|
|
if image_file := self.notification.get_image_file():
|
|
return image_file
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
|
|
try:
|
|
if image_file := getattr(self.notification, "image_file", None):
|
|
return image_file
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
|
|
if image_file := self._get_hint_string("image-path"):
|
|
return image_file
|
|
|
|
try:
|
|
app_icon = getattr(self.notification, "app_icon", None)
|
|
if app_icon and os.path.exists(app_icon):
|
|
return app_icon
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
|
|
return None
|
|
|
|
def _get_hint_string(self, hint_name: str) -> str | None:
|
|
try:
|
|
hint_entry = self.notification.do_get_hint_entry(hint_name)
|
|
if not hint_entry:
|
|
return None
|
|
|
|
if hasattr(hint_entry, "get_variant"):
|
|
variant = hint_entry.get_variant()
|
|
if variant and variant.is_of_type(GLib.VariantType.new("s")):
|
|
return variant.get_string()[0]
|
|
|
|
if hasattr(hint_entry, "get_string"):
|
|
return hint_entry.get_string()
|
|
except (AttributeError, TypeError, Exception):
|
|
pass
|
|
return None
|
|
|
|
def _load_image_from_file(self, image_file: str) -> GdkPixbuf.Pixbuf | None:
|
|
if not (image_path := self._normalize_image_path(image_file)):
|
|
return None
|
|
|
|
paths_to_try = [image_path] + (
|
|
[os.path.abspath(image_path)] if not os.path.isabs(image_path) else []
|
|
)
|
|
|
|
for path in paths_to_try:
|
|
if not os.path.exists(path):
|
|
continue
|
|
try:
|
|
return GdkPixbuf.Pixbuf.new_from_file_at_size(
|
|
path, self._image_size, self._image_size
|
|
)
|
|
except (GLib.GError, Exception):
|
|
continue
|
|
return None
|
|
|
|
def _normalize_image_path(self, image_file: str) -> str | None:
|
|
if not image_file:
|
|
return None
|
|
|
|
image_file = image_file[7:] if image_file.startswith("file://") else image_file
|
|
return os.path.expanduser(image_file)
|
|
|
|
def _scale_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
|
|
"""Scale pixbuf to notification image size if needed"""
|
|
if (
|
|
pixbuf.get_width() == self._image_size
|
|
and pixbuf.get_height() == self._image_size
|
|
):
|
|
return pixbuf
|
|
|
|
return pixbuf.scale_simple(
|
|
self._image_size, self._image_size, GdkPixbuf.InterpType.BILINEAR
|
|
)
|
|
|
|
|
|
# TODO: add the whole thing to a revealer that reveals to the left
|
|
class NotificationsView(Box):
|
|
def __init__(self, **kwargs):
|
|
super().__init__(orientation="v", visible=False, **kwargs)
|
|
|
|
self._notification_items: list[NotificationItem] = []
|
|
|
|
self._load_config()
|
|
on_config_change(self._on_config_change)
|
|
|
|
self.viewport = Box(spacing=4, orientation="v")
|
|
|
|
self.scrolled_window = AnimatedScrollable(
|
|
min_content_size=(420, 1),
|
|
max_content_size=(420, 480),
|
|
h_scrollbar_policy="never",
|
|
v_scrollbar_policy="never",
|
|
child=self.viewport,
|
|
h_expand=True,
|
|
v_expand=True,
|
|
)
|
|
|
|
self.overall_container = Box(
|
|
name="notifications",
|
|
orientation="v",
|
|
children=ClippingBox(
|
|
style_classes="notifications-clip", children=self.scrolled_window
|
|
),
|
|
h_expand=True,
|
|
v_expand=True,
|
|
)
|
|
|
|
self.notifications = Notifications(
|
|
on_notification_added=lambda _, notification_id: (
|
|
notification := cast(
|
|
Notification, self.notifications.notifications.get(notification_id)
|
|
),
|
|
self._enabled
|
|
and (
|
|
notification_item := NotificationItem(
|
|
notification,
|
|
self._image_size,
|
|
self._timeout,
|
|
self._width,
|
|
self._buttons_per_row,
|
|
),
|
|
self._notification_items.append(notification_item),
|
|
self.viewport.add(
|
|
item_rev := Revealer(
|
|
child=notification_item,
|
|
reveal_child=False,
|
|
transition_type="slide-down",
|
|
transition_duration=self._revealer_duration,
|
|
)
|
|
),
|
|
self.viewport.reorder_child(item_rev, 0),
|
|
# ready to show
|
|
item_rev.reveal(),
|
|
notification.closed.connect(
|
|
lambda: (
|
|
self._notification_items.remove(notification_item)
|
|
if notification_item in self._notification_items
|
|
else None,
|
|
item_rev.connect(
|
|
"notify::child-revealed",
|
|
lambda: item_rev.destroy()
|
|
if not item_rev.fully_revealed
|
|
else None,
|
|
),
|
|
idle_add(item_rev.unreveal),
|
|
)
|
|
),
|
|
)
|
|
or None,
|
|
),
|
|
)
|
|
|
|
self.children = (
|
|
Box(
|
|
orientation="h",
|
|
children=[
|
|
Box(
|
|
orientation="v",
|
|
spacing=self._corner_size,
|
|
children=[
|
|
LimitBox(
|
|
max_width=self._corner_size,
|
|
max_height=self._corner_size,
|
|
children=expanded_corner(orientation="top-right"),
|
|
v_expand=True,
|
|
size=(self._corner_size, -1),
|
|
),
|
|
Box(
|
|
h_expand=True,
|
|
v_expand=True,
|
|
),
|
|
],
|
|
h_expand=True,
|
|
v_expand=True,
|
|
),
|
|
self.overall_container,
|
|
],
|
|
),
|
|
Box(
|
|
children=expanded_corner(orientation="top-right"),
|
|
size=self._corner_size,
|
|
h_align="end",
|
|
v_align="end",
|
|
),
|
|
)
|
|
|
|
self.viewport.connect("add", self.on_children_change)
|
|
self.viewport.connect("remove", self.on_children_change)
|
|
self.connect("notify::visible", self.on_visiblity_change)
|
|
|
|
def on_children_change(self, *_):
|
|
self.scrolled_window.animate_size(
|
|
get_children_height_limit(
|
|
self.viewport,
|
|
4,
|
|
lambda rev: (
|
|
cast(Revealer, rev).get_child().get_preferred_size().minimum_size # type: ignore
|
|
),
|
|
)
|
|
)
|
|
|
|
if not self._enabled:
|
|
return self.hide()
|
|
|
|
return self.hide() if not self.viewport.children else self.show()
|
|
|
|
def on_visiblity_change(self, *_):
|
|
self.viewport.remove_style_class("popped")
|
|
|
|
if not self.get_visible():
|
|
return
|
|
|
|
return add_style_class_lazy(self.viewport, "popped")
|
|
|
|
def _load_config(self):
|
|
config = get_all_config()
|
|
self._enabled = get_config_value(config, "notifications.enabled", True)
|
|
timeout_value = get_config_value(config, "notifications.timeout", "10s")
|
|
self._timeout = parse_timeout_to_ms(timeout_value)
|
|
self._image_size = get_config_value(config, "notifications.image_size", 64)
|
|
self._width = get_config_value(config, "notifications.width", 360)
|
|
self._buttons_per_row = get_config_value(
|
|
config, "notifications.buttons_per_row", 2
|
|
)
|
|
self._corner_size = get_config_value(config, "notifications.corner_size", 16)
|
|
self._revealer_duration = get_config_value(
|
|
config, "notifications.revealer_duration", 400
|
|
)
|
|
|
|
def _on_config_change(self, new_config, old_config):
|
|
enabled_changed = has_config_changed(
|
|
old_config, new_config, "notifications.enabled"
|
|
)
|
|
timeout_changed = has_config_changed(
|
|
old_config, new_config, "notifications.timeout"
|
|
)
|
|
image_size_changed = has_config_changed(
|
|
old_config, new_config, "notifications.image_size"
|
|
)
|
|
width_changed = has_config_changed(
|
|
old_config, new_config, "notifications.width"
|
|
)
|
|
buttons_per_row_changed = has_config_changed(
|
|
old_config, new_config, "notifications.buttons_per_row"
|
|
)
|
|
corner_size_changed = has_config_changed(
|
|
old_config, new_config, "notifications.corner_size"
|
|
)
|
|
revealer_duration_changed = has_config_changed(
|
|
old_config, new_config, "notifications.revealer_duration"
|
|
)
|
|
|
|
if (
|
|
enabled_changed
|
|
or timeout_changed
|
|
or image_size_changed
|
|
or width_changed
|
|
or buttons_per_row_changed
|
|
or corner_size_changed
|
|
or revealer_duration_changed
|
|
):
|
|
old_timeout = self._timeout
|
|
old_enabled = self._enabled
|
|
self._load_config()
|
|
|
|
if enabled_changed and self._enabled != old_enabled:
|
|
if not self._enabled:
|
|
for item in list(self._notification_items):
|
|
item.notification.close()
|
|
|
|
if timeout_changed and self._timeout != old_timeout:
|
|
for item in self._notification_items:
|
|
item.update_timeout(self._timeout)
|
|
|
|
|
|
class NotificationWindow(Window):
|
|
def __init__(self, **kwargs):
|
|
super().__init__(
|
|
layer="top",
|
|
anchor="top right",
|
|
visible=False,
|
|
all_visible=False,
|
|
)
|
|
|
|
self.build(
|
|
lambda win, _: win.add(
|
|
NotificationsView().build(
|
|
lambda notifs, _: notifs.bind("visible", "visible", win)
|
|
)
|
|
)
|
|
)
|