diff --git a/src/cthulhu/notification_presenter.py b/src/cthulhu/notification_presenter.py index 205ad21..7eaf451 100644 --- a/src/cthulhu/notification_presenter.py +++ b/src/cthulhu/notification_presenter.py @@ -139,6 +139,32 @@ class NotificationPresenter: self._notifications = [] self._current_index = -1 + def remove_entry(self, entry: Optional[NotificationEntry]) -> bool: + """Removes entry from the notification history.""" + + if entry is None: + return False + + try: + index = self._notifications.index(entry) + except ValueError: + return False + + del self._notifications[index] + if not self._notifications: + self._current_index = -1 + return True + + if self._current_index == -1: + return True + + if index < self._current_index: + self._current_index -= 1 + elif index == self._current_index and self._current_index >= len(self._notifications): + self._current_index = -1 + + return True + def refresh_live_notifications(self) -> bool: """Refreshes live mako state without announcing new notifications.""" @@ -207,6 +233,8 @@ class NotificationPresenter: result = self._mako_monitor.dismiss_notification(entry.notification_id) if result: + entry.live = False + self.remove_entry(entry) script.presentMessage(messages.NOTIFICATION_DISMISSED) return result @@ -669,9 +697,50 @@ class NotificationListGUI: if not self._presenter.dismiss_entry(self._script, entry): self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE) + else: + self._remove_selected_row() self._update_action_buttons() + def _remove_selected_row(self) -> None: + if self._selection is None or self._model is None: + return + + model, paths = self._selection.get_selected_rows() + if not paths: + return + + row_iter = model.get_iter(paths[0]) + if row_iter is None: + return + + model.remove(row_iter) + if self._model.iter_n_children(None) == 0: + return + + row_index = self._path_to_index(paths[0]) + if row_index is None: + self._selection.select_path(0) + return + + next_index = min(row_index, self._model.iter_n_children(None) - 1) + self._selection.select_path(next_index) + + def _path_to_index(self, path: Any) -> Optional[int]: + if isinstance(path, int): + return path + + get_indices = getattr(path, "get_indices", None) + if callable(get_indices): + indices = get_indices() + if indices: + return indices[0] + + try: + return path[0] + except (TypeError, IndexError, KeyError): + return None + _presenter = None diff --git a/tests/test_notification_presenter_mako_regressions.py b/tests/test_notification_presenter_mako_regressions.py index 427a2ed..b66ae35 100644 --- a/tests/test_notification_presenter_mako_regressions.py +++ b/tests/test_notification_presenter_mako_regressions.py @@ -81,6 +81,41 @@ class _FakeLabel: self.visible = value +class _FakeListStore: + def __init__(self, rows=None): + self.rows = list(rows or []) + + def iter_n_children(self, _parent): + return len(self.rows) + + def get_iter(self, path): + index = path if isinstance(path, int) else path[0] + if 0 <= index < len(self.rows): + return index + return None + + def get_value(self, row_iter, column): + return self.rows[row_iter][column] + + def remove(self, row_iter): + del self.rows[row_iter] + return row_iter < len(self.rows) + + +class _FakeSelection: + def __init__(self, model=None, selected_path=0): + self.model = model + self.selected_path = selected_path + + def get_selected_rows(self): + if self.selected_path is None: + return self.model, [] + return self.model, [self.selected_path] + + def select_path(self, path): + self.selected_path = path + + class NotificationPresenterMakoTests(unittest.TestCase): def setUp(self): self.presenter = NotificationPresenter() @@ -163,13 +198,104 @@ class NotificationPresenterMakoTests(unittest.TestCase): self.assertTrue(self.presenter.dismiss_entry(script, entry)) self.assertEqual(self.monitor.dismissed, [6]) + self.assertEqual(self.presenter._notifications, []) script.presentMessage.assert_called_with(messages.NOTIFICATION_DISMISSED) script.reset_mock() + entry = self.presenter.save_notification( + "Notification current", + source="mako", + source_generation=2, + notification_id=6, + live=True, + actions={"default": "View"}, + ) 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) + def test_notification_list_dismiss_removes_selected_entry_from_history_and_model(self): + entry = self.presenter.save_notification( + "Notification current", + source="mako", + source_generation=2, + notification_id=6, + live=True, + actions={"default": "View"}, + ) + remaining = self.presenter.save_notification( + "Notification stale", + source="mako", + source_generation=1, + notification_id=7, + live=False, + actions={}, + ) + + gui = notification_presenter.NotificationListGUI.__new__( + notification_presenter.NotificationListGUI + ) + gui._script = mock.Mock() + gui._presenter = self.presenter + gui._dismiss_button = _FakeButton() + gui._actions_box = _FakeBox() + gui._actions_status_label = _FakeLabel() + gui._model = _FakeListStore( + [ + [entry.message, "now", entry], + [remaining.message, "later", remaining], + ] + ) + gui._selection = _FakeSelection(model=gui._model, selected_path=0) + gui._update_action_buttons = mock.Mock() + + gui._dismiss_selected_notification() + + self.assertEqual(self.monitor.dismissed, [6]) + self.assertEqual(self.presenter._notifications, [remaining]) + self.assertEqual(gui._model.rows, [[remaining.message, "later", remaining]]) + gui._script.presentMessage.assert_called_with(messages.NOTIFICATION_DISMISSED) + + def test_notification_list_dismiss_reselects_remaining_row_after_removing_last_selection(self): + remaining = self.presenter.save_notification( + "Notification stale", + source="mako", + source_generation=1, + notification_id=7, + live=False, + actions={}, + ) + entry = self.presenter.save_notification( + "Notification current", + source="mako", + source_generation=2, + notification_id=6, + live=True, + actions={"default": "View"}, + ) + + gui = notification_presenter.NotificationListGUI.__new__( + notification_presenter.NotificationListGUI + ) + gui._script = mock.Mock() + gui._presenter = self.presenter + gui._dismiss_button = _FakeButton() + gui._actions_box = _FakeBox() + gui._actions_status_label = _FakeLabel() + gui._model = _FakeListStore( + [ + [remaining.message, "later", remaining], + [entry.message, "now", entry], + ] + ) + gui._selection = _FakeSelection(model=gui._model, selected_path=1) + gui._update_action_buttons = mock.Mock() + + gui._dismiss_selected_notification() + + self.assertEqual(gui._model.rows, [[remaining.message, "later", remaining]]) + self.assertEqual(gui._selection.selected_path, 0) + def test_notification_list_builds_inline_action_buttons_in_reported_order(self): entry = self.presenter.save_notification( "Notification current",