fix(tray): reuse Settings and Logs windows instead of duplicating them (#669)

A second tray click on Settings, About, or View Logs now focuses the
existing window instead of opening another copy.

Fixes #653
This commit is contained in:
Jatin K Malik
2026-08-12 23:14:43 -07:00
committed by GitHub
parent 50e9200a1e
commit 73ae0def31
2 changed files with 105 additions and 1 deletions
+41 -1
View File
@@ -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
+64
View File
@@ -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."""