diff --git a/src/cthulhu/notification_presenter.py b/src/cthulhu/notification_presenter.py index 59f74ac..205ad21 100644 --- a/src/cthulhu/notification_presenter.py +++ b/src/cthulhu/notification_presenter.py @@ -421,8 +421,7 @@ class NotificationListGUI: """The dialog containing the notifications list.""" RESPONSE_COPY = 1 - RESPONSE_ACTIONS = 2 - RESPONSE_DISMISS = 3 + RESPONSE_DISMISS = 2 def __init__(self, presenter, script, title, column_headers, entries): self._presenter = presenter @@ -431,17 +430,14 @@ class NotificationListGUI: self._tree = None self._selection = None self._dismiss_button = None - self._actions_button = None + self._actions_box = None + self._actions_status_label = None self._gui = self._create_dialog(title, column_headers, entries) def _create_dialog(self, title, column_headers, entries): dialog = Gtk.Dialog(title, None, Gtk.DialogFlags.MODAL) 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, @@ -451,10 +447,13 @@ class NotificationListGUI: dialog.set_default_response(Gtk.ResponseType.CLOSE) grid = Gtk.Grid() + grid.set_row_spacing(12) content_area = dialog.get_content_area() content_area.add(grid) scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_hexpand(True) + scrolled_window.set_vexpand(True) grid.add(scrolled_window) tree = Gtk.TreeView() @@ -492,6 +491,29 @@ class NotificationListGUI: selection.select_path(0) self._selection = selection self._tree = tree + + actions_frame = Gtk.Frame(label=guilabels.NOTIFICATIONS_ACTIONS_TITLE) + actions_frame.set_label_align(0.0, 0.5) + actions_accessible = actions_frame.get_accessible() + if actions_accessible: + actions_accessible.set_name(guilabels.NOTIFICATIONS_ACTIONS_TITLE) + grid.attach_next_to(actions_frame, scrolled_window, Gtk.PositionType.BOTTOM, 1, 1) + + actions_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + actions_content.set_margin_top(6) + actions_content.set_margin_bottom(6) + actions_content.set_margin_start(6) + actions_content.set_margin_end(6) + actions_frame.add(actions_content) + + self._actions_status_label = Gtk.Label(xalign=0) + self._actions_status_label.set_line_wrap(True) + self._actions_status_label.set_no_show_all(True) + actions_content.pack_start(self._actions_status_label, False, False, 0) + + self._actions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + actions_content.pack_start(self._actions_box, False, False, 0) + dialog.connect("response", self.on_response) dialog.connect("destroy", self._on_destroy) self._update_action_buttons() @@ -508,10 +530,6 @@ class NotificationListGUI: self._copy_selected_notification() return - if response == self.RESPONSE_ACTIONS: - self._show_selected_actions() - return - if response == self.RESPONSE_DISMISS: self._dismiss_selected_notification() return @@ -560,12 +578,70 @@ class NotificationListGUI: 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)) + actions = 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) + + self._clear_inline_action_buttons() + + if not can_control: + self._set_inline_action_status(messages.NOTIFICATION_UNAVAILABLE) + return + + if not actions: + self._set_inline_action_status(messages.NOTIFICATION_NO_ACTIONS) + return + + self._set_inline_action_status("") + for action_key, label_text in actions.items(): + self._add_inline_action_button(entry, action_key, label_text) + + def _clear_inline_action_buttons(self) -> None: + if self._actions_box is None: + return + + for child in self._actions_box.get_children(): + self._actions_box.remove(child) + + def _set_inline_action_status(self, text: str) -> None: + if self._actions_status_label is None: + return + + self._actions_status_label.set_text(text) + self._actions_status_label.set_visible(bool(text)) + + def _add_inline_action_button( + self, + entry: Optional[NotificationEntry], + action_key: str, + label_text: str, + ) -> None: + if self._actions_box is None: + return + + button = Gtk.Button(label=label_text or action_key) + button.connect("clicked", self._on_inline_action_clicked, entry, action_key) + self._actions_box.pack_start(button, False, False, 0) + self._actions_box.show_all() + + def _on_inline_action_clicked( + self, + button: Gtk.Button, + entry: Optional[NotificationEntry], + action_key: str, + ) -> None: + del button + self._presenter.refresh_live_notifications() + if not self._presenter.can_control_entry(entry): + self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE) + self._update_action_buttons() + return + + if not self._presenter.invoke_action_for_entry(self._script, entry, action_key): + self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE) + + self._update_action_buttons() def _copy_selected_notification(self): entry = self._get_selected_entry() @@ -596,151 +672,6 @@ class NotificationListGUI: 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 diff --git a/tests/test_notification_presenter_mako_regressions.py b/tests/test_notification_presenter_mako_regressions.py index 05f8571..427a2ed 100644 --- a/tests/test_notification_presenter_mako_regressions.py +++ b/tests/test_notification_presenter_mako_regressions.py @@ -5,6 +5,7 @@ from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from cthulhu import notification_presenter from cthulhu import messages from cthulhu.notification_presenter import NotificationPresenter @@ -30,6 +31,56 @@ class _FakeMonitor: return True +class _FakeButton: + def __init__(self, label=None): + self.label = label + self.sensitive = True + self._signals = {} + + def connect(self, signal_name, callback, *args): + self._signals[signal_name] = (callback, args) + + def set_sensitive(self, value): + self.sensitive = value + + def set_margin_top(self, _value): + return None + + def click(self): + callback, args = self._signals["clicked"] + callback(self, *args) + + +class _FakeBox: + def __init__(self): + self.children = [] + + def get_children(self): + return list(self.children) + + def remove(self, child): + self.children.remove(child) + + def pack_start(self, child, expand, fill, padding): + del expand, fill, padding + self.children.append(child) + + def show_all(self): + return None + + +class _FakeLabel: + def __init__(self): + self.text = "" + self.visible = False + + def set_text(self, value): + self.text = value + + def set_visible(self, value): + self.visible = value + + class NotificationPresenterMakoTests(unittest.TestCase): def setUp(self): self.presenter = NotificationPresenter() @@ -119,6 +170,114 @@ class NotificationPresenterMakoTests(unittest.TestCase): self.assertEqual(self.monitor.invoked, [(6, "default")]) script.presentMessage.assert_called_with(messages.NOTIFICATION_ACTION_INVOKED) + def test_notification_list_builds_inline_action_buttons_in_reported_order(self): + entry = self.presenter.save_notification( + "Notification current", + source="mako", + source_generation=2, + notification_id=6, + live=True, + actions={"default": "View", "snooze": "Snooze"}, + ) + + gui = notification_presenter.NotificationListGUI.__new__( + notification_presenter.NotificationListGUI + ) + gui._script = mock.Mock() + gui._presenter = mock.Mock() + gui._presenter.can_control_entry.return_value = True + gui._presenter.get_actions_for_entry.return_value = { + "default": "View", + "snooze": "Snooze", + } + gui._presenter.invoke_action_for_entry.return_value = True + gui._dismiss_button = _FakeButton() + gui._actions_box = _FakeBox() + gui._actions_status_label = _FakeLabel() + gui._get_selected_entry = mock.Mock(return_value=entry) + + with mock.patch.object(notification_presenter.Gtk, "Button", _FakeButton): + gui._update_action_buttons() + self.assertTrue(gui._dismiss_button.sensitive) + self.assertEqual( + [button.label for button in gui._actions_box.children], + ["View", "Snooze"], + ) + gui._actions_box.children[1].click() + + gui._presenter.invoke_action_for_entry.assert_called_once_with( + gui._script, + entry, + "snooze", + ) + + def test_notification_list_clears_inline_actions_when_notification_is_unavailable(self): + entry = self.presenter.save_notification( + "Notification stale", + source="mako", + source_generation=1, + notification_id=7, + live=True, + actions={"default": "Open"}, + ) + + gui = notification_presenter.NotificationListGUI.__new__( + notification_presenter.NotificationListGUI + ) + gui._script = mock.Mock() + gui._presenter = mock.Mock() + gui._presenter.can_control_entry.return_value = False + gui._presenter.get_actions_for_entry.return_value = {} + gui._dismiss_button = _FakeButton() + gui._actions_box = _FakeBox() + gui._actions_box.children.append(_FakeButton("Old")) + gui._actions_status_label = _FakeLabel() + gui._get_selected_entry = mock.Mock(return_value=entry) + + with mock.patch.object(notification_presenter.Gtk, "Button", _FakeButton): + gui._update_action_buttons() + + self.assertFalse(gui._dismiss_button.sensitive) + self.assertEqual(gui._actions_box.children, []) + self.assertEqual( + gui._actions_status_label.text, + messages.NOTIFICATION_UNAVAILABLE, + ) + self.assertTrue(gui._actions_status_label.visible) + + def test_notification_list_shows_no_actions_state_for_live_notification_without_actions(self): + entry = self.presenter.save_notification( + "Notification current", + source="mako", + source_generation=2, + notification_id=8, + live=True, + actions={}, + ) + + gui = notification_presenter.NotificationListGUI.__new__( + notification_presenter.NotificationListGUI + ) + gui._script = mock.Mock() + gui._presenter = mock.Mock() + gui._presenter.can_control_entry.return_value = True + gui._presenter.get_actions_for_entry.return_value = {} + gui._dismiss_button = _FakeButton() + gui._actions_box = _FakeBox() + gui._actions_status_label = _FakeLabel() + gui._get_selected_entry = mock.Mock(return_value=entry) + + with mock.patch.object(notification_presenter.Gtk, "Button", _FakeButton): + gui._update_action_buttons() + + self.assertTrue(gui._dismiss_button.sensitive) + self.assertEqual(gui._actions_box.children, []) + self.assertEqual( + gui._actions_status_label.text, + messages.NOTIFICATION_NO_ACTIONS, + ) + self.assertTrue(gui._actions_status_label.visible) + if __name__ == "__main__": unittest.main()