diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index d479043..adbf52a 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -147,6 +147,8 @@ class TrayIndicator: # Must exist before _init_indicator: tests run idle_add synchronously. self._pending_update: Optional[ReleaseInfo] = None self._update_menu_item = None + self._settings_dialog: Optional[SettingsDialog] = None + self._logging_dialog: Optional[Gtk.Dialog] = None # Initialize the indicator (in the GTK main thread) GLib.idle_add(self._init_indicator) @@ -568,6 +570,17 @@ class TrayIndicator: def _show_settings_page(self, page_name: Optional[str]): """Open settings, optionally focused on a specific sidebar page.""" + dialog = self._settings_dialog + if dialog is not None: + try: + if page_name: + dialog.navigate_to_page(page_name) + self._present_window(dialog) + return + except Exception: + logger.debug("Existing settings dialog is gone, opening a new one") + self._settings_dialog = None + dialog = SettingsDialog( parent=None, config_manager=self.config_manager, @@ -581,8 +594,20 @@ class TrayIndicator: ), ) dialog.connect("response", self._on_settings_dialog_response) + dialog.connect("destroy", self._on_settings_dialog_destroyed) + self._settings_dialog = dialog dialog.show() + def _present_window(self, window: Gtk.Window) -> None: + """Raise an already-open window so a second tray click does not spawn another.""" + window.deiconify() + window.present_with_time(Gtk.get_current_event_time()) + + def _on_settings_dialog_destroyed(self, dialog, *_args) -> None: + """Drop the settings dialog reference after it is closed.""" + if self._settings_dialog is dialog: + self._settings_dialog = None + def _get_update_channel(self) -> str: """Return the configured release channel for background update checks.""" return self.config_manager.get_str("updates", "channel", "stable") @@ -660,13 +685,28 @@ class TrayIndicator: """Handle click on the View Logs menu item.""" logger.debug("View Logs clicked") + dialog = self._logging_dialog + if dialog is not None: + try: + self._present_window(dialog) + return + except Exception: + logger.debug("Existing logs dialog is gone, opening a new one") + self._logging_dialog = None + # Import here to avoid circular imports from .logging_dialog import LoggingDialog - # Create and show the logging dialog dialog = LoggingDialog(parent=None) + dialog.connect("destroy", self._on_logging_dialog_destroyed) + self._logging_dialog = dialog dialog.show() + def _on_logging_dialog_destroyed(self, dialog, *_args) -> None: + """Drop the logs dialog reference after it is closed.""" + if self._logging_dialog is dialog: + self._logging_dialog = None + def _on_settings_dialog_response(self, dialog, response): """Handle responses from the settings dialog.""" # With auto-apply, we just close the dialog on any response diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 0d6ccc3..cd141f9 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -264,6 +264,54 @@ class TestTrayIndicator(unittest.TestCase): mock_dialog_class.assert_called_once() mock_dialog_instance.show.assert_called_once() + def test_settings_reuses_open_dialog(self): + """A second Settings click focuses the existing window instead of duplicating it.""" + import vocalinux.ui.tray_indicator as tray_module + + mock_dialog_instance = MagicMock() + mock_dialog_class = MagicMock(return_value=mock_dialog_instance) + + with patch.object(tray_module, "SettingsDialog", mock_dialog_class): + self.tray_indicator._on_settings_clicked(None) + self.tray_indicator._on_settings_clicked(None) + + mock_dialog_class.assert_called_once() + mock_dialog_instance.show.assert_called_once() + mock_dialog_instance.present_with_time.assert_called_once() + mock_dialog_instance.navigate_to_page.assert_not_called() + + def test_about_reuses_open_settings_dialog(self): + """About focuses the existing Settings window and switches to the About page.""" + import vocalinux.ui.tray_indicator as tray_module + + mock_dialog_instance = MagicMock() + mock_dialog_class = MagicMock(return_value=mock_dialog_instance) + + with patch.object(tray_module, "SettingsDialog", mock_dialog_class): + self.tray_indicator._on_settings_clicked(None) + self.tray_indicator._on_about_clicked(None) + + mock_dialog_class.assert_called_once() + mock_dialog_instance.navigate_to_page.assert_called_once_with("about") + mock_dialog_instance.present_with_time.assert_called_once() + + def test_settings_opens_new_dialog_after_close(self): + """Closing Settings allows a later click to open a fresh dialog.""" + import vocalinux.ui.tray_indicator as tray_module + + first_dialog = MagicMock() + second_dialog = MagicMock() + mock_dialog_class = MagicMock(side_effect=[first_dialog, second_dialog]) + + with patch.object(tray_module, "SettingsDialog", mock_dialog_class): + self.tray_indicator._on_settings_clicked(None) + self.tray_indicator._on_settings_dialog_destroyed(first_dialog) + self.tray_indicator._on_settings_clicked(None) + + self.assertEqual(mock_dialog_class.call_count, 2) + second_dialog.show.assert_called_once() + first_dialog.present_with_time.assert_not_called() + def test_about_dialog(self): """Test About opens Settings focused on the About page.""" with patch("vocalinux.ui.tray_indicator.SettingsDialog") as mock_dialog_class: @@ -558,6 +606,22 @@ class TestTrayIndicator(unittest.TestCase): mock_logging_dialog_class.assert_called_once_with(parent=None) mock_dialog.show.assert_called_once() + mock_dialog.connect.assert_called() + + def test_logs_reuses_open_dialog(self): + """A second View Logs click focuses the existing window instead of duplicating it.""" + mock_dialog = MagicMock() + mock_logging_dialog_class = MagicMock(return_value=mock_dialog) + mock_logging_module = MagicMock() + mock_logging_module.LoggingDialog = mock_logging_dialog_class + + with patch.dict(sys.modules, {"vocalinux.ui.logging_dialog": mock_logging_module}): + self.tray_indicator._on_logs_clicked(None) + self.tray_indicator._on_logs_clicked(None) + + mock_logging_dialog_class.assert_called_once_with(parent=None) + mock_dialog.show.assert_called_once() + mock_dialog.present_with_time.assert_called_once() def test_on_settings_dialog_response_close(self): """Test settings dialog response handler for CLOSE."""