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
This commit is contained in:
Jatin K Malik
2026-08-02 19:41:22 -07:00
committed by GitHub
parent 2152dbb90d
commit 4f2a91055e
7 changed files with 131 additions and 10 deletions
+10 -5
View File
@@ -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,
+1
View File
@@ -54,6 +54,7 @@ DEFAULT_CONFIG = {
"ui": {
"start_minimized": False,
"show_notifications": True,
"show_missing_tray_warning": True,
},
"general": {
"autostart": False,
+25 -2
View File
@@ -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)
+14 -3
View File
@@ -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):
+8
View File
@@ -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()
+10
View File
@@ -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."""
+63
View File
@@ -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",