diff --git a/blueman/gui/Notification.py b/blueman/gui/Notification.py index 8fc7c04a1..d1d046072 100644 --- a/blueman/gui/Notification.py +++ b/blueman/gui/Notification.py @@ -1,57 +1,43 @@ from collections.abc import Callable, Iterable +from gettext import gettext as _ +from typing import NamedTuple import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") from gi.repository import Gtk -from gi.repository import Gdk from gi.repository import GdkPixbuf from gi.repository import GLib from gi.repository import Gio -from blueman.gui.GtkAnimation import AnimBase import logging -OPACITY_START = 0.7 - -class Fade(AnimBase): - def __init__(self, window: Gtk.Window) -> None: - super().__init__(state=OPACITY_START) - self.window = window - - def state_changed(self, state: float) -> None: - self.window.props.opacity = state +class NotificationAction(NamedTuple): + id: str + name: str + callback: Callable[[str], None] | None = None class _NotificationDialog(Gtk.MessageDialog): def __init__(self, summary: str, message: str, _timeout: int = -1, _transient: bool = False, - actions: Iterable[tuple[str, str]] | None = None, - actions_cb: Callable[[str], None] | None = None, icon_name: str | None = None, + actions: Iterable[NotificationAction] | None = None, icon_name: str | None = None, image_data: GdkPixbuf.Pixbuf | None = None) -> None: super().__init__(parent=None, type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.NONE, text=None) self.set_name("NotificationDialog") - i = 100 self.actions_supported = True - self.actions: dict[int, str] = {} - self.callback = actions_cb - if actions: - for a in actions: - action_id = a[0] - action_name = a[1] + self.actions: dict[int, NotificationAction] = {} - self.actions[i] = action_id - self.add_button(action_name, i) - i += 1 + if actions is None: + actions = [NotificationAction("close", _("_Close"), lambda s: self.close())] - self.actions[Gtk.ResponseType.DELETE_EVENT] = "close" + for action in actions: + self.add_action(action) self.props.secondary_use_markup = True self.resize(350, 50) - self.fader = Fade(self) - self.props.skip_taskbar_hint = False self.props.title = summary @@ -65,47 +51,38 @@ def __init__(self, summary: str, message: str, _timeout: int = -1, _transient: b elif image_data: self.set_icon_from_pixbuf(image_data) - self.connect("response", self.dialog_response) self.props.icon_name = "blueman" - self.entered = False - - def on_enter(_widget: "_NotificationDialog", _event: Gdk.Event) -> bool: - if self.get_window() == Gdk.Window.at_pointer()[0] or not self.entered: - self.fader.animate(start=self.fader.get_state(), end=1.0, duration=500) - self.entered = True - return False - - def on_leave(_widget: "_NotificationDialog", _event: Gdk.Event) -> bool: - if not Gdk.Window.at_pointer(): - self.entered = False - self.fader.animate(start=self.fader.get_state(), end=OPACITY_START, duration=500) - return False - - self.connect("enter-notify-event", on_enter) - self.connect("leave-notify-event", on_leave) - def set_message(self, message: str) -> None: self.props.secondary_text = message def set_notification_icon(self, icon_name: str) -> None: self.set_icon_from_icon_name(icon_name, 48) - def dialog_response(self, _dialog: Gtk.Dialog, response: int) -> None: - if self.callback: - self.callback(self.actions[response]) - self.hide() + def do_response(self, response: int) -> None: + action = self.actions.pop(response, None) + if action is None: + logging.error(f"Unhandled response {response}") + return + + if action.callback is not None: + action.callback(action.id) + self.destroy() def show(self) -> None: - self.set_opacity(OPACITY_START) self.present() - self.set_opacity(OPACITY_START) def close(self) -> None: - self.hide() + self.destroy() - def add_action(self, _action_id: str, _label: str, _callback: Callable[[str], None] | None = None) -> None: - logging.warning("stub") + def add_action(self, action: NotificationAction) -> None: + if not self.actions: + response_id = 100 + else: + response_id = max(self.actions.keys()) + 1 + + self.actions[response_id] = action + self.add_button(action.name, response_id) def set_icon_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf) -> None: im = Gtk.Image.new_from_pixbuf(pixbuf) @@ -120,8 +97,7 @@ def set_icon_from_icon_name(self, icon_name: str, size: int) -> None: class _NotificationBubble(Gio.DBusProxy): def __init__(self, summary: str, message: str, timeout: int = -1, transient: bool = False, - actions: Iterable[tuple[str, str]] | None = None, - actions_cb: Callable[[str], None] | None = None, icon_name: str | None = None, + actions: Iterable[NotificationAction] | None = None, icon_name: str | None = None, image_data: GdkPixbuf.Pixbuf | None = None) -> None: super().__init__( g_name='org.freedesktop.Notifications', @@ -134,8 +110,8 @@ def __init__(self, summary: str, message: str, timeout: int = -1, transient: boo self._app_name = 'blueman' self._app_icon = '' - self._actions: list[str] = [] - self._callbacks: dict[str, Callable[[str], None]] = {} + self._actions: dict[str, NotificationAction] = {} + self._notification_actions: list[str] = [] self._hints: dict[str, GLib.Variant] = {} # hint : (variant format, spec version) @@ -190,9 +166,9 @@ def __init__(self, summary: str, message: str, timeout: int = -1, transient: boo self.set_hint(key, (w, h, stride, alpha, bits, channel, data)) - if actions: + if actions is not None: for action in actions: - self.add_action(action[0], action[1], actions_cb) + self.add_action(action) self._capabilities = self.GetCapabilities() @@ -231,14 +207,13 @@ def remove_hint(self, key: str) -> None: def clear_hints(self) -> None: self._hints = {} - def add_action(self, action_id: str, label: str, callback: Callable[[str], None] | None = None) -> None: - self._actions.extend([action_id, label]) - if callback: - self._callbacks[action_id] = callback + def add_action(self, action: NotificationAction) -> None: + self._actions[action.id] = action + self._notification_actions.extend([action.id, action.name]) def clear_actions(self) -> None: - self._actions = [] - self._callbacks = {} + self._actions.clear() + self._notification_actions.clear() def do_g_signal(self, _sender_name: str, signal_name: str, params: GLib.Variant) -> None: notif_id, signal_val = params.unpack() @@ -257,13 +232,18 @@ def do_g_signal(self, _sender_name: str, signal_name: str, params: GLib.Variant) elif signal_val == 4: logging.debug('Undefined/reserved reasons.') elif signal_name == 'ActionInvoked': - if signal_val in self._callbacks: - self._callbacks[signal_val](signal_val) + action = self._actions.pop(signal_val, None) + if action is None: + logging.error(f"Unhandled action {signal_val}") + return + + if action.callback is not None: + action.callback(action.id) def show(self) -> None: replace_id = self._return_id if self._return_id else 0 return_id = self.Notify('(susssasa{sv}i)', self._app_name, replace_id, self._app_icon, - self._summary, self._body, self._actions, self._hints, + self._summary, self._body, self._notification_actions, self._hints, self._timeout) self._return_id = return_id @@ -278,8 +258,7 @@ def Notification( message: str, timeout: int = -1, transient: bool = False, - actions: Iterable[tuple[str, str]] | None = None, - actions_cb: Callable[[str], None] | None = None, + actions: Iterable[NotificationAction] | None = None, icon_name: str | None = None, image_data: GdkPixbuf.Pixbuf | None = None ) -> _NotificationBubble | _NotificationDialog: @@ -302,4 +281,4 @@ def Notification( else: klass = _NotificationBubble - return klass(summary, message, timeout, transient, actions, actions_cb, icon_name, image_data) + return klass(summary, message, timeout, transient, actions, icon_name, image_data) diff --git a/blueman/main/applet/BluezAgent.py b/blueman/main/applet/BluezAgent.py index 93a9c56f6..f158d86fb 100644 --- a/blueman/main/applet/BluezAgent.py +++ b/blueman/main/applet/BluezAgent.py @@ -9,7 +9,7 @@ from blueman.bluez.Device import Device from blueman.bluez.AgentManager import AgentManager from blueman.Sdp import ServiceUUID -from blueman.gui.Notification import Notification, _NotificationBubble, _NotificationDialog +from blueman.gui.Notification import Notification, _NotificationBubble, _NotificationDialog, NotificationAction from blueman.main.Builder import Builder from blueman.main.DbusService import DbusService, DbusError @@ -227,10 +227,13 @@ def on_confirm_action(action: str) -> None: if passkey: notify_message += "\n" + _("Confirm value for authentication:") + f" {passkey:06}" - actions = [("confirm", _("Confirm")), ("deny", _("Deny"))] + actions = [ + NotificationAction("confirm", _("Confirm"), on_confirm_action), + NotificationAction("deny", _("Deny"), on_confirm_action) + ] - self._notification = Notification("Bluetooth", notify_message, 0, - actions=actions, actions_cb=on_confirm_action, icon_name="blueman") + self._notification = Notification("Bluetooth", notify_message, 0, actions=actions, + icon_name="blueman") self._notification.show() def _on_request_authorization(self, object_path: ObjectPath, ok: Callable[[], None], @@ -256,11 +259,11 @@ def on_auth_action(action: str) -> None: service = ServiceUUID(uuid).name notify_message = \ _("Authorization request for:") + f"\n{dev_str}\n" + _("Service:") + f" {service}" - actions = [("always", _("Always accept")), - ("accept", _("Accept")), - ("deny", _("Deny"))] + actions = [ + NotificationAction("always", _("Always accept"), on_auth_action), + NotificationAction("accept", _("Accept"), on_auth_action), + NotificationAction("deny", _("Deny"), on_auth_action)] - n = Notification(_("Bluetooth Authentication"), notify_message, 0, - actions=actions, actions_cb=on_auth_action, icon_name="blueman") + n = Notification(_("Bluetooth Authentication"), notify_message, 0, actions=actions, icon_name="blueman") n.show() self._service_notifications.append(n) diff --git a/blueman/plugins/applet/TransferService.py b/blueman/plugins/applet/TransferService.py index 29a47769b..114538487 100644 --- a/blueman/plugins/applet/TransferService.py +++ b/blueman/plugins/applet/TransferService.py @@ -13,7 +13,7 @@ from blueman.bluez.obex.Transfer import Transfer from blueman.bluez.obex.Session import Session from blueman.Functions import launch -from blueman.gui.Notification import Notification, _NotificationBubble, _NotificationDialog +from blueman.gui.Notification import Notification, _NotificationBubble, _NotificationDialog, NotificationAction from blueman.main.Applet import BluemanApplet from blueman.main.DbusService import DbusService, DbusError from blueman.plugins.AppletPlugin import AppletPlugin @@ -124,12 +124,17 @@ def _remove() -> bool: # This device was neither allowed nor is it trusted -> ask for confirmation if address not in self._allowed_devices and not (self._config['opp-accept'] and trusted): + actions = [ + NotificationAction("accept", _("Accept"), on_action), + NotificationAction("reject", _("Reject"), on_action) + ] self._notification = notification = Notification( _("Incoming file over Bluetooth"), _("Incoming file %(0)s from %(1)s") % {"0": "" + escape(filename) + "", "1": "" + escape(name) + ""}, 30000, - actions=[("accept", _("Accept")), ("reject", _("Reject"))], actions_cb=on_action, icon_name="blueman" + actions=actions, + icon_name="blueman" ) notification.show() # Device is trusted or was already allowed, larger file -> display a notification, but auto-accept @@ -178,12 +183,12 @@ def on_reset(_action: str) -> None: share_path, invalid_share_path = self._make_share_path() if invalid_share_path: + action = NotificationAction("reset", _("Reset to default"), on_reset) text = _('Configured directory for incoming files does not exist') secondary_text = _('Please make sure that directory "%s" exists or ' 'configure it with blueman-services. Until then the default "%s" will be used') self._notification = Notification(text, secondary_text % (self._config["shared-path"], share_path), - icon_name='blueman', timeout=30000, - actions=[('reset', 'Reset to default')], actions_cb=on_reset) + icon_name='blueman', timeout=30000, actions=[action]) self._notification.show() self._watch = Manager.watch_name_owner(self._on_dbus_name_appeared, self._on_dbus_name_vanished) @@ -280,7 +285,8 @@ def on_open(_action: str) -> None: logging.info("open") launch("xdg-open", paths=[path.as_posix()], system=True) - n.add_action("open", name, on_open) + action = NotificationAction("open", name, on_open) + n.add_action(action) def _on_transfer_completed(self, _manager: Manager, transfer_path: ObjectPath, success: bool) -> None: if not self._agent or transfer_path not in self._agent.transfers: