From 4f2a91055ebfa93abe8f48f818b9c87867270d0e Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sun, 2 Aug 2026 19:41:22 -0700 Subject: [PATCH] feat(tray): allow disabling missing tray warning dialog (#628) * feat(tray): allow disabling missing tray warning dialog Fixes #620 * test(tray): stabilize missing-watcher opt-out coverage Default the D-Bus ListNames mock to include StatusNotifierWatcher so TrayIndicator construction no longer opens the warning dialog during setUp, and bind the don't-show-again checkbox test to the module Gtk mock. * test(tray): make missing-watcher opt-out tests deterministic --- install.sh | 15 ++++--- src/vocalinux/ui/config_manager.py | 1 + src/vocalinux/ui/settings_dialog.py | 27 ++++++++++++- src/vocalinux/ui/tray_indicator.py | 17 ++++++-- tests/test_config_manager.py | 8 ++++ tests/test_settings_dialog.py | 10 +++++ tests/test_tray_indicator.py | 63 +++++++++++++++++++++++++++++ 7 files changed, 131 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index b123769..9161791 100755 --- a/install.sh +++ b/install.sh @@ -2806,7 +2806,8 @@ install_whispercpp_with_gpu_support() { }, "ui": { "start_minimized": false, - "show_notifications": true + "show_notifications": true, + "show_missing_tray_warning": true }, "advanced": { "debug_logging": false, @@ -2981,7 +2982,8 @@ PY }, "ui": { "start_minimized": false, - "show_notifications": true + "show_notifications": true, + "show_missing_tray_warning": true }, "advanced": { "debug_logging": false, @@ -3033,7 +3035,8 @@ WHISPER_CONFIG }, "ui": { "start_minimized": false, - "show_notifications": true + "show_notifications": true, + "show_missing_tray_warning": true }, "advanced": { "debug_logging": false, @@ -3073,7 +3076,8 @@ FALLBACK_CONFIG }, "ui": { "start_minimized": false, - "show_notifications": true + "show_notifications": true, + "show_missing_tray_warning": true }, "advanced": { "debug_logging": false, @@ -3135,7 +3139,8 @@ VOSK_CONFIG }, "ui": { "start_minimized": false, - "show_notifications": true + "show_notifications": true, + "show_missing_tray_warning": true }, "advanced": { "debug_logging": false, diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 3897c4f..e9be88f 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -54,6 +54,7 @@ DEFAULT_CONFIG = { "ui": { "start_minimized": False, "show_notifications": True, + "show_missing_tray_warning": True, }, "general": { "autostart": False, diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 403e2d9..9f4cae2 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -1498,8 +1498,8 @@ class SettingsDialog(Gtk.Dialog): self.audio_device_combo.connect("changed", self._on_audio_device_changed) def _build_general_section(self): - """Build the Application page: startup behavior.""" - group = PreferencesGroup(title="Startup") + """Build the Application page: general behavior.""" + group = PreferencesGroup(title="General") self.autostart_switch = Gtk.Switch() autostart_row = PreferenceRow( @@ -1519,10 +1519,20 @@ class SettingsDialog(Gtk.Dialog): ) group.add_row(start_minimized_row) + self.missing_tray_warning_switch = Gtk.Switch() + missing_tray_warning_row = PreferenceRow( + title="Warn if tray support is not detected", + subtitle="Show a warning when Vocalinux cannot detect AppIndicator support", + widget=self.missing_tray_warning_switch, + keywords=("tray", "appindicator", "warning"), + ) + group.add_row(missing_tray_warning_row) + self.general_tab.pack_start(group, False, False, 0) self.autostart_switch.connect("state-set", self._on_autostart_toggled) self.start_minimized_switch.connect("state-set", self._on_start_minimized_toggled) + self.missing_tray_warning_switch.connect("state-set", self._on_missing_tray_warning_toggled) def _build_auto_pause_section(self): """Build Auto-Pause settings: enable toggle + process name list.""" @@ -1898,6 +1908,17 @@ class SettingsDialog(Gtk.Dialog): logger.info(f"Start minimized {'enabled' if enabled else 'disabled'}") return False + def _on_missing_tray_warning_toggled(self, widget, state): + """Handle toggle of the missing tray support warning switch.""" + if self._initializing or self._applying_settings: + return False + + enabled = bool(state) + logger.info(f"Missing tray warning toggled: {enabled}") + self.config_manager.set("ui", "show_missing_tray_warning", enabled) + self.config_manager.save_settings() + return False + def _on_copy_to_clipboard_toggled(self, widget, state): """Handle toggle of the copy to clipboard switch.""" if self._initializing or self._applying_settings: @@ -3195,12 +3216,14 @@ class SettingsDialog(Gtk.Dialog): autostart_enabled = general_settings.get("autostart", False) start_minimized = ui_settings.get("start_minimized", False) + show_missing_tray_warning = ui_settings.get("show_missing_tray_warning", True) copy_to_clipboard = text_injection_settings.get("copy_to_clipboard", False) auto_capitalize = text_injection_settings.get("auto_capitalize", True) append_trailing_space = text_injection_settings.get("append_trailing_space", True) self.autostart_switch.set_active(autostart_enabled) self.start_minimized_switch.set_active(start_minimized) + self.missing_tray_warning_switch.set_active(show_missing_tray_warning) self.copy_to_clipboard_switch.set_active(copy_to_clipboard) self.auto_capitalize_switch.set_active(auto_capitalize) self.append_trailing_space_switch.set_active(append_trailing_space) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index c225266..62e8360 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -262,7 +262,8 @@ class TrayIndicator: "No StatusNotifierWatcher on D-Bus session bus; tray icon may not appear. " "On GNOME, install gnome-shell-extension-appindicator." ) - GLib.idle_add(self._show_missing_watcher_dialog) + if self.config_manager.get_bool("ui", "show_missing_tray_warning", True): + GLib.idle_add(self._show_missing_watcher_dialog) # Create the menu self.menu = Gtk.Menu() @@ -339,8 +340,18 @@ class TrayIndicator: "\n" "Keyboard shortcuts will still work even without the tray icon." ) - dialog.connect("response", lambda d, _: d.destroy()) - dialog.show() + dont_show_again = Gtk.CheckButton(label="Don't show again") + dont_show_again.set_margin_top(8) + dialog.get_message_area().pack_start(dont_show_again, False, False, 0) + + def on_response(d, _response): + if dont_show_again.get_active(): + self.config_manager.set("ui", "show_missing_tray_warning", False) + self.config_manager.save_settings() + d.destroy() + + dialog.connect("response", on_response) + dialog.show_all() return False def _show_appindicator_error_dialog(self, error_detail: str): diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index e9bc797..958aebf 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -110,6 +110,9 @@ class TestConfigManager(unittest.TestCase): ) # From defaults self.assertEqual(config_manager.config["ui"]["start_minimized"], True) self.assertEqual(config_manager.config["ui"]["show_notifications"], True) # From defaults + self.assertEqual( + config_manager.config["ui"]["show_missing_tray_warning"], True + ) # From defaults def test_load_config_file_error(self): """Test handling of errors when loading config file.""" @@ -436,6 +439,11 @@ class TestConfigManager(unittest.TestCase): config_manager = ConfigManager() self.assertTrue(config_manager.is_sound_effects_enabled()) + def test_missing_tray_warning_enabled_by_default(self): + """Test that the missing tray support warning is enabled by default.""" + config_manager = ConfigManager() + self.assertTrue(config_manager.get_bool("ui", "show_missing_tray_warning", False)) + def test_set_sound_effects_enabled(self): """Test setting sound effects enabled state.""" config_manager = ConfigManager() diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 7a02fe9..426a066 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -823,6 +823,16 @@ class TestSettingsNavigation(unittest.TestCase): ]: self.assertIn(f'SettingsPage("{name}", "{title}"', self.source_code) + def test_application_page_has_tray_warning_toggle(self): + self.assertIn('PreferencesGroup(title="General")', self.source_code) + self.assertIn("self.missing_tray_warning_switch = Gtk.Switch()", self.source_code) + self.assertIn('title="Warn if tray support is not detected"', self.source_code) + self.assertIn('"show_missing_tray_warning", enabled', self.source_code) + self.assertIn( + 'ui_settings.get("show_missing_tray_warning", True)', + self.source_code, + ) + def test_status_controls_live_in_sidebar_footer(self): """Status, mic level, test, and Close live in the sidebar footer so they stay visible from every page without a bottom strip.""" diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 6f0f745..2724dbf 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -107,6 +107,8 @@ class TestTrayIndicator(unittest.TestCase): self.mock_speech_engine.state = RecognitionState.IDLE self.mock_text_injector = MagicMock() self.mock_config_manager = MagicMock() + self.mock_config_manager.get_bool.side_effect = lambda section, key, default=False: default + self.mock_config_manager.get_str.side_effect = lambda section, key, default="": default # Patch os path functions self.patcher_path_exists = patch("os.path.exists", return_value=True) @@ -131,6 +133,17 @@ class TestTrayIndicator(unittest.TestCase): self.mock_config_manager_class = self.patcher_config_manager.start() self.mock_config_manager_class.return_value = self.mock_config_manager + # Default D-Bus ListNames to include StatusNotifierWatcher so constructing + # TrayIndicator (which idle_add's _init_indicator) does not open the + # missing-watcher dialog during setUp. Individual tests can override. + self.patcher_dbus_proxy = patch( + "vocalinux.ui.tray_indicator.Gio.DBusProxy.new_for_bus_sync" + ) + self.mock_dbus_proxy_factory = self.patcher_dbus_proxy.start() + mock_proxy = MagicMock() + mock_proxy.call_sync.return_value.unpack.return_value = (["org.kde.StatusNotifierWatcher"],) + self.mock_dbus_proxy_factory.return_value = mock_proxy + # Import and create TrayIndicator from vocalinux.ui.tray_indicator import TrayIndicator @@ -146,6 +159,7 @@ class TestTrayIndicator(unittest.TestCase): self.patcher_listdir.stop() self.patcher_makedirs.stop() self.patcher_config_manager.stop() + self.patcher_dbus_proxy.stop() self.patcher_settings_dialog.stop() self.thread_patcher.stop() self.ksm_patcher.stop() @@ -498,6 +512,55 @@ class TestTrayIndicator(unittest.TestCase): self.assertEqual(result, False) mock_dialog.assert_called_once() + def test_init_indicator_missing_watcher_respects_opt_out(self): + get_bool = MagicMock( + side_effect=lambda section, key, default=False: ( + False if (section, key) == ("ui", "show_missing_tray_warning") else default + ) + ) + self.tray_indicator.config_manager.get_bool = get_bool + scheduled = [] + + def record_idle(func, *args): + scheduled.append(func) + return False # do not execute callbacks + + with patch.object( + self.tray_indicator, + "_check_status_notifier_watcher", + return_value=False, + ): + with patch("vocalinux.ui.tray_indicator.GLib.idle_add", side_effect=record_idle): + result = self.tray_indicator._init_indicator() + + self.assertEqual(result, False) + get_bool.assert_any_call("ui", "show_missing_tray_warning", True) + self.assertNotIn(self.tray_indicator._show_missing_watcher_dialog, scheduled) + + def test_missing_watcher_dialog_dont_show_again_saves_opt_out(self): + mock_dialog = MagicMock() + mock_checkbox = MagicMock() + mock_checkbox.get_active.return_value = True + config = MagicMock() + self.tray_indicator.config_manager = config + + with patch("vocalinux.ui.tray_indicator.Gtk.MessageDialog", return_value=mock_dialog): + with patch("vocalinux.ui.tray_indicator.Gtk.CheckButton", return_value=mock_checkbox): + result = self.tray_indicator._show_missing_watcher_dialog() + + self.assertEqual(result, False) + mock_dialog.get_message_area.return_value.pack_start.assert_called_once_with( + mock_checkbox, False, False, 0 + ) + mock_dialog.show_all.assert_called_once() + + response_callback = mock_dialog.connect.call_args[0][1] + response_callback(mock_dialog, None) + + config.set.assert_called_once_with("ui", "show_missing_tray_warning", False) + config.save_settings.assert_called_once() + mock_dialog.destroy.assert_called_once() + def test_init_indicator_creation_failure_shows_error_dialog(self): with patch( "vocalinux.ui.tray_indicator.AppIndicator3.Indicator.new_with_path",