From c8142151eb0afe9f8d65c7327135955f8cddb322 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sun, 22 Feb 2026 19:52:16 -0800 Subject: [PATCH] refactor(ui): convert settings dialog to tabbed layout (#246) * refactor(ui): convert settings dialog to tabbed layout Convert the settings dialog from a long scrolling list to a tab-based UI using Gtk.Notebook. This follows GNOME HIG best practices for settings panels with 3-5 sections. Changes: - Add notebook CSS styling for modern tab appearance - Create 4 tab content boxes (Recognition, Audio, Shortcuts, General) - Replace ScrolledWindow with Gtk.Notebook - Reorganize settings sections into appropriate tabs: - Recognition: Engine, Model, Language, VAD, Silence, Test - Audio: Device selection, Audio level test - Shortcuts: Keyboard shortcuts - General: Autostart, Start minimized - Widen dialog to 700px to accommodate tab content This eliminates vertical scrolling and provides a cleaner UX with logical grouping of related settings. * refactor(settings): split recognition tab into Speech Engine and Recognition tabs - Reordered tabs by importance: Speech Engine, Recognition, Audio, Shortcuts, General - Split the Recognition tab into two separate tabs: - Speech Engine tab: Engine, Model Size, Language, Model Info - Recognition tab: VAD Sensitivity, Silence Timeout, Test Recognition, Recognition Status - This reduces vertical height of each tab and improves usability * fix(ui): reduce settings dialog height to 50% of screen Previously set to 75% which made the dialog unnecessarily tall. * fix(ui): remove redundant close button from settings dialog Settings auto-apply, so the bottom close button is unnecessary. Users can close using the title bar X button. * test(settings): update test for no-action-buttons pattern The settings dialog now uses title bar close instead of a bottom Close button, so update the test accordingly. --- src/vocalinux/ui/settings_dialog.py | 167 ++++++++++++++++++++++------ tests/test_settings_dialog.py | 16 +-- 2 files changed, 138 insertions(+), 45 deletions(-) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 853034e..d89df15 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -7,8 +7,8 @@ and other relevant parameters. UX Design Notes: - Follows GNOME Human Interface Guidelines (HIG) for modern desktop look - Uses preference-page style layout with clearly grouped sections -- Implements instant-apply pattern: settings apply immediately when changed -- Close button only (no Apply/Cancel) - consistent with GNOME Settings app +- Settings apply immediately when changed (instant-apply pattern) +- No action buttons needed - use title bar close button - Provides real-time progress feedback for recognition state - Multi-modal feedback (text + icon + audio level) for accessibility - Modal dialog for model downloads (explicit confirmation for large downloads) @@ -122,6 +122,52 @@ SYSTEM_MODELS_DIRS = [ # CSS for modern styling SETTINGS_CSS = """ +/* Notebook (tab) styling */ +.notebook { + background-color: transparent; + border: none; +} + +.notebook tab { + background-color: transparent; + border: none; + padding: 10px 16px; + color: @theme_unfocused_fg_color; +} + +.notebook tab:hover { + background-color: alpha(@theme_selected_bg_color, 0.1); +} + +.notebook tab:active { + background-color: alpha(@theme_selected_bg_color, 0.2); +} + +.notebook tab:selected { + background-color: transparent; + color: @theme_fg_color; +} + +.notebook tab label { + font-weight: 500; + font-size: 0.95em; +} + +.notebook tab:selected label { + font-weight: 600; +} + +.notebook header { + background-color: alpha(@theme_bg_color, 0.5); + border-bottom: 1px solid alpha(@borders, 0.3); + box-shadow: 0 1px 3px alpha(@theme_bg_color, 0.2); +} + +/* Tab content area */ +.notebook stack { + background-color: transparent; +} + /* Modern GNOME-style settings dialog */ .settings-dialog { background-color: @theme_bg_color; @@ -683,9 +729,8 @@ class SettingsDialog(Gtk.Dialog): # Setup CSS styling _setup_css() - # Dialog configuration - Close button only (instant-apply pattern) - self.add_button("_Close", Gtk.ResponseType.CLOSE) - # Calculate dialog size - 75% of screen height for better visibility + # Dialog configuration - no action buttons needed (use title bar close) + # Calculate dialog size display = Gdk.Display.get_default() if display: monitor = display.get_primary_monitor() @@ -701,27 +746,83 @@ class SettingsDialog(Gtk.Dialog): else: screen_height = 1080 # Default fallback screen_width = 1920 - dialog_height = int(screen_height * 0.75) - dialog_width = min(520, int(screen_width * 0.8)) + dialog_height = int(screen_height * 0.5) + dialog_width = min(700, int(screen_width * 0.8)) self.set_default_size(dialog_width, dialog_height) self.get_style_context().add_class("settings-dialog") - # Main scrolled window for content - scrolled = Gtk.ScrolledWindow() - scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scrolled.get_style_context().add_class("scrolled-content") + # Create notebook for tabbed interface + notebook = Gtk.Notebook() + notebook.set_show_tabs(True) + notebook.set_show_border(False) + notebook.get_style_context().add_class("notebook") - # Main content box - self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - self.content_box.set_margin_top(16) - self.content_box.set_margin_bottom(16) - self.content_box.set_margin_start(16) - self.content_box.set_margin_end(16) + # Create tab content boxes + # Speech Engine tab (Engine, Model, Language) + self.speech_engine_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.speech_engine_tab.set_margin_top(16) + self.speech_engine_tab.set_margin_bottom(16) + self.speech_engine_tab.set_margin_start(16) + self.speech_engine_tab.set_margin_end(16) - scrolled.add(self.content_box) - self.get_content_area().pack_start(scrolled, True, True, 0) + # Recognition Settings tab (VAD, Silence, Test) + self.recognition_settings_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.recognition_settings_tab.set_margin_top(16) + self.recognition_settings_tab.set_margin_bottom(16) + self.recognition_settings_tab.set_margin_start(16) + self.recognition_settings_tab.set_margin_end(16) - # Build UI sections + # Audio tab + self.audio_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.audio_tab.set_margin_top(16) + self.audio_tab.set_margin_bottom(16) + self.audio_tab.set_margin_start(16) + self.audio_tab.set_margin_end(16) + + self.shortcuts_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.shortcuts_tab.set_margin_top(16) + self.shortcuts_tab.set_margin_bottom(16) + self.shortcuts_tab.set_margin_start(16) + self.shortcuts_tab.set_margin_end(16) + + self.general_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.general_tab.set_margin_top(16) + self.general_tab.set_margin_bottom(16) + self.general_tab.set_margin_start(16) + self.general_tab.set_margin_end(16) + + # Add tabs to notebook (ordered by importance) + # Speech Engine tab - most important (what model/language to use) + speech_engine_label = Gtk.Label(label="Speech Engine") + speech_engine_label.set_tooltip_text("Speech recognition engine and model settings") + notebook.append_page(self.speech_engine_tab, speech_engine_label) + + # Recognition Settings tab - second most important (how to recognize) + recognition_label = Gtk.Label(label="Recognition") + recognition_label.set_tooltip_text("Recognition behavior and test settings") + notebook.append_page(self.recognition_settings_tab, recognition_label) + + # Audio tab - third (hardware configuration) + audio_label = Gtk.Label(label="Audio") + audio_label.set_tooltip_text("Microphone and audio settings") + notebook.append_page(self.audio_tab, audio_label) + + # Shortcuts tab + shortcuts_label = Gtk.Label(label="Shortcuts") + shortcuts_label.set_tooltip_text("Keyboard shortcuts") + notebook.append_page(self.shortcuts_tab, shortcuts_label) + + # General tab - least important (application behavior) + general_label = Gtk.Label(label="General") + general_label.set_tooltip_text("General settings") + notebook.append_page(self.general_tab, general_label) + + self.get_content_area().pack_start(notebook, True, True, 0) + + # Set content_box to speech_engine_tab for backward compatibility + self.content_box = self.speech_engine_tab + + # Build UI sections into appropriate tabs self._build_general_section() self._build_audio_section() self._build_engine_section() @@ -801,8 +902,8 @@ class SettingsDialog(Gtk.Dialog): self.audio_test_status.set_margin_top(4) self.audio_test_status.get_style_context().add_class("status-info") - self.content_box.pack_start(group, False, False, 0) - self.content_box.pack_start(self.audio_test_status, False, False, 0) + self.audio_tab.pack_start(group, False, False, 0) + self.audio_tab.pack_start(self.audio_test_status, False, False, 0) # Populate devices self._populate_audio_devices() @@ -830,7 +931,7 @@ class SettingsDialog(Gtk.Dialog): ) group.add_row(start_minimized_row) - self.content_box.pack_start(group, False, False, 0) + 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) @@ -980,7 +1081,7 @@ class SettingsDialog(Gtk.Dialog): ) group.add_row(silence_row) - self.content_box.pack_start(group, False, False, 0) + self.recognition_settings_tab.pack_start(group, False, False, 0) # Connect signals self.vad_spin.connect("value-changed", self._on_vad_changed) @@ -1015,7 +1116,7 @@ class SettingsDialog(Gtk.Dialog): ) group.add_row(shortcut_row) - self.content_box.pack_start(group, False, False, 0) + self.shortcuts_tab.pack_start(group, False, False, 0) # Info box about the shortcut info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) @@ -1035,7 +1136,7 @@ class SettingsDialog(Gtk.Dialog): self.shortcut_info_label.get_style_context().add_class("tip-label") info_box.pack_start(self.shortcut_info_label, True, True, 0) - self.content_box.pack_start(info_box, False, False, 0) + self.shortcuts_tab.pack_start(info_box, False, False, 0) # Connect signal self.shortcut_combo.connect("changed", self._on_shortcut_changed) @@ -1115,7 +1216,7 @@ class SettingsDialog(Gtk.Dialog): test_container.add(test_box) group.listbox.add(test_container) - self.content_box.pack_start(group, False, False, 0) + self.recognition_settings_tab.pack_start(group, False, False, 0) # Recognition Progress section progress_group = PreferencesGroup(title="Recognition Status") @@ -1150,13 +1251,13 @@ class SettingsDialog(Gtk.Dialog): ) progress_group.add_row(level_row) - self.content_box.pack_start(progress_group, False, False, 0) + self.recognition_settings_tab.pack_start(progress_group, False, False, 0) # Progress info label self.progress_info_label = Gtk.Label(label="", use_markup=True, xalign=0) self.progress_info_label.set_margin_start(16) self.progress_info_label.set_margin_bottom(8) - self.content_box.pack_start(self.progress_info_label, False, False, 0) + self.recognition_settings_tab.pack_start(self.progress_info_label, False, False, 0) def _load_and_apply_settings(self): """Load current settings and populate the UI.""" @@ -1971,14 +2072,6 @@ For now, the engine has been reverted to VOSK.""" return False - def do_response(self, response_id): - """Handle dialog button responses.""" - if response_id == Gtk.ResponseType.CLOSE: - logger.info("Close button clicked") - self.destroy() - - return True - def update_recognition_progress(self, state: str, audio_level: float = 0.0, info: str = ""): """Update the recognition progress feedback UI.""" self.recognition_status_label.set_text(state) diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index f4376c8..9798c68 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -7,7 +7,7 @@ logic that the settings dialog is supposed to execute. UX Design Notes tested: - Instant-apply pattern: settings apply immediately when changed -- Close-only button (no Apply/Cancel) consistent with GNOME Settings +- No action buttons - uses title bar close (GNOME HIG) """ import sys @@ -238,7 +238,7 @@ class TestSettingsDialogClasses(unittest.TestCase): class TestSettingsDialogInstantApply(unittest.TestCase): - """Test cases for instant-apply behavior (Close-only button pattern).""" + """Test cases for instant-apply behavior (no action buttons).""" def setUp(self): """Set up test fixtures.""" @@ -262,8 +262,8 @@ class TestSettingsDialogInstantApply(unittest.TestCase): self.assertIn("def _auto_apply_settings(self", source_code) - def test_settings_dialog_close_only_pattern(self): - """Test that SettingsDialog uses Close-only button pattern (no Apply/Cancel).""" + def test_settings_dialog_no_action_buttons(self): + """Test that SettingsDialog uses no action buttons (title bar close only).""" import os source_path = os.path.join( @@ -277,17 +277,17 @@ class TestSettingsDialogInstantApply(unittest.TestCase): with open(source_path, "r") as f: source_code = f.read() - # Check for Close button - self.assertIn("_Close", source_code) - # Should NOT have Apply or Cancel buttons (instant-apply pattern) + # Should NOT have action buttons - uses title bar close (instant-apply pattern) + self.assertNotIn("_Close", source_code) self.assertNotIn("_Apply", source_code) self.assertNotIn("ResponseType.APPLY", source_code) + self.assertNotIn("ResponseType.CLOSE", source_code) def test_settings_dialog_no_revert_settings(self): """Test that SettingsDialog does NOT have _revert_settings (removed).""" from vocalinux.ui.settings_dialog import SettingsDialog - # _revert_settings was removed as part of Close-only button pattern + # _revert_settings was removed as part of no-action-buttons pattern self.assertFalse(hasattr(SettingsDialog, "_revert_settings")) def test_settings_dialog_no_show_applied_message(self):