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.
This commit is contained in:
Jatin K Malik
2026-02-22 19:52:16 -08:00
committed by GitHub
parent b0565b36ad
commit c8142151eb
2 changed files with 138 additions and 45 deletions
+130 -37
View File
@@ -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)
+8 -8
View File
@@ -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):