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:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user