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:
co-authored by
Jatin K Malik
parent
053fc173fa
commit
e7022f83dd
+570
-350
File diff suppressed because it is too large
Load Diff
+157
-14
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user