Initial support for mako notification daemon.
This commit is contained in:
@@ -261,6 +261,7 @@ from . import event_manager
|
|||||||
from . import keybindings
|
from . import keybindings
|
||||||
from . import learn_mode_presenter
|
from . import learn_mode_presenter
|
||||||
from . import logger
|
from . import logger
|
||||||
|
from . import mako_notification_monitor
|
||||||
from . import messages
|
from . import messages
|
||||||
from . import notification_presenter
|
from . import notification_presenter
|
||||||
from . import focus_manager
|
from . import focus_manager
|
||||||
@@ -719,6 +720,7 @@ def start() -> None:
|
|||||||
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Starting Atspi main event loop', True)
|
debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Starting Atspi main event loop', True)
|
||||||
|
|
||||||
# Start D-Bus remote controller service after ATSPI is ready
|
# Start D-Bus remote controller service after ATSPI is ready
|
||||||
|
cthulhuApp.getMakoNotificationMonitor().start()
|
||||||
GObject.idle_add(_start_dbus_service)
|
GObject.idle_add(_start_dbus_service)
|
||||||
|
|
||||||
Atspi.event_main()
|
Atspi.event_main()
|
||||||
@@ -771,6 +773,7 @@ def shutdown(script: Optional[Any] = None, inputEvent: Optional[Any] = None) ->
|
|||||||
cthulhuApp.getSignalManager().emitSignal('stop-application-completed')
|
cthulhuApp.getSignalManager().emitSignal('stop-application-completed')
|
||||||
sound_theme_manager.getManager().playStopSound(wait=True, timeoutSeconds=1)
|
sound_theme_manager.getManager().playStopSound(wait=True, timeoutSeconds=1)
|
||||||
cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True)
|
cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True)
|
||||||
|
cthulhuApp.getMakoNotificationMonitor().stop()
|
||||||
|
|
||||||
# Deactivate the event manager first so that it clears its queue and will not
|
# Deactivate the event manager first so that it clears its queue and will not
|
||||||
# accept new events. Then let the script manager unregister script event listeners.
|
# accept new events. Then let the script manager unregister script event listeners.
|
||||||
@@ -958,6 +961,8 @@ class Cthulhu(GObject.Object):
|
|||||||
self.translationManager: TranslationManager = translation_manager.TranslationManager(self)
|
self.translationManager: TranslationManager = translation_manager.TranslationManager(self)
|
||||||
self.debugManager: Any = debug
|
self.debugManager: Any = debug
|
||||||
self.APIHelper: APIHelper = APIHelper(self)
|
self.APIHelper: APIHelper = APIHelper(self)
|
||||||
|
self.makoNotificationMonitor: mako_notification_monitor.MakoNotificationMonitor = \
|
||||||
|
mako_notification_monitor.MakoNotificationMonitor(self)
|
||||||
self.createCompatAPI()
|
self.createCompatAPI()
|
||||||
self.pluginSystemManager: PluginSystemManager = plugin_system_manager.PluginSystemManager(self)
|
self.pluginSystemManager: PluginSystemManager = plugin_system_manager.PluginSystemManager(self)
|
||||||
# Scan for available plugins at startup
|
# Scan for available plugins at startup
|
||||||
@@ -969,6 +974,9 @@ class Cthulhu(GObject.Object):
|
|||||||
def getPluginSystemManager(self) -> PluginSystemManager:
|
def getPluginSystemManager(self) -> PluginSystemManager:
|
||||||
return self.pluginSystemManager
|
return self.pluginSystemManager
|
||||||
|
|
||||||
|
def getMakoNotificationMonitor(self) -> mako_notification_monitor.MakoNotificationMonitor:
|
||||||
|
return self.makoNotificationMonitor
|
||||||
|
|
||||||
def getDynamicApiManager(self) -> DynamicApiManager:
|
def getDynamicApiManager(self) -> DynamicApiManager:
|
||||||
return self.dynamicApiManager
|
return self.dynamicApiManager
|
||||||
|
|
||||||
@@ -1027,6 +1035,7 @@ class Cthulhu(GObject.Object):
|
|||||||
self.getDynamicApiManager().registerAPI('Messages', messages)
|
self.getDynamicApiManager().registerAPI('Messages', messages)
|
||||||
self.getDynamicApiManager().registerAPI('Cmdnames', cmdnames)
|
self.getDynamicApiManager().registerAPI('Cmdnames', cmdnames)
|
||||||
self.getDynamicApiManager().registerAPI('NotificationPresenter', notification_presenter)
|
self.getDynamicApiManager().registerAPI('NotificationPresenter', notification_presenter)
|
||||||
|
self.getDynamicApiManager().registerAPI('MakoNotificationMonitor', self.makoNotificationMonitor)
|
||||||
self.getDynamicApiManager().registerAPI('CthulhuState', cthulhu_state)
|
self.getDynamicApiManager().registerAPI('CthulhuState', cthulhu_state)
|
||||||
self.getDynamicApiManager().registerAPI('CthulhuPlatform', cthulhu_platform)
|
self.getDynamicApiManager().registerAPI('CthulhuPlatform', cthulhu_platform)
|
||||||
self.getDynamicApiManager().registerAPI('Settings', settings)
|
self.getDynamicApiManager().registerAPI('Settings', settings)
|
||||||
|
|||||||
@@ -376,6 +376,22 @@ NOTIFICATIONS_COLUMN_HEADER = C_("notification presenter", "Notifications")
|
|||||||
# for the time, which will be relative (e.g. "10 minutes ago") or absolute.
|
# for the time, which will be relative (e.g. "10 minutes ago") or absolute.
|
||||||
NOTIFICATIONS_RECEIVED_TIME = C_("notification presenter", "Received")
|
NOTIFICATIONS_RECEIVED_TIME = C_("notification presenter", "Received")
|
||||||
|
|
||||||
|
# Translators: This is the label for a button which dismisses the selected live
|
||||||
|
# notification from mako's queue.
|
||||||
|
NOTIFICATIONS_DISMISS_BUTTON = C_("notification presenter", "Dismiss Notification")
|
||||||
|
|
||||||
|
# Translators: This is the label for a button which opens the list of actions
|
||||||
|
# for the selected live notification.
|
||||||
|
NOTIFICATIONS_ACTIONS_BUTTON = C_("notification presenter", "Notification Actions")
|
||||||
|
|
||||||
|
# Translators: This is the title for the dialog listing the actions associated
|
||||||
|
# with the selected live notification.
|
||||||
|
NOTIFICATIONS_ACTIONS_TITLE = C_("notification presenter", "Notification Actions")
|
||||||
|
|
||||||
|
# Translators: This is the label for a button which invokes the selected action
|
||||||
|
# from the notification actions dialog.
|
||||||
|
NOTIFICATIONS_INVOKE_ACTION_BUTTON = C_("notification presenter", "Invoke Action")
|
||||||
|
|
||||||
# Translators: This string is a label for the group of Cthulhu commands which
|
# Translators: This string is a label for the group of Cthulhu commands which
|
||||||
# are associated with presenting notifications.
|
# are associated with presenting notifications.
|
||||||
KB_GROUP_NOTIFICATIONS = _("Notification presenter")
|
KB_GROUP_NOTIFICATIONS = _("Notification presenter")
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
#
|
||||||
|
# Copyright (c) 2026 Stormux
|
||||||
|
#
|
||||||
|
# This library is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU Lesser General Public
|
||||||
|
# License as published by the Free Software Foundation; either
|
||||||
|
# version 2.1 of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This library is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public
|
||||||
|
# License along with this library; if not, write to the
|
||||||
|
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
|
||||||
|
# Boston MA 02110-1301 USA.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""Core support for speaking mako notifications via D-Bus."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from gi.repository import Gio, GLib
|
||||||
|
|
||||||
|
from . import cthulhu_state
|
||||||
|
from . import debug
|
||||||
|
from . import messages
|
||||||
|
from . import notification_presenter
|
||||||
|
from . import settings
|
||||||
|
|
||||||
|
|
||||||
|
class MakoNotificationMonitor:
|
||||||
|
"""Tracks mako's live D-Bus queue and presents new notifications."""
|
||||||
|
|
||||||
|
BUS_NAME = "org.freedesktop.Notifications"
|
||||||
|
OBJECT_PATH = "/fr/emersion/Mako"
|
||||||
|
INTERFACE_NAME = "fr.emersion.Mako"
|
||||||
|
SOURCE_NAME = "mako"
|
||||||
|
|
||||||
|
def __init__(self, app) -> None:
|
||||||
|
self._app = app
|
||||||
|
self._presenter = None
|
||||||
|
self._connection: Optional[Gio.DBusConnection] = None
|
||||||
|
self._watch_id: int = 0
|
||||||
|
self._properties_subscription_id: int = 0
|
||||||
|
self._known_ids: set[int] = set()
|
||||||
|
self._is_running: bool = False
|
||||||
|
self._is_available: bool = False
|
||||||
|
self._generation: int = 0
|
||||||
|
self._tag_pattern = re.compile(r"<[^>]+>")
|
||||||
|
self._whitespace_pattern = re.compile(r"\s+")
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Starts watching the session bus for mako."""
|
||||||
|
|
||||||
|
if self._is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||||
|
except Exception as error:
|
||||||
|
msg = f"MAKO MONITOR: Failed to connect to session bus: {error}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._watch_id = Gio.bus_watch_name_on_connection(
|
||||||
|
self._connection,
|
||||||
|
self.BUS_NAME,
|
||||||
|
Gio.BusNameWatcherFlags.NONE,
|
||||||
|
self._on_name_appeared,
|
||||||
|
self._on_name_vanished,
|
||||||
|
)
|
||||||
|
self._is_running = True
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, "MAKO MONITOR: Started", True)
|
||||||
|
|
||||||
|
def stop(self, reset_live_state: bool = True) -> None:
|
||||||
|
"""Stops watching the session bus for mako."""
|
||||||
|
|
||||||
|
if self._properties_subscription_id and self._connection is not None:
|
||||||
|
self._connection.signal_unsubscribe(self._properties_subscription_id)
|
||||||
|
self._properties_subscription_id = 0
|
||||||
|
|
||||||
|
if self._watch_id:
|
||||||
|
Gio.bus_unwatch_name(self._watch_id)
|
||||||
|
self._watch_id = 0
|
||||||
|
|
||||||
|
if reset_live_state:
|
||||||
|
self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)
|
||||||
|
|
||||||
|
self._known_ids.clear()
|
||||||
|
self._is_available = False
|
||||||
|
self._is_running = False
|
||||||
|
self._connection = None
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, "MAKO MONITOR: Stopped", True)
|
||||||
|
|
||||||
|
def refresh(self, announce_new: bool = False) -> bool:
|
||||||
|
"""Refreshes mako's live queue and optionally speaks new notifications."""
|
||||||
|
|
||||||
|
if self._connection is None or not self._is_available:
|
||||||
|
return False
|
||||||
|
|
||||||
|
live_notifications = self._fetch_live_notifications()
|
||||||
|
if live_notifications is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._sync_notifications(live_notifications, announce_new)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def dismiss_notification(self, notification_id: int) -> bool:
|
||||||
|
"""Dismisses a live mako notification by ID."""
|
||||||
|
|
||||||
|
if self._connection is None or not self._is_available:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
options = {
|
||||||
|
"id": GLib.Variant("u", notification_id),
|
||||||
|
"group": GLib.Variant("b", False),
|
||||||
|
"history": GLib.Variant("b", True),
|
||||||
|
"all": GLib.Variant("b", False),
|
||||||
|
}
|
||||||
|
self._connection.call_sync(
|
||||||
|
self.BUS_NAME,
|
||||||
|
self.OBJECT_PATH,
|
||||||
|
self.INTERFACE_NAME,
|
||||||
|
"DismissNotifications",
|
||||||
|
GLib.Variant("(a{sv})", (options,)),
|
||||||
|
None,
|
||||||
|
Gio.DBusCallFlags.NONE,
|
||||||
|
-1,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
msg = f"MAKO MONITOR: Failed to dismiss notification {notification_id}: {error}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.refresh(announce_new=False)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def invoke_action(self, notification_id: int, action_key: str) -> bool:
|
||||||
|
"""Invokes a live mako notification action."""
|
||||||
|
|
||||||
|
if self._connection is None or not self._is_available:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._connection.call_sync(
|
||||||
|
self.BUS_NAME,
|
||||||
|
self.OBJECT_PATH,
|
||||||
|
self.INTERFACE_NAME,
|
||||||
|
"InvokeAction",
|
||||||
|
GLib.Variant("(us)", (notification_id, action_key)),
|
||||||
|
None,
|
||||||
|
Gio.DBusCallFlags.NONE,
|
||||||
|
-1,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
msg = (
|
||||||
|
f"MAKO MONITOR: Failed to invoke action {action_key} "
|
||||||
|
f"for notification {notification_id}: {error}"
|
||||||
|
)
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.refresh(announce_new=False)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_current_entry(self, entry) -> bool:
|
||||||
|
"""Returns True if entry belongs to the current mako session."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._is_available
|
||||||
|
and entry.source == self.SOURCE_NAME
|
||||||
|
and entry.source_generation == self._generation
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_generation(self) -> int:
|
||||||
|
"""Returns the current mako session generation."""
|
||||||
|
|
||||||
|
return self._generation
|
||||||
|
|
||||||
|
def _get_presenter(self):
|
||||||
|
presenter = self._presenter or notification_presenter.getPresenter()
|
||||||
|
if presenter is not self._presenter:
|
||||||
|
self._presenter = presenter
|
||||||
|
|
||||||
|
if getattr(presenter, "_mako_monitor", None) is not self:
|
||||||
|
presenter.set_mako_monitor(self)
|
||||||
|
|
||||||
|
return presenter
|
||||||
|
|
||||||
|
def _on_name_appeared(
|
||||||
|
self,
|
||||||
|
connection: Gio.DBusConnection,
|
||||||
|
name: str,
|
||||||
|
name_owner: str,
|
||||||
|
user_data: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
msg = f"MAKO MONITOR: {name} appeared as {name_owner}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
|
self._connection = connection
|
||||||
|
self._subscribe_to_properties()
|
||||||
|
|
||||||
|
live_notifications = self._fetch_live_notifications()
|
||||||
|
if live_notifications is None:
|
||||||
|
self._unsubscribe_from_properties()
|
||||||
|
self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)
|
||||||
|
self._known_ids.clear()
|
||||||
|
self._is_available = False
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
"MAKO MONITOR: Notification daemon is not exposing the mako interface",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._generation += 1
|
||||||
|
self._is_available = True
|
||||||
|
self._known_ids.clear()
|
||||||
|
self._sync_notifications(live_notifications, announce_new=False)
|
||||||
|
|
||||||
|
def _on_name_vanished(
|
||||||
|
self,
|
||||||
|
connection: Gio.DBusConnection,
|
||||||
|
name: str,
|
||||||
|
user_data: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
msg = f"MAKO MONITOR: {name} vanished"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
self._unsubscribe_from_properties()
|
||||||
|
self._get_presenter().mark_source_unavailable(self.SOURCE_NAME)
|
||||||
|
self._known_ids.clear()
|
||||||
|
self._is_available = False
|
||||||
|
|
||||||
|
def _subscribe_to_properties(self) -> None:
|
||||||
|
if self._connection is None or self._properties_subscription_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._properties_subscription_id = self._connection.signal_subscribe(
|
||||||
|
self.BUS_NAME,
|
||||||
|
"org.freedesktop.DBus.Properties",
|
||||||
|
"PropertiesChanged",
|
||||||
|
self.OBJECT_PATH,
|
||||||
|
self.INTERFACE_NAME,
|
||||||
|
Gio.DBusSignalFlags.NONE,
|
||||||
|
self._on_properties_changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _unsubscribe_from_properties(self) -> None:
|
||||||
|
if self._connection is None or not self._properties_subscription_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._connection.signal_unsubscribe(self._properties_subscription_id)
|
||||||
|
self._properties_subscription_id = 0
|
||||||
|
|
||||||
|
def _on_properties_changed(
|
||||||
|
self,
|
||||||
|
connection: Gio.DBusConnection,
|
||||||
|
sender_name: Optional[str],
|
||||||
|
object_path: str,
|
||||||
|
interface_name: str,
|
||||||
|
signal_name: str,
|
||||||
|
parameters: GLib.Variant,
|
||||||
|
user_data: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
del connection, sender_name, object_path, interface_name, signal_name, parameters, user_data
|
||||||
|
self.refresh(announce_new=True)
|
||||||
|
|
||||||
|
def _fetch_live_notifications(self) -> Optional[list[dict[str, Any]]]:
|
||||||
|
if self._connection is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
reply = self._connection.call_sync(
|
||||||
|
self.BUS_NAME,
|
||||||
|
self.OBJECT_PATH,
|
||||||
|
self.INTERFACE_NAME,
|
||||||
|
"ListNotifications",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Gio.DBusCallFlags.NONE,
|
||||||
|
-1,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
msg = f"MAKO MONITOR: Failed to list notifications: {error}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
unpacked = reply.unpack()
|
||||||
|
except Exception as error:
|
||||||
|
msg = f"MAKO MONITOR: Failed to unpack notification list: {error}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not unpacked:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return list(unpacked[0])
|
||||||
|
|
||||||
|
def _sync_notifications(
|
||||||
|
self,
|
||||||
|
live_notifications: list[dict[str, Any]],
|
||||||
|
announce_new: bool,
|
||||||
|
) -> None:
|
||||||
|
live_by_id: dict[int, dict[str, Any]] = {}
|
||||||
|
ordered_ids: list[int] = []
|
||||||
|
|
||||||
|
for notification in live_notifications:
|
||||||
|
parsed = self._parse_notification(notification)
|
||||||
|
notification_id = parsed.get("notification_id")
|
||||||
|
if notification_id is None:
|
||||||
|
continue
|
||||||
|
live_by_id[notification_id] = parsed
|
||||||
|
ordered_ids.append(notification_id)
|
||||||
|
|
||||||
|
self._get_presenter().sync_live_notifications(
|
||||||
|
self.SOURCE_NAME,
|
||||||
|
live_by_id,
|
||||||
|
self._generation,
|
||||||
|
)
|
||||||
|
|
||||||
|
if announce_new:
|
||||||
|
for notification_id in ordered_ids:
|
||||||
|
if notification_id in self._known_ids:
|
||||||
|
continue
|
||||||
|
self._announce_notification(live_by_id[notification_id])
|
||||||
|
|
||||||
|
self._known_ids = set(live_by_id)
|
||||||
|
|
||||||
|
def _announce_notification(self, notification: dict[str, Any]) -> None:
|
||||||
|
message = notification.get("message") or ""
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._get_presenter().save_notification(
|
||||||
|
message,
|
||||||
|
source=self.SOURCE_NAME,
|
||||||
|
source_generation=self._generation,
|
||||||
|
notification_id=notification.get("notification_id"),
|
||||||
|
live=True,
|
||||||
|
actions=notification.get("actions"),
|
||||||
|
app_name=notification.get("app_name", ""),
|
||||||
|
summary=notification.get("summary", ""),
|
||||||
|
body=notification.get("body", ""),
|
||||||
|
urgency=notification.get("urgency", -1),
|
||||||
|
desktop_entry=notification.get("desktop_entry", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
script = cthulhu_state.activeScript or self._app.getScriptManager().get_default_script()
|
||||||
|
if script is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
voice = script.speechGenerator.voice(string=message)
|
||||||
|
script.speakMessage(message, voice=voice)
|
||||||
|
script.displayBrailleMessage(message, flashTime=settings.brailleFlashTime)
|
||||||
|
|
||||||
|
def _parse_notification(self, notification: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
actions = notification.get("actions") or {}
|
||||||
|
if not isinstance(actions, dict):
|
||||||
|
actions = {}
|
||||||
|
|
||||||
|
app_name = self._normalize_text(notification.get("app-name", ""))
|
||||||
|
summary = self._normalize_text(notification.get("summary", ""))
|
||||||
|
body = self._normalize_text(notification.get("body", ""))
|
||||||
|
desktop_entry = self._normalize_text(notification.get("desktop-entry", ""))
|
||||||
|
urgency = notification.get("urgency", -1)
|
||||||
|
notification_id = notification.get("id")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"notification_id": notification_id if isinstance(notification_id, int) else None,
|
||||||
|
"actions": {
|
||||||
|
str(key): self._normalize_text(value) or str(key)
|
||||||
|
for key, value in actions.items()
|
||||||
|
},
|
||||||
|
"app_name": app_name,
|
||||||
|
"summary": summary,
|
||||||
|
"body": body,
|
||||||
|
"urgency": urgency if isinstance(urgency, int) else -1,
|
||||||
|
"desktop_entry": desktop_entry,
|
||||||
|
"message": self._build_message(app_name, summary, body, desktop_entry),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_message(
|
||||||
|
self,
|
||||||
|
app_name: str,
|
||||||
|
summary: str,
|
||||||
|
body: str,
|
||||||
|
desktop_entry: str,
|
||||||
|
) -> str:
|
||||||
|
parts = [messages.NOTIFICATION]
|
||||||
|
if summary:
|
||||||
|
parts.append(summary)
|
||||||
|
if body and body != summary:
|
||||||
|
parts.append(body)
|
||||||
|
if len(parts) == 1:
|
||||||
|
fallback = app_name or desktop_entry
|
||||||
|
if fallback:
|
||||||
|
parts.append(fallback)
|
||||||
|
return " ".join(part for part in parts if part).strip()
|
||||||
|
|
||||||
|
def _normalize_text(self, text: Any) -> str:
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = html.unescape(text)
|
||||||
|
text = self._tag_pattern.sub(" ", text)
|
||||||
|
text = self._whitespace_pattern.sub(" ", text)
|
||||||
|
return text.strip()
|
||||||
@@ -60,6 +60,7 @@ cthulhu_python_sources = files([
|
|||||||
'learn_mode_presenter.py',
|
'learn_mode_presenter.py',
|
||||||
'liveregions.py',
|
'liveregions.py',
|
||||||
'logger.py',
|
'logger.py',
|
||||||
|
'mako_notification_monitor.py',
|
||||||
'mathsymbols.py',
|
'mathsymbols.py',
|
||||||
'messages.py',
|
'messages.py',
|
||||||
'mouse_review.py',
|
'mouse_review.py',
|
||||||
|
|||||||
@@ -2011,6 +2011,21 @@ NOTIFICATION_LIST_BOTTOM = C_("notification", "Bottom")
|
|||||||
# list of notifications is reached.
|
# list of notifications is reached.
|
||||||
NOTIFICATION_LIST_TOP = C_("notification", "Top")
|
NOTIFICATION_LIST_TOP = C_("notification", "Top")
|
||||||
|
|
||||||
|
# Translators: This message is presented when a live notification can no longer
|
||||||
|
# be controlled because it has already been closed or replaced.
|
||||||
|
NOTIFICATION_UNAVAILABLE = _("Notification is no longer available")
|
||||||
|
|
||||||
|
# Translators: This message is presented when the selected live notification
|
||||||
|
# does not expose any actions.
|
||||||
|
NOTIFICATION_NO_ACTIONS = _("No notification actions available")
|
||||||
|
|
||||||
|
# Translators: This message is presented after dismissing a live notification.
|
||||||
|
NOTIFICATION_DISMISSED = _("Notification dismissed")
|
||||||
|
|
||||||
|
# Translators: This message is presented after invoking an action on a live
|
||||||
|
# notification.
|
||||||
|
NOTIFICATION_ACTION_INVOKED = _("Notification action invoked")
|
||||||
|
|
||||||
# Translators: This message is presented to the user when the notifications list
|
# Translators: This message is presented to the user when the notifications list
|
||||||
# is empty.
|
# is empty.
|
||||||
NOTIFICATION_NO_MESSAGES = _("No notification messages")
|
NOTIFICATION_NO_MESSAGES = _("No notification messages")
|
||||||
|
|||||||
@@ -23,24 +23,18 @@
|
|||||||
# Forked from Orca screen reader.
|
# Forked from Orca screen reader.
|
||||||
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
||||||
|
|
||||||
"""Module for notification messages"""
|
"""Module for notification messages."""
|
||||||
|
|
||||||
__id__ = "$Id$"
|
|
||||||
__version__ = "$Revision$"
|
|
||||||
__date__ = "$Date$"
|
|
||||||
__copyright__ = "Copyright (c) 2023 Igalia, S.L." \
|
|
||||||
"Copyright (c) 2010 Informal Informatica LTDA."
|
|
||||||
__license__ = "LGPL"
|
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Dict, List, Any, Callable
|
from typing import Optional, Dict, List, Any, Callable
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version('Gtk', '3.0')
|
|
||||||
|
gi.require_version("Gtk", "3.0")
|
||||||
from gi.repository import GObject
|
from gi.repository import GObject
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk
|
||||||
from gi.repository import Gdk
|
from gi.repository import Gdk
|
||||||
from gi.repository import Gdk
|
|
||||||
|
|
||||||
from . import cmdnames
|
from . import cmdnames
|
||||||
from . import debug
|
from . import debug
|
||||||
@@ -48,13 +42,32 @@ from . import guilabels
|
|||||||
from . import input_event
|
from . import input_event
|
||||||
from . import keybindings
|
from . import keybindings
|
||||||
from . import messages
|
from . import messages
|
||||||
from . import cthulhu_state
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationEntry:
|
||||||
|
"""Represents a notification saved in Cthulhu's history."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
timestamp: float
|
||||||
|
source: str = "generic"
|
||||||
|
source_generation: int = 0
|
||||||
|
notification_id: Optional[int] = None
|
||||||
|
live: bool = False
|
||||||
|
actions: Dict[str, str] = field(default_factory=dict)
|
||||||
|
app_name: str = ""
|
||||||
|
summary: str = ""
|
||||||
|
body: str = ""
|
||||||
|
urgency: int = -1
|
||||||
|
desktop_entry: str = ""
|
||||||
|
|
||||||
|
|
||||||
class NotificationPresenter:
|
class NotificationPresenter:
|
||||||
"""Provides access to the notification history."""
|
"""Provides access to the notification history."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._gui: Optional[Any] = None # Optional[Gtk.Window]
|
self._gui: Optional[Any] = None
|
||||||
|
self._mako_monitor: Optional[Any] = None
|
||||||
self._handlers: Dict[str, Callable] = self._setup_handlers()
|
self._handlers: Dict[str, Callable] = self._setup_handlers()
|
||||||
self._bindings: keybindings.KeyBindings = self._setup_bindings()
|
self._bindings: keybindings.KeyBindings = self._setup_bindings()
|
||||||
self._max_size: int = 55
|
self._max_size: int = 55
|
||||||
@@ -63,7 +76,7 @@ class NotificationPresenter:
|
|||||||
# the list. The current index is relative to, and used directly, with the
|
# the list. The current index is relative to, and used directly, with the
|
||||||
# python list, i.e. self._notifications[-3] would return the third-to-last
|
# python list, i.e. self._notifications[-3] would return the third-to-last
|
||||||
# notification message.
|
# notification message.
|
||||||
self._notifications: List[List[Any]] = [] # List of [message: str, time: float]
|
self._notifications: List[NotificationEntry] = []
|
||||||
self._current_index: int = -1
|
self._current_index: int = -1
|
||||||
|
|
||||||
def get_bindings(self) -> keybindings.KeyBindings:
|
def get_bindings(self) -> keybindings.KeyBindings:
|
||||||
@@ -76,14 +89,47 @@ class NotificationPresenter:
|
|||||||
|
|
||||||
return self._handlers
|
return self._handlers
|
||||||
|
|
||||||
def save_notification(self, message: str) -> None:
|
def set_mako_monitor(self, monitor: Any) -> None:
|
||||||
|
"""Associates the mako D-Bus monitor with this presenter."""
|
||||||
|
|
||||||
|
self._mako_monitor = monitor
|
||||||
|
|
||||||
|
def save_notification(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
source: str = "generic",
|
||||||
|
source_generation: int = 0,
|
||||||
|
notification_id: Optional[int] = None,
|
||||||
|
live: bool = False,
|
||||||
|
actions: Optional[Dict[str, str]] = None,
|
||||||
|
app_name: str = "",
|
||||||
|
summary: str = "",
|
||||||
|
body: str = "",
|
||||||
|
urgency: int = -1,
|
||||||
|
desktop_entry: str = "",
|
||||||
|
) -> NotificationEntry:
|
||||||
"""Adds message to the list of notification messages."""
|
"""Adds message to the list of notification messages."""
|
||||||
|
|
||||||
tokens = ["NOTIFICATION PRESENTER: Adding '", message, "'."]
|
tokens = ["NOTIFICATION PRESENTER: Adding '", message, "'."]
|
||||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
to_remove = max(len(self._notifications) - self._max_size + 1, 0)
|
to_remove = max(len(self._notifications) - self._max_size + 1, 0)
|
||||||
self._notifications = self._notifications[to_remove:]
|
self._notifications = self._notifications[to_remove:]
|
||||||
self._notifications.append([message, time.time()])
|
entry = NotificationEntry(
|
||||||
|
message=message,
|
||||||
|
timestamp=time.time(),
|
||||||
|
source=source,
|
||||||
|
source_generation=source_generation,
|
||||||
|
notification_id=notification_id,
|
||||||
|
live=live,
|
||||||
|
actions=dict(actions or {}),
|
||||||
|
app_name=app_name,
|
||||||
|
summary=summary,
|
||||||
|
body=body,
|
||||||
|
urgency=urgency,
|
||||||
|
desktop_entry=desktop_entry,
|
||||||
|
)
|
||||||
|
self._notifications.append(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
def clear_list(self) -> None:
|
def clear_list(self) -> None:
|
||||||
"""Clears the notifications list."""
|
"""Clears the notifications list."""
|
||||||
@@ -93,30 +139,117 @@ class NotificationPresenter:
|
|||||||
self._notifications = []
|
self._notifications = []
|
||||||
self._current_index = -1
|
self._current_index = -1
|
||||||
|
|
||||||
|
def refresh_live_notifications(self) -> bool:
|
||||||
|
"""Refreshes live mako state without announcing new notifications."""
|
||||||
|
|
||||||
|
if self._mako_monitor is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._mako_monitor.refresh(announce_new=False)
|
||||||
|
|
||||||
|
def sync_live_notifications(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
live_notifications: Dict[int, Dict[str, Any]],
|
||||||
|
source_generation: int,
|
||||||
|
) -> None:
|
||||||
|
"""Synchronizes current live notification metadata with history."""
|
||||||
|
|
||||||
|
live_ids = set(live_notifications)
|
||||||
|
for entry in self._notifications:
|
||||||
|
if entry.source != source or entry.source_generation != source_generation:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.notification_id in live_ids:
|
||||||
|
metadata = live_notifications[entry.notification_id]
|
||||||
|
entry.live = True
|
||||||
|
entry.message = metadata.get("message", entry.message)
|
||||||
|
entry.actions = dict(metadata.get("actions") or {})
|
||||||
|
entry.app_name = metadata.get("app_name", entry.app_name)
|
||||||
|
entry.summary = metadata.get("summary", entry.summary)
|
||||||
|
entry.body = metadata.get("body", entry.body)
|
||||||
|
entry.urgency = metadata.get("urgency", entry.urgency)
|
||||||
|
entry.desktop_entry = metadata.get("desktop_entry", entry.desktop_entry)
|
||||||
|
elif entry.live:
|
||||||
|
entry.live = False
|
||||||
|
|
||||||
|
def mark_source_unavailable(self, source: str) -> None:
|
||||||
|
"""Marks all live notifications from source as no longer live."""
|
||||||
|
|
||||||
|
for entry in self._notifications:
|
||||||
|
if entry.source == source and entry.live:
|
||||||
|
entry.live = False
|
||||||
|
|
||||||
|
def can_control_entry(self, entry: Optional[NotificationEntry]) -> bool:
|
||||||
|
"""Returns True if entry can still be controlled through mako."""
|
||||||
|
|
||||||
|
if entry is None or not entry.live or entry.notification_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._mako_monitor is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._mako_monitor.is_current_entry(entry)
|
||||||
|
|
||||||
|
def get_actions_for_entry(self, entry: Optional[NotificationEntry]) -> Dict[str, str]:
|
||||||
|
"""Returns the live action map for entry, if it is controllable."""
|
||||||
|
|
||||||
|
if not self.can_control_entry(entry):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return dict(entry.actions)
|
||||||
|
|
||||||
|
def dismiss_entry(self, script: Any, entry: Optional[NotificationEntry]) -> bool:
|
||||||
|
"""Dismisses the live notification represented by entry."""
|
||||||
|
|
||||||
|
if not self.can_control_entry(entry) or self._mako_monitor is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = self._mako_monitor.dismiss_notification(entry.notification_id)
|
||||||
|
if result:
|
||||||
|
script.presentMessage(messages.NOTIFICATION_DISMISSED)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def invoke_action_for_entry(
|
||||||
|
self,
|
||||||
|
script: Any,
|
||||||
|
entry: Optional[NotificationEntry],
|
||||||
|
action_key: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Invokes action_key for the live notification represented by entry."""
|
||||||
|
|
||||||
|
if not self.can_control_entry(entry) or self._mako_monitor is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = self._mako_monitor.invoke_action(entry.notification_id, action_key)
|
||||||
|
if result:
|
||||||
|
script.presentMessage(messages.NOTIFICATION_ACTION_INVOKED)
|
||||||
|
return result
|
||||||
|
|
||||||
def _setup_handlers(self) -> Dict[str, Callable]:
|
def _setup_handlers(self) -> Dict[str, Callable]:
|
||||||
"""Sets up and returns the notification-presenter input event handlers."""
|
"""Sets up and returns the notification-presenter input event handlers."""
|
||||||
|
|
||||||
handlers = {}
|
handlers = {}
|
||||||
|
|
||||||
handlers["present_last_notification"] = \
|
handlers["present_last_notification"] = input_event.InputEventHandler(
|
||||||
input_event.InputEventHandler(
|
self._present_last_notification,
|
||||||
self._present_last_notification,
|
cmdnames.NOTIFICATION_MESSAGES_LAST,
|
||||||
cmdnames.NOTIFICATION_MESSAGES_LAST)
|
)
|
||||||
|
|
||||||
handlers["present_next_notification"] = \
|
handlers["present_next_notification"] = input_event.InputEventHandler(
|
||||||
input_event.InputEventHandler(
|
self._present_next_notification,
|
||||||
self._present_next_notification,
|
cmdnames.NOTIFICATION_MESSAGES_NEXT,
|
||||||
cmdnames.NOTIFICATION_MESSAGES_NEXT)
|
)
|
||||||
|
|
||||||
handlers["present_previous_notification"] = \
|
handlers["present_previous_notification"] = input_event.InputEventHandler(
|
||||||
input_event.InputEventHandler(
|
self._present_previous_notification,
|
||||||
self._present_previous_notification,
|
cmdnames.NOTIFICATION_MESSAGES_PREVIOUS,
|
||||||
cmdnames.NOTIFICATION_MESSAGES_PREVIOUS)
|
)
|
||||||
|
|
||||||
handlers["show_notification_list"] = \
|
handlers["show_notification_list"] = input_event.InputEventHandler(
|
||||||
input_event.InputEventHandler(
|
self._show_notification_list,
|
||||||
self._show_notification_list,
|
cmdnames.NOTIFICATION_MESSAGES_LIST,
|
||||||
cmdnames.NOTIFICATION_MESSAGES_LIST)
|
)
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
|
|
||||||
@@ -130,28 +263,36 @@ class NotificationPresenter:
|
|||||||
"n",
|
"n",
|
||||||
keybindings.defaultModifierMask,
|
keybindings.defaultModifierMask,
|
||||||
keybindings.CTHULHU_MODIFIER_MASK,
|
keybindings.CTHULHU_MODIFIER_MASK,
|
||||||
self._handlers.get("present_last_notification")))
|
self._handlers.get("present_last_notification"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
bindings.add(
|
bindings.add(
|
||||||
keybindings.KeyBinding(
|
keybindings.KeyBinding(
|
||||||
"",
|
"",
|
||||||
keybindings.defaultModifierMask,
|
keybindings.defaultModifierMask,
|
||||||
keybindings.NO_MODIFIER_MASK,
|
keybindings.NO_MODIFIER_MASK,
|
||||||
self._handlers.get("present_next_notification")))
|
self._handlers.get("present_next_notification"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
bindings.add(
|
bindings.add(
|
||||||
keybindings.KeyBinding(
|
keybindings.KeyBinding(
|
||||||
"",
|
"",
|
||||||
keybindings.defaultModifierMask,
|
keybindings.defaultModifierMask,
|
||||||
keybindings.NO_MODIFIER_MASK,
|
keybindings.NO_MODIFIER_MASK,
|
||||||
self._handlers.get("present_previous_notification")))
|
self._handlers.get("present_previous_notification"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
bindings.add(
|
bindings.add(
|
||||||
keybindings.KeyBinding(
|
keybindings.KeyBinding(
|
||||||
"n",
|
"n",
|
||||||
keybindings.defaultModifierMask,
|
keybindings.defaultModifierMask,
|
||||||
keybindings.CTHULHU_CTRL_MODIFIER_MASK,
|
keybindings.CTHULHU_CTRL_MODIFIER_MASK,
|
||||||
self._handlers.get("show_notification_list")))
|
self._handlers.get("show_notification_list"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return bindings
|
return bindings
|
||||||
|
|
||||||
@@ -171,6 +312,9 @@ class NotificationPresenter:
|
|||||||
days = round(diff / 86400)
|
days = round(diff / 86400)
|
||||||
return messages.daysAgo(days)
|
return messages.daysAgo(days)
|
||||||
|
|
||||||
|
def _entry_to_string(self, entry: NotificationEntry) -> str:
|
||||||
|
return f"{entry.message} {self._timestamp_to_string(entry.timestamp)}"
|
||||||
|
|
||||||
def _present_last_notification(self, script, event=None):
|
def _present_last_notification(self, script, event=None):
|
||||||
"""Presents the last notification."""
|
"""Presents the last notification."""
|
||||||
|
|
||||||
@@ -181,9 +325,8 @@ class NotificationPresenter:
|
|||||||
msg = "NOTIFICATION PRESENTER: Presenting last notification."
|
msg = "NOTIFICATION PRESENTER: Presenting last notification."
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
message, timestamp = self._notifications[-1]
|
entry = self._notifications[-1]
|
||||||
string = f"{message} {self._timestamp_to_string(timestamp)}"
|
script.presentMessage(self._entry_to_string(entry))
|
||||||
script.presentMessage(string)
|
|
||||||
self._current_index = -1
|
self._current_index = -1
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -200,23 +343,21 @@ class NotificationPresenter:
|
|||||||
)
|
)
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
# This is the first (oldest) message in the list.
|
if self._current_index == 0:
|
||||||
if self._current_index == 0 :
|
|
||||||
script.presentMessage(messages.NOTIFICATION_LIST_TOP)
|
script.presentMessage(messages.NOTIFICATION_LIST_TOP)
|
||||||
message, timestamp = self._notifications[self._current_index]
|
entry = self._notifications[self._current_index]
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
index = self._current_index - 1
|
index = self._current_index - 1
|
||||||
message, timestamp = self._notifications[index]
|
entry = self._notifications[index]
|
||||||
self._current_index -= 1
|
self._current_index -= 1
|
||||||
except IndexError:
|
except IndexError:
|
||||||
msg = "NOTIFICATION PRESENTER: Handling IndexError exception."
|
msg = "NOTIFICATION PRESENTER: Handling IndexError exception."
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
script.presentMessage(messages.NOTIFICATION_LIST_TOP)
|
script.presentMessage(messages.NOTIFICATION_LIST_TOP)
|
||||||
message, timestamp = self._notifications[self._current_index]
|
entry = self._notifications[self._current_index]
|
||||||
|
|
||||||
string = f"{message} {self._timestamp_to_string(timestamp)}"
|
script.presentMessage(self._entry_to_string(entry))
|
||||||
script.presentMessage(string)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _present_next_notification(self, script, event=None):
|
def _present_next_notification(self, script, event=None):
|
||||||
@@ -232,28 +373,27 @@ class NotificationPresenter:
|
|||||||
)
|
)
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
# This is the last (newest) message in the list.
|
|
||||||
if self._current_index == -1:
|
if self._current_index == -1:
|
||||||
script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM)
|
script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM)
|
||||||
message, timestamp = self._notifications[self._current_index]
|
entry = self._notifications[self._current_index]
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
index = self._current_index + 1
|
index = self._current_index + 1
|
||||||
message, timestamp = self._notifications[index]
|
entry = self._notifications[index]
|
||||||
self._current_index += 1
|
self._current_index += 1
|
||||||
except IndexError:
|
except IndexError:
|
||||||
msg = "NOTIFICATION PRESENTER: Handling IndexError exception."
|
msg = "NOTIFICATION PRESENTER: Handling IndexError exception."
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM)
|
script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM)
|
||||||
message, timestamp = self._notifications[self._current_index]
|
entry = self._notifications[self._current_index]
|
||||||
|
|
||||||
string = f"{message} {self._timestamp_to_string(timestamp)}"
|
script.presentMessage(self._entry_to_string(entry))
|
||||||
script.presentMessage(string)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _show_notification_list(self, script, event=None):
|
def _show_notification_list(self, script, event=None):
|
||||||
"""Opens a dialog with a list of the notifications."""
|
"""Opens a dialog with a list of the notifications."""
|
||||||
|
|
||||||
|
self.refresh_live_notifications()
|
||||||
if not self._notifications:
|
if not self._notifications:
|
||||||
script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
|
script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
|
||||||
return True
|
return True
|
||||||
@@ -261,39 +401,54 @@ class NotificationPresenter:
|
|||||||
msg = "NOTIFICATION PRESENTER: Showing notification list."
|
msg = "NOTIFICATION PRESENTER: Showing notification list."
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
rows = [(message, self._timestamp_to_string(timestamp)) \
|
entries = list(reversed(self._notifications))
|
||||||
for message, timestamp in reversed(self._notifications)]
|
|
||||||
title = guilabels.notifications_count(len(self._notifications))
|
title = guilabels.notifications_count(len(self._notifications))
|
||||||
column_headers = [guilabels.NOTIFICATIONS_COLUMN_HEADER,
|
column_headers = [
|
||||||
guilabels.NOTIFICATIONS_RECEIVED_TIME]
|
guilabels.NOTIFICATIONS_COLUMN_HEADER,
|
||||||
self._gui = NotificationListGUI(script, title, column_headers, rows)
|
guilabels.NOTIFICATIONS_RECEIVED_TIME,
|
||||||
|
]
|
||||||
|
self._gui = NotificationListGUI(self, script, title, column_headers, entries)
|
||||||
self._gui.show_gui()
|
self._gui.show_gui()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def on_dialog_destroyed(self):
|
def on_dialog_destroyed(self):
|
||||||
"""Handler for the 'destroyed' signal of the dialog."""
|
"""Handler for the dialog being destroyed."""
|
||||||
|
|
||||||
self._gui = None
|
self._gui = None
|
||||||
|
|
||||||
|
|
||||||
class NotificationListGUI:
|
class NotificationListGUI:
|
||||||
"""The dialog containing the notifications list."""
|
"""The dialog containing the notifications list."""
|
||||||
|
|
||||||
RESPONSE_COPY = 1
|
RESPONSE_COPY = 1
|
||||||
|
RESPONSE_ACTIONS = 2
|
||||||
|
RESPONSE_DISMISS = 3
|
||||||
|
|
||||||
def __init__(self, script, title, column_headers, rows):
|
def __init__(self, presenter, script, title, column_headers, entries):
|
||||||
|
self._presenter = presenter
|
||||||
self._script = script
|
self._script = script
|
||||||
self._model = None
|
self._model = None
|
||||||
self._tree = None
|
self._tree = None
|
||||||
self._gui = self._create_dialog(title, column_headers, rows)
|
self._selection = None
|
||||||
|
self._dismiss_button = None
|
||||||
|
self._actions_button = None
|
||||||
|
self._gui = self._create_dialog(title, column_headers, entries)
|
||||||
|
|
||||||
def _create_dialog(self, title, column_headers, rows):
|
def _create_dialog(self, title, column_headers, entries):
|
||||||
dialog = Gtk.Dialog(title,
|
dialog = Gtk.Dialog(title, None, Gtk.DialogFlags.MODAL)
|
||||||
None,
|
|
||||||
Gtk.DialogFlags.MODAL,
|
|
||||||
(Gtk.STOCK_COPY, self.RESPONSE_COPY,
|
|
||||||
Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY,
|
|
||||||
Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE))
|
|
||||||
dialog.set_default_size(600, 400)
|
dialog.set_default_size(600, 400)
|
||||||
|
dialog.add_button(Gtk.STOCK_COPY, self.RESPONSE_COPY)
|
||||||
|
self._actions_button = dialog.add_button(
|
||||||
|
guilabels.NOTIFICATIONS_ACTIONS_BUTTON,
|
||||||
|
self.RESPONSE_ACTIONS,
|
||||||
|
)
|
||||||
|
self._dismiss_button = dialog.add_button(
|
||||||
|
guilabels.NOTIFICATIONS_DISMISS_BUTTON,
|
||||||
|
self.RESPONSE_DISMISS,
|
||||||
|
)
|
||||||
|
dialog.add_button(Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY)
|
||||||
|
dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
|
||||||
|
dialog.set_default_response(Gtk.ResponseType.CLOSE)
|
||||||
|
|
||||||
grid = Gtk.Grid()
|
grid = Gtk.Grid()
|
||||||
content_area = dialog.get_content_area()
|
content_area = dialog.get_content_area()
|
||||||
@@ -305,9 +460,12 @@ class NotificationListGUI:
|
|||||||
tree = Gtk.TreeView()
|
tree = Gtk.TreeView()
|
||||||
tree.set_hexpand(True)
|
tree.set_hexpand(True)
|
||||||
tree.set_vexpand(True)
|
tree.set_vexpand(True)
|
||||||
|
tree_accessible = tree.get_accessible()
|
||||||
|
if tree_accessible:
|
||||||
|
tree_accessible.set_name(title)
|
||||||
scrolled_window.add(tree)
|
scrolled_window.add(tree)
|
||||||
|
|
||||||
cols = len(column_headers) * [GObject.TYPE_STRING]
|
cols = (GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_PYOBJECT)
|
||||||
for i, header in enumerate(column_headers):
|
for i, header in enumerate(column_headers):
|
||||||
cell = Gtk.CellRendererText()
|
cell = Gtk.CellRendererText()
|
||||||
column = Gtk.TreeViewColumn(header, cell, text=i)
|
column = Gtk.TreeViewColumn(header, cell, text=i)
|
||||||
@@ -316,18 +474,27 @@ class NotificationListGUI:
|
|||||||
column.set_sort_column_id(i)
|
column.set_sort_column_id(i)
|
||||||
|
|
||||||
self._model = Gtk.ListStore(*cols)
|
self._model = Gtk.ListStore(*cols)
|
||||||
for row in rows:
|
for entry in entries:
|
||||||
row_iter = self._model.append(None)
|
row_iter = self._model.append(None)
|
||||||
for i, cell in enumerate(row):
|
self._model.set_value(row_iter, 0, entry.message)
|
||||||
self._model.set_value(row_iter, i, cell)
|
self._model.set_value(
|
||||||
|
row_iter,
|
||||||
|
1,
|
||||||
|
self._presenter._timestamp_to_string(entry.timestamp),
|
||||||
|
)
|
||||||
|
self._model.set_value(row_iter, 2, entry)
|
||||||
|
|
||||||
tree.set_model(self._model)
|
tree.set_model(self._model)
|
||||||
selection = tree.get_selection()
|
selection = tree.get_selection()
|
||||||
selection.set_mode(Gtk.SelectionMode.SINGLE)
|
selection.set_mode(Gtk.SelectionMode.SINGLE)
|
||||||
|
selection.connect("changed", self._on_selection_changed)
|
||||||
if self._model.iter_n_children(None) > 0:
|
if self._model.iter_n_children(None) > 0:
|
||||||
selection.select_path(0)
|
selection.select_path(0)
|
||||||
|
self._selection = selection
|
||||||
self._tree = tree
|
self._tree = tree
|
||||||
dialog.connect("response", self.on_response)
|
dialog.connect("response", self.on_response)
|
||||||
|
dialog.connect("destroy", self._on_destroy)
|
||||||
|
self._update_action_buttons()
|
||||||
return dialog
|
return dialog
|
||||||
|
|
||||||
def on_response(self, dialog, response):
|
def on_response(self, dialog, response):
|
||||||
@@ -341,9 +508,17 @@ class NotificationListGUI:
|
|||||||
self._copy_selected_notification()
|
self._copy_selected_notification()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if response == self.RESPONSE_ACTIONS:
|
||||||
|
self._show_selected_actions()
|
||||||
|
return
|
||||||
|
|
||||||
|
if response == self.RESPONSE_DISMISS:
|
||||||
|
self._dismiss_selected_notification()
|
||||||
|
return
|
||||||
|
|
||||||
if response == Gtk.ResponseType.APPLY and self._model is not None:
|
if response == Gtk.ResponseType.APPLY and self._model is not None:
|
||||||
self._model.clear()
|
self._model.clear()
|
||||||
getPresenter().clear_list()
|
self._presenter.clear_list()
|
||||||
self._script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
|
self._script.presentMessage(messages.NOTIFICATION_NO_MESSAGES)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
self._gui.destroy()
|
self._gui.destroy()
|
||||||
@@ -357,40 +532,236 @@ class NotificationListGUI:
|
|||||||
time_stamp = Gdk.CURRENT_TIME
|
time_stamp = Gdk.CURRENT_TIME
|
||||||
self._gui.present_with_time(int(time_stamp))
|
self._gui.present_with_time(int(time_stamp))
|
||||||
|
|
||||||
def _copy_selected_notification(self):
|
def _on_destroy(self, widget):
|
||||||
if self._model is None or self._tree is None:
|
self._presenter.on_dialog_destroyed()
|
||||||
return
|
|
||||||
|
|
||||||
selection = self._tree.get_selection()
|
def _on_selection_changed(self, selection):
|
||||||
model, paths = selection.get_selected_rows()
|
self._presenter.refresh_live_notifications()
|
||||||
|
self._update_action_buttons()
|
||||||
|
|
||||||
|
def _get_selected_entry(self) -> Optional[NotificationEntry]:
|
||||||
|
if self._selection is None or self._model is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model, paths = self._selection.get_selected_rows()
|
||||||
if not paths and self._model.iter_n_children(None) > 0:
|
if not paths and self._model.iter_n_children(None) > 0:
|
||||||
selection.select_path(0)
|
self._selection.select_path(0)
|
||||||
model, paths = selection.get_selected_rows()
|
model, paths = self._selection.get_selected_rows()
|
||||||
|
|
||||||
if not paths:
|
if not paths:
|
||||||
return
|
return None
|
||||||
|
|
||||||
iter_for_path = model.get_iter(paths[0])
|
iter_for_path = model.get_iter(paths[0])
|
||||||
if iter_for_path is None:
|
if iter_for_path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return model.get_value(iter_for_path, 2)
|
||||||
|
|
||||||
|
def _update_action_buttons(self) -> None:
|
||||||
|
entry = self._get_selected_entry()
|
||||||
|
can_control = self._presenter.can_control_entry(entry)
|
||||||
|
has_actions = bool(self._presenter.get_actions_for_entry(entry))
|
||||||
|
|
||||||
|
if self._dismiss_button is not None:
|
||||||
|
self._dismiss_button.set_sensitive(can_control)
|
||||||
|
if self._actions_button is not None:
|
||||||
|
self._actions_button.set_sensitive(can_control and has_actions)
|
||||||
|
|
||||||
|
def _copy_selected_notification(self):
|
||||||
|
entry = self._get_selected_entry()
|
||||||
|
if entry is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
message = model.get_value(iter_for_path, 0)
|
timestamp = self._presenter._timestamp_to_string(entry.timestamp)
|
||||||
timestamp = model.get_value(iter_for_path, 1)
|
|
||||||
if timestamp:
|
if timestamp:
|
||||||
text = f"{message}\t{timestamp}"
|
text = f"{entry.message}\t{timestamp}"
|
||||||
else:
|
else:
|
||||||
text = f"{message}"
|
text = f"{entry.message}"
|
||||||
|
|
||||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||||
clipboard.set_text(text, -1)
|
clipboard.set_text(text, -1)
|
||||||
clipboard.store()
|
clipboard.store()
|
||||||
self._script.presentMessage(messages.CLIPBOARD_COPIED_FULL)
|
self._script.presentMessage(messages.CLIPBOARD_COPIED_FULL)
|
||||||
|
|
||||||
|
def _dismiss_selected_notification(self) -> None:
|
||||||
|
self._presenter.refresh_live_notifications()
|
||||||
|
entry = self._get_selected_entry()
|
||||||
|
if not self._presenter.can_control_entry(entry):
|
||||||
|
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
|
||||||
|
self._update_action_buttons()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._presenter.dismiss_entry(self._script, entry):
|
||||||
|
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
|
||||||
|
|
||||||
|
self._update_action_buttons()
|
||||||
|
|
||||||
|
def _show_selected_actions(self) -> None:
|
||||||
|
self._presenter.refresh_live_notifications()
|
||||||
|
entry = self._get_selected_entry()
|
||||||
|
actions = self._presenter.get_actions_for_entry(entry)
|
||||||
|
if not self._presenter.can_control_entry(entry):
|
||||||
|
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
|
||||||
|
self._update_action_buttons()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not actions:
|
||||||
|
self._script.presentMessage(messages.NOTIFICATION_NO_ACTIONS)
|
||||||
|
self._update_action_buttons()
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = NotificationActionsGUI(
|
||||||
|
self._gui,
|
||||||
|
self._script,
|
||||||
|
self._presenter,
|
||||||
|
entry,
|
||||||
|
actions,
|
||||||
|
)
|
||||||
|
dialog.show_gui()
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationActionsGUI:
|
||||||
|
"""Dialog listing live mako actions for a notification."""
|
||||||
|
|
||||||
|
RESPONSE_INVOKE = 1
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: Gtk.Dialog,
|
||||||
|
script: Any,
|
||||||
|
presenter: NotificationPresenter,
|
||||||
|
entry: NotificationEntry,
|
||||||
|
actions: Dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
self._parent = parent
|
||||||
|
self._script = script
|
||||||
|
self._presenter = presenter
|
||||||
|
self._entry = entry
|
||||||
|
self._actions = dict(actions)
|
||||||
|
self._list_box = None
|
||||||
|
self._invoke_button = None
|
||||||
|
self._dialog = self._create_dialog()
|
||||||
|
|
||||||
|
def _create_dialog(self):
|
||||||
|
dialog = Gtk.Dialog(
|
||||||
|
guilabels.NOTIFICATIONS_ACTIONS_TITLE,
|
||||||
|
self._parent,
|
||||||
|
Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
|
||||||
|
)
|
||||||
|
dialog.set_default_size(420, 260)
|
||||||
|
self._invoke_button = dialog.add_button(
|
||||||
|
guilabels.NOTIFICATIONS_INVOKE_ACTION_BUTTON,
|
||||||
|
self.RESPONSE_INVOKE,
|
||||||
|
)
|
||||||
|
dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
|
||||||
|
|
||||||
|
content_area = dialog.get_content_area()
|
||||||
|
scrolled_window = Gtk.ScrolledWindow()
|
||||||
|
scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||||
|
content_area.add(scrolled_window)
|
||||||
|
|
||||||
|
self._list_box = Gtk.ListBox()
|
||||||
|
self._list_box.set_selection_mode(Gtk.SelectionMode.SINGLE)
|
||||||
|
self._list_box.connect("row-activated", self._on_row_activated)
|
||||||
|
self._list_box.connect("selected-rows-changed", self._on_selection_changed)
|
||||||
|
list_accessible = self._list_box.get_accessible()
|
||||||
|
if list_accessible:
|
||||||
|
list_accessible.set_name(guilabels.NOTIFICATIONS_ACTIONS_TITLE)
|
||||||
|
scrolled_window.add(self._list_box)
|
||||||
|
|
||||||
|
for action_key, label_text in self._actions.items():
|
||||||
|
row = Gtk.ListBoxRow()
|
||||||
|
row._action_key = action_key # type: ignore[attr-defined]
|
||||||
|
label = Gtk.Label(label=label_text or action_key, xalign=0)
|
||||||
|
label.set_margin_start(10)
|
||||||
|
label.set_margin_end(10)
|
||||||
|
label.set_margin_top(6)
|
||||||
|
label.set_margin_bottom(6)
|
||||||
|
row.add(label)
|
||||||
|
self._list_box.add(row)
|
||||||
|
|
||||||
|
first_row = self._list_box.get_row_at_index(0)
|
||||||
|
if first_row is not None:
|
||||||
|
self._list_box.select_row(first_row)
|
||||||
|
|
||||||
|
dialog.connect("response", self._on_response)
|
||||||
|
self._update_invoke_button()
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
def show_gui(self) -> None:
|
||||||
|
self._dialog.show_all()
|
||||||
|
time_stamp = Gtk.get_current_event_time()
|
||||||
|
if not time_stamp or time_stamp > 0xFFFFFFFF:
|
||||||
|
time_stamp = Gdk.CURRENT_TIME
|
||||||
|
self._dialog.present_with_time(int(time_stamp))
|
||||||
|
|
||||||
|
def _on_response(self, dialog, response) -> None:
|
||||||
|
if response == Gtk.ResponseType.CLOSE:
|
||||||
|
self._dialog.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
if response == self.RESPONSE_INVOKE:
|
||||||
|
self._invoke_selected_action()
|
||||||
|
|
||||||
|
def _on_row_activated(self, list_box, row) -> None:
|
||||||
|
self._invoke_selected_action()
|
||||||
|
|
||||||
|
def _on_selection_changed(self, list_box) -> None:
|
||||||
|
self._update_invoke_button()
|
||||||
|
|
||||||
|
def _get_selected_action_key(self) -> Optional[str]:
|
||||||
|
if self._list_box is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
row = self._list_box.get_selected_row()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return getattr(row, "_action_key", None)
|
||||||
|
|
||||||
|
def _update_invoke_button(self) -> None:
|
||||||
|
if self._invoke_button is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._invoke_button.set_sensitive(self._get_selected_action_key() is not None)
|
||||||
|
|
||||||
|
def _invoke_selected_action(self) -> None:
|
||||||
|
self._presenter.refresh_live_notifications()
|
||||||
|
action_key = self._get_selected_action_key()
|
||||||
|
if not action_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._presenter.can_control_entry(self._entry):
|
||||||
|
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
|
||||||
|
self._dialog.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._presenter.invoke_action_for_entry(self._script, self._entry, action_key):
|
||||||
|
self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE)
|
||||||
|
|
||||||
|
self._dialog.destroy()
|
||||||
|
|
||||||
|
|
||||||
_presenter = None
|
_presenter = None
|
||||||
|
|
||||||
|
|
||||||
def getPresenter():
|
def getPresenter():
|
||||||
"""Returns the Notification Presenter"""
|
"""Returns the Notification Presenter."""
|
||||||
|
|
||||||
global _presenter
|
global _presenter
|
||||||
if _presenter is None:
|
if _presenter is None:
|
||||||
_presenter = NotificationPresenter()
|
_presenter = NotificationPresenter()
|
||||||
|
|
||||||
|
if _presenter._mako_monitor is None:
|
||||||
|
try:
|
||||||
|
from . import cthulhu
|
||||||
|
|
||||||
|
app = getattr(cthulhu, "cthulhuApp", None)
|
||||||
|
if app is not None:
|
||||||
|
monitor = getattr(app, "makoNotificationMonitor", None)
|
||||||
|
if monitor is not None:
|
||||||
|
_presenter.set_mako_monitor(monitor)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return _presenter
|
return _presenter
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_ROOT = REPO_ROOT / "src"
|
||||||
|
BUILD_PACKAGE_ROOT = REPO_ROOT / "_build" / "src" / "cthulhu"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_generated_module(module_name):
|
||||||
|
module_path = BUILD_PACKAGE_ROOT / f"{module_name}.py"
|
||||||
|
full_name = f"cthulhu.{module_name}"
|
||||||
|
|
||||||
|
if module_path.exists():
|
||||||
|
spec = importlib.util.spec_from_file_location(full_name, module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[full_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
module = types.ModuleType(full_name)
|
||||||
|
if module_name == "cthulhu_i18n":
|
||||||
|
module._ = lambda text: text
|
||||||
|
module.ngettext = lambda singular, plural, count: singular if count == 1 else plural
|
||||||
|
module.cgettext = lambda text: text
|
||||||
|
module.C_ = lambda _context, text: text
|
||||||
|
module.localedir = str(REPO_ROOT / "po")
|
||||||
|
module.setModuleLocale = lambda *_args, **_kwargs: None
|
||||||
|
module.setLocaleForMessages = lambda *_args, **_kwargs: None
|
||||||
|
module.setLocaleForNames = lambda *_args, **_kwargs: None
|
||||||
|
module.setLocaleForGUI = lambda *_args, **_kwargs: None
|
||||||
|
return module
|
||||||
|
|
||||||
|
if module_name == "cthulhu_platform":
|
||||||
|
from cthulhu import cthulhuVersion
|
||||||
|
|
||||||
|
module.version = (
|
||||||
|
f"Cthulhu screen reader version "
|
||||||
|
f"{cthulhuVersion.version}-{cthulhuVersion.codeName}"
|
||||||
|
)
|
||||||
|
module.revision = ""
|
||||||
|
module.prefix = str(REPO_ROOT)
|
||||||
|
module.package = "cthulhu"
|
||||||
|
module.datadir = str(REPO_ROOT)
|
||||||
|
module.tablesdir = "/usr/share/liblouis/tables"
|
||||||
|
return module
|
||||||
|
|
||||||
|
raise ImportError(f"Unsupported generated module: {module_name}")
|
||||||
|
|
||||||
|
|
||||||
|
if str(SRC_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
import cthulhu # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
for generated_module in ("cthulhu_i18n", "cthulhu_platform"):
|
||||||
|
if f"cthulhu.{generated_module}" in sys.modules:
|
||||||
|
continue
|
||||||
|
|
||||||
|
loaded_module = _load_generated_module(generated_module)
|
||||||
|
sys.modules[f"cthulhu.{generated_module}"] = loaded_module
|
||||||
|
setattr(cthulhu, generated_module, loaded_module)
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||||
|
|
||||||
|
from cthulhu import notification_presenter
|
||||||
|
from cthulhu.mako_notification_monitor import MakoNotificationMonitor
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScript:
|
||||||
|
def __init__(self):
|
||||||
|
self.speechGenerator = mock.Mock()
|
||||||
|
self.speechGenerator.voice.return_value = object()
|
||||||
|
self.speakMessage = mock.Mock()
|
||||||
|
self.displayBrailleMessage = mock.Mock()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScriptManager:
|
||||||
|
def __init__(self, script):
|
||||||
|
self._script = script
|
||||||
|
|
||||||
|
def get_default_script(self):
|
||||||
|
return self._script
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeApp:
|
||||||
|
def __init__(self, script):
|
||||||
|
self._script_manager = _FakeScriptManager(script)
|
||||||
|
|
||||||
|
def getScriptManager(self):
|
||||||
|
return self._script_manager
|
||||||
|
|
||||||
|
|
||||||
|
class MakoNotificationMonitorTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.presenter = notification_presenter.NotificationPresenter()
|
||||||
|
self.script = _FakeScript()
|
||||||
|
self.app = _FakeApp(self.script)
|
||||||
|
|
||||||
|
presenter_patcher = mock.patch(
|
||||||
|
"cthulhu.mako_notification_monitor.notification_presenter.getPresenter",
|
||||||
|
return_value=self.presenter,
|
||||||
|
)
|
||||||
|
self.addCleanup(presenter_patcher.stop)
|
||||||
|
presenter_patcher.start()
|
||||||
|
|
||||||
|
self.monitor = MakoNotificationMonitor(self.app)
|
||||||
|
self.monitor._generation = 1
|
||||||
|
|
||||||
|
def test_initial_seed_does_not_announce_existing_notifications(self):
|
||||||
|
self.monitor._sync_notifications(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"app-name": "discord",
|
||||||
|
"summary": "hello",
|
||||||
|
"body": "world",
|
||||||
|
"actions": {"default": "View"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
announce_new=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.monitor._known_ids, {10})
|
||||||
|
self.assertEqual(self.presenter._notifications, [])
|
||||||
|
self.script.speakMessage.assert_not_called()
|
||||||
|
self.script.displayBrailleMessage.assert_not_called()
|
||||||
|
|
||||||
|
def test_new_notification_is_spoken_and_saved(self):
|
||||||
|
self.monitor._sync_notifications(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"app-name": "discord",
|
||||||
|
"summary": "old",
|
||||||
|
"body": "",
|
||||||
|
"actions": {},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
announce_new=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.monitor._sync_notifications(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"app-name": "discord",
|
||||||
|
"summary": "old",
|
||||||
|
"body": "",
|
||||||
|
"actions": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"app-name": "discord",
|
||||||
|
"summary": "new summary",
|
||||||
|
"body": "new body",
|
||||||
|
"actions": {"default": "View"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
announce_new=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.presenter._notifications), 1)
|
||||||
|
entry = self.presenter._notifications[0]
|
||||||
|
self.assertEqual(entry.notification_id, 11)
|
||||||
|
self.assertTrue(entry.live)
|
||||||
|
self.assertEqual(entry.source, "mako")
|
||||||
|
self.assertEqual(entry.actions, {"default": "View"})
|
||||||
|
self.assertEqual(entry.message, "Notification new summary new body")
|
||||||
|
self.script.speakMessage.assert_called_once()
|
||||||
|
self.script.displayBrailleMessage.assert_called_once()
|
||||||
|
|
||||||
|
def test_removed_notification_is_marked_not_live(self):
|
||||||
|
self.monitor._sync_notifications(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"app-name": "discord",
|
||||||
|
"summary": "new summary",
|
||||||
|
"body": "new body",
|
||||||
|
"actions": {"default": "View"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
announce_new=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(self.presenter._notifications[0].live)
|
||||||
|
|
||||||
|
self.monitor._sync_notifications([], announce_new=False)
|
||||||
|
|
||||||
|
self.assertFalse(self.presenter._notifications[0].live)
|
||||||
|
self.assertEqual(self.monitor._known_ids, set())
|
||||||
|
|
||||||
|
def test_parse_notification_strips_markup_and_normalizes_actions(self):
|
||||||
|
parsed = self.monitor._parse_notification(
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"app-name": "Crash Reporting System",
|
||||||
|
"summary": "<b>Service Crash</b>",
|
||||||
|
"body": "<html><tt>/usr/bin/python3.14</tt> crashed</html>",
|
||||||
|
"desktop-entry": "crash-handler",
|
||||||
|
"urgency": 2,
|
||||||
|
"actions": {"1": "<b>Details</b>"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(parsed["notification_id"], 22)
|
||||||
|
self.assertEqual(parsed["summary"], "Service Crash")
|
||||||
|
self.assertEqual(parsed["body"], "/usr/bin/python3.14 crashed")
|
||||||
|
self.assertEqual(parsed["actions"], {"1": "Details"})
|
||||||
|
self.assertEqual(
|
||||||
|
parsed["message"],
|
||||||
|
"Notification Service Crash /usr/bin/python3.14 crashed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||||
|
|
||||||
|
from cthulhu import messages
|
||||||
|
from cthulhu.notification_presenter import NotificationPresenter
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMonitor:
|
||||||
|
def __init__(self):
|
||||||
|
self.dismissed = []
|
||||||
|
self.invoked = []
|
||||||
|
self.current_generation = 2
|
||||||
|
|
||||||
|
def refresh(self, announce_new=False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_current_entry(self, entry):
|
||||||
|
return entry.source == "mako" and entry.source_generation == self.current_generation
|
||||||
|
|
||||||
|
def dismiss_notification(self, notification_id):
|
||||||
|
self.dismissed.append(notification_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def invoke_action(self, notification_id, action_key):
|
||||||
|
self.invoked.append((notification_id, action_key))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPresenterMakoTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.presenter = NotificationPresenter()
|
||||||
|
self.monitor = _FakeMonitor()
|
||||||
|
self.presenter.set_mako_monitor(self.monitor)
|
||||||
|
|
||||||
|
def test_sync_updates_current_generation_only(self):
|
||||||
|
old_entry = self.presenter.save_notification(
|
||||||
|
"Notification old",
|
||||||
|
source="mako",
|
||||||
|
source_generation=1,
|
||||||
|
notification_id=5,
|
||||||
|
live=False,
|
||||||
|
actions={"default": "Old"},
|
||||||
|
)
|
||||||
|
current_entry = self.presenter.save_notification(
|
||||||
|
"Notification current",
|
||||||
|
source="mako",
|
||||||
|
source_generation=2,
|
||||||
|
notification_id=6,
|
||||||
|
live=True,
|
||||||
|
actions={"default": "View"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.presenter.sync_live_notifications(
|
||||||
|
"mako",
|
||||||
|
{
|
||||||
|
5: {
|
||||||
|
"message": "Notification replaced",
|
||||||
|
"actions": {"default": "New"},
|
||||||
|
"app_name": "discord",
|
||||||
|
"summary": "replaced",
|
||||||
|
"body": "",
|
||||||
|
"urgency": 1,
|
||||||
|
"desktop_entry": "discord",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
source_generation=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(old_entry.message, "Notification old")
|
||||||
|
self.assertEqual(old_entry.actions, {"default": "Old"})
|
||||||
|
self.assertFalse(current_entry.live)
|
||||||
|
|
||||||
|
def test_can_control_entry_and_get_actions_require_current_monitor_generation(self):
|
||||||
|
entry = self.presenter.save_notification(
|
||||||
|
"Notification current",
|
||||||
|
source="mako",
|
||||||
|
source_generation=2,
|
||||||
|
notification_id=6,
|
||||||
|
live=True,
|
||||||
|
actions={"default": "View"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(self.presenter.can_control_entry(entry))
|
||||||
|
self.assertEqual(self.presenter.get_actions_for_entry(entry), {"default": "View"})
|
||||||
|
|
||||||
|
stale_entry = self.presenter.save_notification(
|
||||||
|
"Notification stale",
|
||||||
|
source="mako",
|
||||||
|
source_generation=1,
|
||||||
|
notification_id=7,
|
||||||
|
live=True,
|
||||||
|
actions={"default": "Open"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(self.presenter.can_control_entry(stale_entry))
|
||||||
|
self.assertEqual(self.presenter.get_actions_for_entry(stale_entry), {})
|
||||||
|
|
||||||
|
def test_dismiss_and_invoke_action_route_through_monitor(self):
|
||||||
|
entry = self.presenter.save_notification(
|
||||||
|
"Notification current",
|
||||||
|
source="mako",
|
||||||
|
source_generation=2,
|
||||||
|
notification_id=6,
|
||||||
|
live=True,
|
||||||
|
actions={"default": "View"},
|
||||||
|
)
|
||||||
|
script = mock.Mock()
|
||||||
|
|
||||||
|
self.assertTrue(self.presenter.dismiss_entry(script, entry))
|
||||||
|
self.assertEqual(self.monitor.dismissed, [6])
|
||||||
|
script.presentMessage.assert_called_with(messages.NOTIFICATION_DISMISSED)
|
||||||
|
|
||||||
|
script.reset_mock()
|
||||||
|
self.assertTrue(self.presenter.invoke_action_for_entry(script, entry, "default"))
|
||||||
|
self.assertEqual(self.monitor.invoked, [(6, "default")])
|
||||||
|
script.presentMessage.assert_called_with(messages.NOTIFICATION_ACTION_INVOKED)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user