feat(settings): searchable sidebar settings instead of seven tabs (#601)

* feat(settings): replace tab navigation with searchable sidebar and topic pages

- Sidebar (icon + label) with Gtk.Stack replaces the 7-tab notebook
- Live settings search (Ctrl+F): filters rows in place, per-page match
  counts, dims pages without hits, empty-results state
- Topic-based pages per GNOME HIG: Dictation, Speech Model, Audio,
  Performance, Application, Advanced
- Persistent bottom status strip: recognition state, shared mic level
  bar, and Test Dictation with inline transcription output
- GPU device selection moves out of the power-user gate onto the
  Performance page; Reset to Defaults moves into the Advanced page
- Consolidated model info card (info + warning + legend in one surface)
- Contextual custom-shortcut row, in-list auto-pause empty state,
  uniform control widths, aligned group descriptions
- Esc clears search, Ctrl+W closes

* test(settings): update source-inspection tests for sidebar redesign, add search coverage

* fix(settings): index unlocked advanced controls in search

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* fix(settings): restore selected page after clearing search

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
Jatin K Malik
2026-07-27 14:31:39 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 053fc173fa
commit e7022f83dd
3 changed files with 729 additions and 366 deletions
File diff suppressed because it is too large Load Diff
+157 -14
View File
@@ -413,12 +413,10 @@ class TestSettingsDialogInstantApply(unittest.TestCase):
self.assertIn("def _on_reset_advanced_clicked(self, widget):", source_code)
self.assertIn('defaults = DEFAULT_CONFIG["advanced"]', source_code)
self.assertIn("self.advanced_reset_button", source_code)
self.assertIn(
"action_area.set_child_secondary(self.advanced_reset_button, True)", source_code
)
self.assertNotIn("reset_box.pack_start(self.advanced_reset_button", source_code)
def test_advanced_reset_button_is_contextual_footer_action(self):
def test_advanced_reset_button_is_in_page_action(self):
"""Reset to Defaults lives inside the Advanced page (gated with the
controls it resets), not in a footer that changes per page."""
import os
source_path = os.path.join(
@@ -432,15 +430,11 @@ class TestSettingsDialogInstantApply(unittest.TestCase):
with open(source_path, "r") as f:
source_code = f.read()
self.assertIn("self.advanced_page_num = notebook.append_page", source_code)
self.assertIn(
'notebook.connect("switch-page", self._on_settings_page_switched)', source_code
)
self.assertIn("def _update_advanced_reset_button_visibility", source_code)
self.assertIn(
"self.advanced_reset_button.set_visible(page_num == self.advanced_page_num)",
source_code,
)
self.assertIn("reset_row.pack_start(self.advanced_reset_button", source_code)
self.assertIn("controls_box.pack_start(reset_row", source_code)
# Not a floating action-area button anymore
self.assertNotIn("action_area.pack_start(self.advanced_reset_button", source_code)
self.assertNotIn("set_child_secondary(self.advanced_reset_button", source_code)
def test_advanced_panel_omits_unsupported_non_speech_token_setting(self):
import os
@@ -693,5 +687,154 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase):
self.assertEqual(_default_whispercpp_variant_for_size("medium", "auto"), "medium")
class TestSettingsSearch(unittest.TestCase):
"""Test cases for the settings search feature."""
def setUp(self):
if "vocalinux.ui.settings_dialog" in sys.modules:
del sys.modules["vocalinux.ui.settings_dialog"]
def test_row_matches_query_title(self):
from vocalinux.ui.settings_dialog import _row_matches_query
self.assertTrue(_row_matches_query("clip", "Copy to Clipboard"))
self.assertTrue(_row_matches_query("COPY", "Copy to Clipboard"))
self.assertFalse(_row_matches_query("gpu", "Copy to Clipboard"))
def test_row_matches_query_subtitle_and_keywords(self):
from vocalinux.ui.settings_dialog import _row_matches_query
self.assertTrue(
_row_matches_query("pasting", "Copy to Clipboard", "copy text for easy pasting")
)
self.assertTrue(_row_matches_query("vulkan", "GPU Device", "", ("vulkan", "graphics")))
self.assertFalse(_row_matches_query("audio", "GPU Device", "", ("vulkan",)))
def test_row_matches_query_empty_query_matches(self):
from vocalinux.ui.settings_dialog import _row_matches_query
self.assertTrue(_row_matches_query("", "Anything"))
self.assertTrue(_row_matches_query(" ", "Anything"))
def test_search_wiring_in_source(self):
"""The dialog wires a live search entry that filters all pages."""
import os
source_path = os.path.join(
os.path.dirname(__file__),
"..",
"src",
"vocalinux",
"ui",
"settings_dialog.py",
)
with open(source_path, "r") as f:
source_code = f.read()
self.assertIn("self.search_entry = Gtk.SearchEntry()", source_code)
self.assertIn('"search-changed", self._on_search_changed', source_code)
self.assertIn("def _snapshot_search_baseline", source_code)
self.assertIn("def _restore_search_baseline", source_code)
# Clearing the search restores engine-driven visibility
restore_body = source_code.split("def _restore_search_baseline")[1].split("def ")[0]
self.assertIn("self._update_engine_specific_ui()", restore_body)
def test_search_indexes_nested_preference_groups(self):
"""Unlocked settings inside a revealer remain discoverable."""
source = self._settings_source()
self.assertIn("collect_groups", source)
self.assertIn("Gtk.Container", source)
self.assertIn("widget.get_children()", source)
def test_search_respects_closed_revealers(self):
"""A locked Advanced section must not leak controls into search."""
source = self._settings_source()
self.assertIn("Gtk.Revealer", source)
self.assertIn("parent.get_reveal_child()", source)
def test_clearing_search_restores_previous_page(self):
"""Clearing a no-results search must leave a real page selected."""
source = self._settings_source()
self.assertIn("self._search_previous_page = visible_page", source)
self.assertIn("self.sidebar_listbox.select_row(page.sidebar_row)", source)
self.assertIn("self.settings_stack.set_visible_child_name(page.name)", source)
@staticmethod
def _settings_source():
import os
source_path = os.path.join(
os.path.dirname(__file__),
"..",
"src",
"vocalinux",
"ui",
"settings_dialog.py",
)
with open(source_path, "r") as source_file:
return source_file.read()
class TestSettingsNavigation(unittest.TestCase):
"""Test cases for the sidebar + stack navigation shell."""
@classmethod
def setUpClass(cls):
import os
source_path = os.path.join(
os.path.dirname(__file__),
"..",
"src",
"vocalinux",
"ui",
"settings_dialog.py",
)
with open(source_path, "r") as f:
cls.source_code = f.read()
def test_sidebar_and_stack_replace_notebook(self):
self.assertIn("self.settings_stack = Gtk.Stack()", self.source_code)
self.assertIn("self.sidebar_listbox = Gtk.ListBox()", self.source_code)
self.assertNotIn("Gtk.Notebook()", self.source_code)
def test_topic_pages_exist(self):
for name, title in [
("dictation", "Dictation"),
("model", "Speech Model"),
("audio", "Audio"),
("performance", "Performance"),
("application", "Application"),
("advanced", "Advanced"),
]:
self.assertIn(f'SettingsPage("{name}", "{title}"', self.source_code)
def test_status_strip_is_persistent(self):
"""The status strip is packed into the dialog, not into a page."""
self.assertIn("def _build_status_strip(self):", self.source_code)
strip_body = self.source_code.split("def _build_status_strip")[1].split("\n def ")[0]
self.assertIn("self.get_content_area().pack_start(strip", strip_body)
# One shared level bar for recognition + mic test
self.assertIn("self.recognition_audio_level = self.audio_level_bar", strip_body)
def test_gpu_selection_is_not_power_user_gated(self):
"""GPU device selection lives on the Performance page, ungated."""
self.assertIn("def _build_gpu_section(self):", self.source_code)
gpu_body = self.source_code.split("def _build_gpu_section")[1].split("\n def ")[0]
self.assertIn("self.power_tab.pack_start(gpu_group", gpu_body)
advanced_body = self.source_code.split("def _build_advanced_section")[1].split(
"\n def "
)[0]
self.assertNotIn("gpu", advanced_body.lower())
def test_dialog_keyboard_shortcuts(self):
"""Ctrl+F focuses search; Esc clears an active search."""
self.assertIn("def _on_dialog_key_press(self, widget, event):", self.source_code)
body = self.source_code.split("def _on_dialog_key_press")[1].split("\n def ")[0]
self.assertIn('"f"', body)
self.assertIn("self.search_entry.grab_focus()", body)
self.assertIn('"escape"', body)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -42,8 +42,8 @@ class TestSettingsDialogShortcutsSection(unittest.TestCase):
self.assertIn("self.shortcut_combo = Gtk.ComboBoxText()", self.source_code)
def test_shortcut_combo_size_request(self):
"""Test that shortcut combo has size request set."""
self.assertIn("self.shortcut_combo.set_size_request(200, -1)", self.source_code)
"""Test that shortcut combo uses the shared control width."""
self.assertIn("self.shortcut_combo.set_size_request(_CONTROL_WIDTH, -1)", self.source_code)
def test_shortcut_combo_tooltip(self):
"""Test that shortcut combo has a tooltip."""