From 3e78a9bc4e13d777489e398f9b2eb4d88ac98427 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 29 Jul 2026 01:34:57 -0700 Subject: [PATCH] feat(settings): move dictation controls into sidebar (#618) Keep the persistent test and close actions accessible without an awkward bottom action row, while using portable sidebar icons. --- src/vocalinux/ui/settings_dialog.py | 103 ++++++++++++++++------------ tests/test_settings_dialog.py | 45 +++++++++--- 2 files changed, 94 insertions(+), 54 deletions(-) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 8b16309..3f8aa61 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -9,10 +9,10 @@ UX Design Notes: - Sidebar navigation (icon + label) with topic-based pages: Dictation, Speech Model, Audio, Performance, Application, Advanced - Live settings search (Ctrl+F) filters rows across all pages in place -- Persistent bottom status strip: recognition state, mic level, and a - dictation test that can verify any instant-applied change from any page +- Sidebar footer (always visible from any page): recognition state, mic + level, a dictation test, and the Close action - Settings apply immediately when changed (instant-apply pattern) -- Close button provided for WM compatibility (some WMs hide title bar close) +- In-window Close button for WM compatibility (some WMs hide title bar close) - Multi-modal feedback (text + icon + audio level) for accessibility - Modal dialog for model downloads (explicit confirmation for large downloads) """ @@ -375,7 +375,17 @@ SETTINGS_CSS = """ } .settings-search { - margin: 8px 6px 6px 6px; + margin: 8px 6px 14px 6px; + border-radius: 8px; +} + +/* Sidebar footer: dictation status, test action, and dialog close */ +.sidebar-footer { + padding: 10px 6px 12px 6px; +} + +.sidebar-footer button { + min-height: 30px; border-radius: 8px; } @@ -386,13 +396,7 @@ SETTINGS_CSS = """ color: @theme_unfocused_fg_color; } -/* Persistent status strip at the bottom of the dialog */ -.status-strip { - background-color: @theme_base_color; - border-top: 1px solid alpha(@borders, 0.5); - padding: 8px 16px; -} - +/* Recognition state label in the sidebar footer */ .status-strip-state { font-weight: 500; font-size: 0.9em; @@ -1095,18 +1099,12 @@ class SettingsDialog(Gtk.Dialog): shortcut_update_callback: callable = None, ): super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0) - self.set_decorated(True) # Force window decorations (close button) on all WMs + # Force window decorations (title-bar close) on all WMs. An in-window + # Close button also lives in the sidebar footer so the dialog always + # has a visible way to dismiss it, even on window managers that hide + # the title-bar close button for Gtk.Dialog windows (fixes #323). + self.set_decorated(True) - # Add a Close action button so the dialog always has a visible way to - # dismiss it, even on window managers that hide the title-bar close - # button for Gtk.Dialog windows without action buttons (fixes #323). - self.add_button("Close", Gtk.ResponseType.CLOSE) - action_area = self.get_action_area() - action_area.set_margin_top(8) - action_area.set_margin_bottom(12) - action_area.set_margin_start(16) - action_area.set_margin_end(16) - action_area.set_spacing(8) self.config_manager = config_manager self.speech_engine = speech_engine self.shortcut_update_callback = shortcut_update_callback @@ -1123,7 +1121,7 @@ class SettingsDialog(Gtk.Dialog): # Setup CSS styling _setup_css() - # Dialog configuration - Close button added above for WM compatibility + # Dialog configuration - Close button lives in the sidebar footer (see #323) # Calculate dialog size display = Gdk.Display.get_default() if display: @@ -1146,13 +1144,15 @@ class SettingsDialog(Gtk.Dialog): self.get_style_context().add_class("settings-dialog") # Topic-based pages (GNOME HIG: group by topic, navigate via sidebar) + # Icons stick to the stock Adwaita symbolic set so they resolve on + # every distro (some system-monitor icons only ship with Yaru). self._pages = [ SettingsPage("dictation", "Dictation", "input-keyboard-symbolic"), SettingsPage("model", "Speech Model", "audio-input-microphone-symbolic"), - SettingsPage("audio", "Audio", "audio-card-symbolic"), - SettingsPage("performance", "Performance", "utilities-system-monitor-symbolic"), + SettingsPage("audio", "Audio", "audio-speakers-symbolic"), + SettingsPage("performance", "Performance", "power-profile-performance-symbolic"), SettingsPage("application", "Application", "preferences-system-symbolic"), - SettingsPage("advanced", "Advanced", "preferences-other-symbolic"), + SettingsPage("advanced", "Advanced", "applications-engineering-symbolic"), ] pages_by_name = {page.name: page for page in self._pages} @@ -1226,7 +1226,7 @@ class SettingsDialog(Gtk.Dialog): self._build_gpu_section() self._build_general_section() self._build_advanced_section() - self._build_status_strip() + self._build_sidebar_footer(sidebar_box) # Record searchable groups vs. loose extras per page for page in self._pages: @@ -2665,28 +2665,34 @@ class SettingsDialog(Gtk.Dialog): applied = bool(self.shortcut_update_callback(shortcut_id, mode_id)) self._report_shortcut_apply_result(display_name, applied) - def _build_status_strip(self): - """Build the persistent status strip at the bottom of the dialog. + def _build_sidebar_footer(self, sidebar_box: Gtk.Box): + """Build the sidebar footer: dictation status, test action, and Close. Always visible regardless of the selected page: recognition state, - live microphone level, and a dictation test button. Test output is - revealed inline, so any instant-applied change can be verified - immediately. + live microphone level, a dictation test button, and the dialog's + in-window Close button. Test output is revealed inline, so any + instant-applied change can be verified immediately. """ - strip = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - strip.get_style_context().add_class("status-strip") + separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + separator.set_margin_start(8) + separator.set_margin_end(8) + sidebar_box.pack_start(separator, False, False, 0) - bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + footer.get_style_context().add_class("sidebar-footer") + + status_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self.recognition_indicator = Gtk.Image.new_from_icon_name( "media-record-symbolic", Gtk.IconSize.MENU ) self.recognition_indicator.set_opacity(0.3) - bar.pack_start(self.recognition_indicator, False, False, 0) + status_row.pack_start(self.recognition_indicator, False, False, 0) self.recognition_status_label = Gtk.Label(label="Idle", xalign=0) self.recognition_status_label.get_style_context().add_class("status-strip-state") - bar.pack_start(self.recognition_status_label, False, False, 0) + status_row.pack_start(self.recognition_status_label, False, False, 0) + footer.pack_start(status_row, False, False, 0) # One shared level bar: recognition progress and the microphone test # both report into it (recognition_audio_level is a legacy alias). @@ -2696,23 +2702,21 @@ class SettingsDialog(Gtk.Dialog): self.audio_level_bar.set_value(0) self.audio_level_bar.set_valign(Gtk.Align.CENTER) self.recognition_audio_level = self.audio_level_bar - bar.pack_start(self.audio_level_bar, True, True, 0) + footer.pack_start(self.audio_level_bar, False, False, 0) self.test_button = Gtk.Button(label="Test Dictation") self.test_button.set_tooltip_text( "Record 3 seconds of speech and show the transcription here" ) self.test_button.connect("clicked", self._on_test_clicked) - bar.pack_end(self.test_button, False, False, 0) - - strip.pack_start(bar, False, False, 0) + footer.pack_start(self.test_button, False, False, 0) # One shared status line (audio test results and recognition info) self.progress_info_label = Gtk.Label(label="", use_markup=True, xalign=0) self.progress_info_label.get_style_context().add_class("status-info") self.progress_info_label.set_line_wrap(True) self.audio_test_status = self.progress_info_label - strip.pack_start(self.progress_info_label, False, False, 0) + footer.pack_start(self.progress_info_label, False, False, 0) # Test transcription output, revealed while testing self.test_output_revealer = Gtk.Revealer() @@ -2733,9 +2737,20 @@ class SettingsDialog(Gtk.Dialog): scrolled_window.add(self.test_textview) self.test_output_revealer.add(scrolled_window) - strip.pack_start(self.test_output_revealer, False, False, 0) + footer.pack_start(self.test_output_revealer, False, False, 0) - self.get_content_area().pack_start(strip, False, False, 0) + # In-window Close so the dialog can always be dismissed, even on WMs + # that hide the title-bar close button for Gtk.Dialog windows (#323). + close_button = Gtk.Button(label="Close") + close_button.set_tooltip_text("Close settings (Ctrl+W)") + close_button.connect("clicked", self._on_close_clicked) + footer.pack_start(close_button, False, False, 0) + + sidebar_box.pack_start(footer, False, False, 0) + + def _on_close_clicked(self, button): + """Close the dialog through the normal response path (same as title-bar X).""" + self.response(Gtk.ResponseType.CLOSE) def _build_advanced_section(self): """Build the Advanced section with whisper.cpp parameters.""" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 3533c4a..7a02fe9 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -462,7 +462,11 @@ class TestSettingsDialogInstantApply(unittest.TestCase): self.assertNotIn("Suppress Non-Speech Tokens", source_code) self.assertNotIn("advanced_suppress_nst_switch", source_code) - def test_close_button_uses_dialog_padding(self): + def test_close_button_lives_in_sidebar_footer(self): + """The in-window Close button is anchored in the sidebar footer and + closes through the normal response path, not an orphan action-area + row below the content (#323 keeps an in-window Close for WMs that + hide the title-bar button).""" import os source_path = os.path.join( @@ -476,9 +480,10 @@ class TestSettingsDialogInstantApply(unittest.TestCase): with open(source_path, "r") as f: source_code = f.read() - self.assertIn("action_area = self.get_action_area()", source_code) - self.assertIn("action_area.set_margin_start(16)", source_code) - self.assertIn("action_area.set_margin_end(16)", source_code) + self.assertNotIn('self.add_button("Close"', source_code) + self.assertIn('close_button = Gtk.Button(label="Close")', source_code) + self.assertIn('close_button.connect("clicked", self._on_close_clicked)', source_code) + self.assertIn("self.response(Gtk.ResponseType.CLOSE)", source_code) def test_advanced_disclaimer_appears_before_controls(self): import os @@ -818,13 +823,33 @@ class TestSettingsNavigation(unittest.TestCase): ]: 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) + def test_status_controls_live_in_sidebar_footer(self): + """Status, mic level, test, and Close live in the sidebar footer so + they stay visible from every page without a bottom strip.""" + self.assertIn("def _build_sidebar_footer(self, sidebar_box", self.source_code) + footer_body = self.source_code.split("def _build_sidebar_footer")[1].split("\n def ")[0] + self.assertIn("sidebar_box.pack_start(footer", footer_body) # One shared level bar for recognition + mic test - self.assertIn("self.recognition_audio_level = self.audio_level_bar", strip_body) + self.assertIn("self.recognition_audio_level = self.audio_level_bar", footer_body) + self.assertIn('self.test_button = Gtk.Button(label="Test Dictation")', footer_body) + self.assertIn('close_button = Gtk.Button(label="Close")', footer_body) + # No persistent bottom strip below the pages + self.assertNotIn("def _build_status_strip(self):", self.source_code) + + def test_sidebar_icons_use_adwaita_names(self): + """Sidebar icons must resolve in the stock Adwaita theme.""" + for icon in [ + "input-keyboard-symbolic", + "audio-input-microphone-symbolic", + "audio-speakers-symbolic", + "power-profile-performance-symbolic", + "preferences-system-symbolic", + "applications-engineering-symbolic", + ]: + self.assertIn(f'"{icon}"', self.source_code) + # utilities-system-monitor-symbolic only ships with Ubuntu's Yaru theme + self.assertNotIn("utilities-system-monitor-symbolic", self.source_code) + self.assertNotIn("audio-card-symbolic", self.source_code) def test_gpu_selection_is_not_power_user_gated(self): """GPU device selection lives on the Performance page, ungated."""