From d29c884782be463dc6d5231e035342a01024160a Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:43:56 -0700 Subject: [PATCH] fix(settings): gray out Dictation Tone while sound effects are off (#877) * fix(settings): gray out Dictation Tone while sound effects are off The Enable Sound Effects switch mutes every dictation cue, so the tone picker changed nothing while it was off. The row now goes insensitive with the switch and follows the state loaded from config, matching the keep-alive timeout and auto-pause sensitivity pattern. Also bump the pre-commit isort pin 5.13.2 -> 8.0.1 to match uv.lock: the two versions disagree on single-name import wrapping, so the old hook collapsed imports the locked isort (and CI) then expanded back. Fixes #849 * test(settings): cover tone-row sensitivity on settings load --------- Co-authored-by: Jatin K Malik --- .pre-commit-config.yaml | 2 +- src/vocalinux/ui/settings_dialog.py | 11 ++- tests/test_sound_effects_sensitivity.py | 97 +++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 tests/test_sound_effects_sensitivity.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 152b885..0b7058b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: # Import sorting with isort (black-compatible) - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 8.0.1 hooks: - id: isort args: ["--profile", "black"] diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 62bc4a1..81a3c4c 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -3524,11 +3524,16 @@ class SettingsDialog(Gtk.Dialog): logger.info(f"Paste shortcut set to {self.config_manager.get_paste_shortcut()}") return False + def _update_sound_effects_sensitivity(self, enabled: bool) -> None: + """Gray out the tone picker while sound effects are disabled.""" + self.tone_row.set_sensitive(enabled) + def _on_sound_effects_toggled(self, widget, state): + enabled = bool(state) + self._update_sound_effects_sensitivity(enabled) if self._initializing or self._applying_settings: return False - enabled = bool(state) logger.info(f"Sound effects toggled: {enabled}") self.config_manager.set_sound_effects_enabled(enabled) self.config_manager.save_settings() @@ -5994,7 +5999,9 @@ class SettingsDialog(Gtk.Dialog): self.append_trailing_space_switch.set_active(append_trailing_space) if not self.paste_shortcut_combo.set_active_id(paste_shortcut): self.paste_shortcut_combo.set_active_id(DEFAULT_PASTE_SHORTCUT) - self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled()) + sound_effects_enabled = self.config_manager.is_sound_effects_enabled() + self.sound_effects_switch.set_active(sound_effects_enabled) + self._update_sound_effects_sensitivity(sound_effects_enabled) duck_enabled = self.config_manager.is_playback_duck_enabled() self.duck_level_scale.set_value(self.config_manager.get_playback_duck_percent()) self.duck_playback_switch.set_active(duck_enabled) diff --git a/tests/test_sound_effects_sensitivity.py b/tests/test_sound_effects_sensitivity.py new file mode 100644 index 0000000..28230a4 --- /dev/null +++ b/tests/test_sound_effects_sensitivity.py @@ -0,0 +1,97 @@ +"""The Dictation Tone row is grayed out while sound effects are off (#849).""" + +from __future__ import annotations + +import importlib +import sys +from collections.abc import Iterator +from typing import Any +from unittest.mock import MagicMock, Mock, patch + +import pytest + +import vocalinux.ui + + +@pytest.fixture(scope="module") +def settings_dialog() -> Iterator[Any]: + """Import settings_dialog with real base classes for its GTK subclasses. + + Same approach as tests/test_follow_keyboard_layout.py; both sys.modules and + the package attribute are restored so later tests patching the module by + name do not reach a second module object (#686). + """ + repository = sys.modules["gi.repository"] + bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")} + saved_module = sys.modules.pop("vocalinux.ui.settings_dialog", None) + saved_attribute = getattr(vocalinux.ui, "settings_dialog", None) + try: + with patch.object(repository, "Gtk", MagicMock(**bases)): + module = importlib.import_module("vocalinux.ui.settings_dialog") + yield module + finally: + sys.modules.pop("vocalinux.ui.settings_dialog", None) + if saved_module is not None: + sys.modules["vocalinux.ui.settings_dialog"] = saved_module + if saved_attribute is not None: + vocalinux.ui.settings_dialog = saved_attribute + elif hasattr(vocalinux.ui, "settings_dialog"): + del vocalinux.ui.settings_dialog + + +@pytest.fixture +def dialog_class(settings_dialog: Any) -> type[Any]: + return settings_dialog.SettingsDialog + + +def _dialog_stub(dialog_class: type[Any], initializing: bool = False) -> Mock: + """A Mock dialog whose self-calls reach the real sensitivity helper.""" + dialog = Mock() + dialog._initializing = initializing + dialog._applying_settings = False + dialog._update_sound_effects_sensitivity = ( + lambda enabled: dialog_class._update_sound_effects_sensitivity(dialog, enabled) + ) + return dialog + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_toggling_sound_effects_sets_tone_row_sensitivity( + dialog_class: type[Any], enabled: bool +) -> None: + dialog = _dialog_stub(dialog_class) + + dialog_class._on_sound_effects_toggled(dialog, Mock(), enabled) + + dialog.tone_row.set_sensitive.assert_called_once_with(enabled) + dialog.config_manager.set_sound_effects_enabled.assert_called_once_with(enabled) + + +def test_sensitivity_still_updates_while_settings_load(dialog_class: type[Any]) -> None: + """set_active() during load must sync the row even though saving is skipped.""" + dialog = _dialog_stub(dialog_class, initializing=True) + + dialog_class._on_sound_effects_toggled(dialog, Mock(), False) + + dialog.tone_row.set_sensitive.assert_called_once_with(False) + dialog.config_manager.set_sound_effects_enabled.assert_not_called() + + +def test_loading_settings_syncs_tone_row_sensitivity( + settings_dialog: Any, dialog_class: type[Any] +) -> None: + """A saved sound-effects=off state must gray out the tone row on load.""" + dialog = _dialog_stub(dialog_class) + dialog._get_current_settings.return_value = { + "engine": "whisper_cpp", + "language": "en", + "model_size": "base", + } + dialog.config_manager.get_settings.return_value = {} + dialog.config_manager.is_sound_effects_enabled.return_value = False + dialog.engine_combo.get_model.return_value = [["whisper.cpp"]] + + with patch.object(settings_dialog, "get_available_engines", return_value={"whisper_cpp": True}): + dialog_class._load_and_apply_settings(dialog) + + dialog.tone_row.set_sensitive.assert_called_once_with(False)