diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py
index 0feae7e..7e615cd 100644
--- a/src/vocalinux/ui/settings_dialog.py
+++ b/src/vocalinux/ui/settings_dialog.py
@@ -6,10 +6,13 @@ 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
+- 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
- Settings apply immediately when changed (instant-apply pattern)
- Close button provided for WM compatibility (some WMs hide title bar close)
-- 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)
"""
@@ -197,6 +200,9 @@ def _default_whispercpp_variant_for_size(model_size: str, language_id: str) -> O
return variants[0]
+# Uniform width for right-hand row controls so they align down a page
+_CONTROL_WIDTH = 230
+
MODEL_SIZE_TOOLTIP = (
"Choose the largest model your computer can run comfortably. Tiny/Base are fastest, "
"Small is balanced, and Medium/Large can be more accurate but need more memory."
@@ -325,52 +331,71 @@ SYSTEM_MODELS_DIRS = [
# CSS for modern styling
SETTINGS_CSS = """
-/* Notebook (tab) styling */
-.notebook {
+/* Sidebar navigation (GNOME Settings style) */
+.settings-sidebar {
+ background-color: alpha(@theme_bg_color, 0.5);
+ border-right: 1px solid alpha(@borders, 0.5);
+}
+
+.settings-sidebar list {
background-color: transparent;
- border: none;
}
-.notebook tab {
- background-color: transparent;
- border: none;
- padding: 10px 16px;
- color: @theme_unfocused_fg_color;
+.sidebar-row {
+ padding: 10px 12px;
+ border-radius: 8px;
+ margin: 1px 6px;
}
-.notebook tab:hover {
- background-color: alpha(@theme_selected_bg_color, 0.1);
+.sidebar-row:hover {
+ background-color: alpha(@theme_fg_color, 0.07);
}
-.notebook tab:active {
- background-color: alpha(@theme_selected_bg_color, 0.2);
+.sidebar-row:selected {
+ background-color: alpha(@theme_selected_bg_color, 0.85);
+ color: @theme_selected_fg_color;
}
-/* GTK3 marks the selected notebook tab with :checked (not :selected) */
-.notebook tab:checked {
- background-color: transparent;
- color: @theme_fg_color;
- box-shadow: inset 0 -3px @theme_selected_bg_color;
-}
-
-.notebook tab label {
+.sidebar-row label {
font-weight: 500;
font-size: 0.95em;
}
-.notebook tab:checked label {
- font-weight: 700;
+.sidebar-match-count {
+ font-size: 0.8em;
+ color: @theme_unfocused_fg_color;
+ background-color: alpha(@theme_fg_color, 0.1);
+ border-radius: 10px;
+ padding: 1px 7px;
}
-.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);
+.sidebar-row:selected .sidebar-match-count {
+ color: @theme_selected_fg_color;
+ background-color: alpha(@theme_selected_fg_color, 0.2);
}
-/* Tab content area */
-.notebook stack {
- background-color: transparent;
+.settings-search {
+ margin: 8px 6px 6px 6px;
+ border-radius: 8px;
+}
+
+/* Empty search results */
+.search-empty-title {
+ font-size: 1.1em;
+ font-weight: bold;
+ 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;
+}
+
+.status-strip-state {
+ font-weight: 500;
+ font-size: 0.9em;
}
/* Modern GNOME-style settings dialog */
@@ -758,12 +783,29 @@ def _gdk_keyname_to_token(name: Optional[str]) -> Optional[str]:
return None
+def _row_matches_query(query: str, title: str, subtitle: str = "", keywords=()) -> bool:
+ """Return whether a settings row matches a search query.
+
+ Matching is case-insensitive and checks the row title, subtitle, and any
+ extra keywords attached to the row.
+ """
+ query = (query or "").strip().casefold()
+ if not query:
+ return True
+ haystacks = [title or "", subtitle or "", *keywords]
+ return any(query in text.casefold() for text in haystacks)
+
+
class PreferencesGroup(Gtk.Box):
"""A card-style group of preferences, similar to libadwaita's AdwPreferencesGroup."""
- def __init__(self, title: str = "", description: str = ""):
+ def __init__(self, title: str = "", description: str = "", keywords=()):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.get_style_context().add_class("preferences-group")
+ self.title = title
+ self.description = description
+ self.keywords = tuple(keywords)
+ self.rows = []
# Header with title
if title:
@@ -780,6 +822,9 @@ class PreferencesGroup(Gtk.Box):
if description:
desc_label = Gtk.Label(label=description, xalign=0, wrap=True)
desc_label.get_style_context().add_class("preference-row-subtitle")
+ # Align with the title, which carries 16px CSS padding.
+ desc_label.set_margin_start(16)
+ desc_label.set_margin_end(16)
header_box.pack_start(desc_label, False, False, 0)
self.pack_start(header_box, False, False, 0)
@@ -793,6 +838,7 @@ class PreferencesGroup(Gtk.Box):
def add_row(self, widget):
"""Add a widget as a row in the preferences group."""
self.listbox.add(widget)
+ self.rows.append(widget)
class PreferenceRow(Gtk.ListBoxRow):
@@ -804,10 +850,14 @@ class PreferenceRow(Gtk.ListBoxRow):
subtitle: str = "",
widget: Gtk.Widget = None,
activatable: bool = False,
+ keywords=(),
):
super().__init__()
self.set_activatable(activatable)
self.get_style_context().add_class("preference-row")
+ self.title = title
+ self.subtitle = subtitle
+ self.keywords = tuple(keywords)
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
hbox.set_margin_top(12)
@@ -828,7 +878,7 @@ class PreferenceRow(Gtk.ListBoxRow):
if subtitle:
self.subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
self.subtitle_label.get_style_context().add_class("preference-row-subtitle")
- self.subtitle_label.set_max_width_chars(40)
+ self.subtitle_label.set_max_width_chars(55)
self.subtitle_label.set_line_wrap(True)
self.subtitle_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
text_box.pack_start(self.subtitle_label, False, False, 0)
@@ -844,9 +894,63 @@ class PreferenceRow(Gtk.ListBoxRow):
def set_subtitle(self, subtitle: str):
"""Update the subtitle text."""
+ self.subtitle = subtitle
if self.subtitle_label:
self.subtitle_label.set_text(subtitle)
+ def matches_query(self, query: str) -> bool:
+ """Return whether this row matches a settings search query."""
+ return _row_matches_query(query, self.title, self.subtitle, self.keywords)
+
+
+class SettingsPage:
+ """One topic page in the settings dialog (sidebar entry + stack child).
+
+ Wraps the page content box and, after the page is built, records which
+ children are searchable PreferencesGroups vs. loose "extra" widgets
+ (info boxes, status labels) that are simply hidden while searching.
+ """
+
+ def __init__(self, name: str, title: str, icon_name: str):
+ self.name = name
+ self.title = title
+ self.icon_name = icon_name
+ self.groups = []
+ self.extras = []
+ self.sidebar_row = None
+ self.match_count_label = None
+
+ self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ self.box.set_margin_top(16)
+ self.box.set_margin_bottom(16)
+ self.box.set_margin_start(16)
+ self.box.set_margin_end(16)
+
+ def collect_children(self):
+ """Classify built descendants into searchable groups and extras.
+
+ Most groups are direct page children, but gated sections such as the
+ Advanced decoding controls live inside a Gtk.Revealer. Walk nested
+ containers so unlocked settings are searchable too.
+ """
+ self.groups = []
+ self.extras = []
+
+ def collect_groups(widget):
+ if isinstance(widget, PreferencesGroup):
+ self.groups.append(widget)
+ return True
+ if isinstance(widget, Gtk.Container):
+ found = False
+ for child in widget.get_children():
+ found = collect_groups(child) or found
+ return found
+ return False
+
+ for child in self.box.get_children():
+ if not collect_groups(child):
+ self.extras.append(child)
+
class ModelDownloadDialog(Gtk.Dialog):
"""Dialog showing model download progress with cancel support."""
@@ -999,13 +1103,6 @@ class SettingsDialog(Gtk.Dialog):
action_area.set_margin_start(16)
action_area.set_margin_end(16)
action_area.set_spacing(8)
- self.advanced_reset_button = Gtk.Button(label="Reset to Defaults")
- self.advanced_reset_button.set_tooltip_text(
- "Restore the whisper.cpp advanced parameters to their default values"
- )
- self.advanced_reset_button.connect("clicked", self._on_reset_advanced_clicked)
- action_area.pack_start(self.advanced_reset_button, False, False, 0)
- action_area.set_child_secondary(self.advanced_reset_button, True)
self.config_manager = config_manager
self.speech_engine = speech_engine
self.shortcut_update_callback = shortcut_update_callback
@@ -1039,68 +1136,35 @@ class SettingsDialog(Gtk.Dialog):
else:
screen_height = 1080 # Default fallback
screen_width = 1920
- dialog_height = min(800, int(screen_height * 0.75))
- dialog_width = min(700, int(screen_width * 0.8))
+ dialog_height = min(760, int(screen_height * 0.8))
+ dialog_width = min(880, int(screen_width * 0.85))
self.set_default_size(dialog_width, dialog_height)
self.get_style_context().add_class("settings-dialog")
- # Create notebook for tabbed interface
- notebook = Gtk.Notebook()
- self.settings_notebook = notebook
- notebook.set_show_tabs(True)
- notebook.set_show_border(False)
- notebook.get_style_context().add_class("notebook")
+ # Topic-based pages (GNOME HIG: group by topic, navigate via sidebar)
+ 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("application", "Application", "preferences-system-symbolic"),
+ SettingsPage("advanced", "Advanced", "preferences-other-symbolic"),
+ ]
+ pages_by_name = {page.name: page for page in self._pages}
- # 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)
+ # Aliases so the build methods keep targeting familiar boxes.
+ self.dictation_page = pages_by_name["dictation"]
+ self.shortcuts_tab = self.dictation_page.box
+ self.recognition_settings_tab = self.dictation_page.box
+ self.speech_engine_tab = pages_by_name["model"].box
+ self.audio_tab = pages_by_name["audio"].box
+ self.power_tab = pages_by_name["performance"].box
+ self.general_tab = pages_by_name["application"].box
+ self.advanced_tab = pages_by_name["advanced"].box
- # 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)
-
- # 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.power_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
- self.power_tab.set_margin_top(16)
- self.power_tab.set_margin_bottom(16)
- self.power_tab.set_margin_start(16)
- self.power_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)
-
- self.advanced_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
- self.advanced_tab.set_margin_top(16)
- self.advanced_tab.set_margin_bottom(16)
- self.advanced_tab.set_margin_start(16)
- self.advanced_tab.set_margin_end(16)
-
- # Add tabs to notebook (ordered by importance). Each tab is wrapped in a
- # vertical ScrolledWindow: without one, the notebook's minimum height is
- # the tallest tab's full content (~1000px), which overrides
- # set_default_size and makes the dialog taller than the monitor.
+ # Each page is wrapped in a vertical ScrolledWindow: without one, the
+ # stack's minimum height is the tallest page's full content, which
+ # overrides set_default_size and can exceed the monitor.
def _scrollable(tab):
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
@@ -1108,73 +1172,73 @@ class SettingsDialog(Gtk.Dialog):
scroller.add(tab)
return scroller
- # 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(_scrollable(self.speech_engine_tab), speech_engine_label)
+ # Content stack + sidebar navigation with settings search
+ self.settings_stack = Gtk.Stack()
+ self.settings_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
+ self.settings_stack.set_transition_duration(120)
+ self.settings_stack.set_hexpand(True)
+ for page in self._pages:
+ self.settings_stack.add_titled(_scrollable(page.box), page.name, page.title)
+ self.settings_stack.add_named(self._build_search_empty_page(), "search-empty")
+ self.settings_stack.connect("notify::visible-child", self._on_settings_page_changed)
- # 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(_scrollable(self.recognition_settings_tab), recognition_label)
+ sidebar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ sidebar_box.get_style_context().add_class("settings-sidebar")
+ sidebar_box.set_size_request(200, -1)
- # Audio tab - third (hardware configuration)
- audio_label = Gtk.Label(label="Audio")
- audio_label.set_tooltip_text("Microphone and audio settings")
- notebook.append_page(_scrollable(self.audio_tab), audio_label)
+ self.search_entry = Gtk.SearchEntry()
+ self.search_entry.set_placeholder_text("Search settings…")
+ self.search_entry.get_style_context().add_class("settings-search")
+ self.search_entry.connect("search-changed", self._on_search_changed)
+ sidebar_box.pack_start(self.search_entry, False, False, 0)
- # Shortcuts tab
- shortcuts_label = Gtk.Label(label="Shortcuts")
- shortcuts_label.set_tooltip_text("Keyboard shortcuts")
- notebook.append_page(_scrollable(self.shortcuts_tab), shortcuts_label)
+ self.sidebar_listbox = Gtk.ListBox()
+ self.sidebar_listbox.set_selection_mode(Gtk.SelectionMode.SINGLE)
+ for page in self._pages:
+ self.sidebar_listbox.add(self._build_sidebar_row(page))
+ self.sidebar_listbox.connect("row-selected", self._on_sidebar_row_selected)
+ sidebar_box.pack_start(self.sidebar_listbox, True, True, 0)
- # Power tab - resource management (auto-pause, idle unload)
- power_label = Gtk.Label(label="Power")
- power_label.set_tooltip_text("Free CPU, GPU, and memory while gaming or idle")
- notebook.append_page(_scrollable(self.power_tab), power_label)
+ main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ main_box.pack_start(sidebar_box, False, False, 0)
+ main_box.pack_start(self.settings_stack, True, True, 0)
+ self.get_content_area().pack_start(main_box, True, True, 0)
- # General tab - least important (application behavior)
- general_label = Gtk.Label(label="General")
- general_label.set_tooltip_text("General settings")
- notebook.append_page(_scrollable(self.general_tab), general_label)
-
- # Advanced tab - whisper.cpp parameters and power-user features (remote API, etc.)
- advanced_label = Gtk.Label(label="Advanced")
- advanced_label.set_tooltip_text(
- "Advanced whisper.cpp parameters and settings for power users"
- )
- self.advanced_page_num = notebook.append_page(
- _scrollable(self.advanced_tab), advanced_label
- )
- notebook.connect("switch-page", self._on_settings_page_switched)
-
- self.get_content_area().pack_start(notebook, True, True, 0)
+ # Search state: baseline visibility snapshot while a query is active.
+ self._search_baseline = None
+ self._search_previous_page = None
# 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_auto_pause_section()
- self._build_model_keepalive_section()
- self._build_audio_section()
+ # Build UI sections into their topic pages
+ self._build_shortcuts_section()
+ self._build_recognition_section()
self._build_engine_section()
self._build_remote_server_section()
- self._build_recognition_section()
- self._build_shortcuts_section()
+ self._build_audio_section()
+ self._build_auto_pause_section()
+ self._build_model_keepalive_section()
+ self._build_gpu_section()
+ self._build_general_section()
self._build_advanced_section()
- self._build_test_section()
+ self._build_status_strip()
+
+ # Record searchable groups vs. loose extras per page
+ for page in self._pages:
+ page.collect_children()
# Load settings and populate UI
self._load_and_apply_settings()
self.connect("response", self._on_settings_dialog_response)
+ self.connect("key-press-event", self._on_dialog_key_press)
# Show everything first
self.show_all()
+ self.sidebar_listbox.select_row(self.sidebar_listbox.get_row_at_index(0))
# Then update visibility of engine-specific elements
- self._update_advanced_reset_button_visibility()
self._update_engine_specific_ui()
# Initialize recognition progress UI
@@ -1186,6 +1250,187 @@ class SettingsDialog(Gtk.Dialog):
# Initialization complete - enable auto-apply
self._initializing = False
+ # ------------------------------------------------------------------
+ # Navigation: sidebar, stack, and settings search
+ # ------------------------------------------------------------------
+
+ def _build_sidebar_row(self, page: SettingsPage) -> Gtk.ListBoxRow:
+ """Build one sidebar navigation row (icon + title + match badge)."""
+ row = Gtk.ListBoxRow()
+ row.get_style_context().add_class("sidebar-row")
+ row.page_name = page.name
+
+ hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ icon = Gtk.Image.new_from_icon_name(page.icon_name, Gtk.IconSize.MENU)
+ hbox.pack_start(icon, False, False, 0)
+
+ label = Gtk.Label(label=page.title, xalign=0)
+ hbox.pack_start(label, True, True, 0)
+
+ match_label = Gtk.Label(label="")
+ match_label.get_style_context().add_class("sidebar-match-count")
+ match_label.set_no_show_all(True)
+ hbox.pack_end(match_label, False, False, 0)
+
+ row.add(hbox)
+ page.sidebar_row = row
+ page.match_count_label = match_label
+ return row
+
+ def _build_search_empty_page(self) -> Gtk.Widget:
+ """Build the stack page shown when a search has no results."""
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ box.set_valign(Gtk.Align.CENTER)
+ box.set_halign(Gtk.Align.CENTER)
+
+ icon = Gtk.Image.new_from_icon_name("edit-find-symbolic", Gtk.IconSize.DIALOG)
+ icon.set_opacity(0.4)
+ box.pack_start(icon, False, False, 0)
+
+ title = Gtk.Label(label="No matching settings")
+ title.get_style_context().add_class("search-empty-title")
+ box.pack_start(title, False, False, 0)
+
+ self.search_empty_label = Gtk.Label(label="Try a different search term.")
+ self.search_empty_label.get_style_context().add_class("preference-row-subtitle")
+ box.pack_start(self.search_empty_label, False, False, 0)
+ return box
+
+ def _on_sidebar_row_selected(self, listbox, row):
+ """Switch the stack to the page selected in the sidebar."""
+ if row is not None:
+ self.settings_stack.set_visible_child_name(row.page_name)
+
+ def _on_settings_page_changed(self, stack, pspec):
+ """Persist deferred edits when the visible settings page changes."""
+ if stack.get_visible_child_name() != "advanced":
+ self._flush_advanced_prompt_if_dirty()
+
+ def _on_dialog_key_press(self, widget, event):
+ """Dialog-level shortcuts: Ctrl+F focuses search, Ctrl+W closes, Esc clears search."""
+ keyname = (Gdk.keyval_name(event.keyval) or "").lower()
+ ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK)
+
+ if ctrl and keyname == "f":
+ self.search_entry.grab_focus()
+ return True
+ if ctrl and keyname == "w":
+ self.response(Gtk.ResponseType.CLOSE)
+ return True
+ if keyname == "escape" and self.search_entry.get_text():
+ # First Esc clears the search; a second one closes the dialog
+ # through the default Gtk.Dialog binding.
+ self.search_entry.set_text("")
+ return True
+ return False
+
+ def _snapshot_search_baseline(self):
+ """Remember pre-search visibility so clearing the query restores it."""
+ visible_page = self.settings_stack.get_visible_child_name()
+ page_names = {page.name for page in self._pages}
+ self._search_previous_page = visible_page if visible_page in page_names else "dictation"
+
+ baseline = {"rows": {}, "groups": {}, "extras": {}}
+ for page in self._pages:
+ for group in page.groups:
+ visible = group.get_visible()
+ parent = group.get_parent()
+ while visible and parent is not None and parent is not page.box:
+ if isinstance(parent, Gtk.Revealer) and not parent.get_reveal_child():
+ visible = False
+ else:
+ visible = parent.get_visible()
+ parent = parent.get_parent()
+ baseline["groups"][group] = visible
+ for row in group.rows:
+ baseline["rows"][row] = row.get_visible()
+ for extra in page.extras:
+ baseline["extras"][extra] = extra.get_visible()
+ self._search_baseline = baseline
+
+ def _restore_search_baseline(self):
+ """Restore visibility recorded before the search started."""
+ if self._search_baseline is None:
+ return
+ for widgets in self._search_baseline.values():
+ for widget, visible in widgets.items():
+ widget.set_visible(visible)
+ self._search_baseline = None
+
+ for page in self._pages:
+ page.match_count_label.hide()
+ page.sidebar_row.set_sensitive(True)
+ page.sidebar_row.show()
+
+ # Engine-driven visibility is authoritative; re-apply it in case a
+ # control changed while the filter was active.
+ self._update_engine_specific_ui()
+
+ page = next(
+ (page for page in self._pages if page.name == self._search_previous_page),
+ self._pages[0],
+ )
+ self.sidebar_listbox.select_row(page.sidebar_row)
+ self.settings_stack.set_visible_child_name(page.name)
+ self._search_previous_page = None
+
+ def _on_search_changed(self, entry):
+ """Live-filter settings rows across all pages."""
+ query = entry.get_text().strip()
+
+ if not query:
+ self._restore_search_baseline()
+ return
+
+ if self._search_baseline is None:
+ self._snapshot_search_baseline()
+
+ baseline = self._search_baseline
+ first_match_page = None
+ for page in self._pages:
+ page_matches = 0
+ for group in page.groups:
+ if not baseline["groups"].get(group, True):
+ # Whole group hidden by engine-specific logic; skip it.
+ group.hide()
+ continue
+ group_title_match = _row_matches_query(
+ query, group.title, group.description, group.keywords
+ )
+ visible_rows = 0
+ for row in group.rows:
+ if not baseline["rows"].get(row, True):
+ row.hide()
+ continue
+ row_matches = group_title_match or (
+ isinstance(row, PreferenceRow) and row.matches_query(query)
+ )
+ row.set_visible(row_matches)
+ if row_matches:
+ visible_rows += 1
+ group.set_visible(visible_rows > 0)
+ page_matches += visible_rows
+ for extra in page.extras:
+ extra.hide()
+
+ if page_matches > 0:
+ page.match_count_label.set_text(str(page_matches))
+ page.match_count_label.show()
+ page.sidebar_row.set_sensitive(True)
+ if first_match_page is None:
+ first_match_page = page
+ else:
+ page.match_count_label.hide()
+ page.sidebar_row.set_sensitive(False)
+
+ if first_match_page is not None:
+ self.sidebar_listbox.select_row(first_match_page.sidebar_row)
+ self.settings_stack.set_visible_child_name(first_match_page.name)
+ else:
+ self.sidebar_listbox.unselect_all()
+ self.search_empty_label.set_text(f"No settings match \u201c{query}\u201d.")
+ self.settings_stack.set_visible_child_name("search-empty")
+
def _build_audio_section(self):
"""Build the Audio Input section."""
group = PreferencesGroup(title="Audio Input")
@@ -1196,7 +1441,7 @@ class SettingsDialog(Gtk.Dialog):
self.audio_device_combo.set_tooltip_text(
"Select the microphone to use for voice recognition"
)
- self.audio_device_combo.set_size_request(250, -1)
+ self.audio_device_combo.set_size_request(_CONTROL_WIDTH, -1)
_prevent_scroll_on_hover(self.audio_device_combo)
device_box.pack_start(self.audio_device_combo, True, True, 0)
@@ -1210,38 +1455,24 @@ class SettingsDialog(Gtk.Dialog):
title="Input Device",
subtitle="Select the microphone for voice recognition",
widget=device_box,
+ keywords=("microphone", "mic", "input"),
)
group.add_row(device_row)
- # Audio level test row
- level_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
- self.audio_level_bar = Gtk.LevelBar()
- self.audio_level_bar.set_min_value(0)
- self.audio_level_bar.set_max_value(100)
- self.audio_level_bar.set_value(0)
- self.audio_level_bar.set_size_request(150, -1)
- level_box.pack_start(self.audio_level_bar, True, True, 0)
-
+ # Microphone test row; the live level shows in the status strip below.
self.test_audio_btn = Gtk.Button(label="Test")
- self.test_audio_btn.set_tooltip_text("Test the microphone for 2 seconds")
+ self.test_audio_btn.set_tooltip_text("Record 2 seconds and check the level")
self.test_audio_btn.connect("clicked", self._on_test_audio_clicked)
- level_box.pack_start(self.test_audio_btn, False, False, 0)
level_row = PreferenceRow(
- title="Audio Level",
- subtitle="Test your microphone",
- widget=level_box,
+ title="Microphone Test",
+ subtitle="Record 2 seconds; the level meter below shows the result",
+ widget=self.test_audio_btn,
+ keywords=("audio level", "check", "volume"),
)
group.add_row(level_row)
- # Status label for audio testing (added below the group)
- self.audio_test_status = Gtk.Label(label="", use_markup=True, xalign=0)
- self.audio_test_status.set_margin_start(16)
- self.audio_test_status.set_margin_top(4)
- self.audio_test_status.get_style_context().add_class("status-info")
-
self.audio_tab.pack_start(group, False, False, 0)
- self.audio_tab.pack_start(self.audio_test_status, False, False, 0)
# Sound Effects section
sound_group = PreferencesGroup(title="Sound Effects")
@@ -1263,44 +1494,31 @@ class SettingsDialog(Gtk.Dialog):
self.audio_device_combo.connect("changed", self._on_audio_device_changed)
def _build_general_section(self):
- """Build the General section with autostart and UI settings."""
- group = PreferencesGroup(title="General")
+ """Build the Application page: startup behavior."""
+ group = PreferencesGroup(title="Startup")
self.autostart_switch = Gtk.Switch()
- self.autostart_switch.set_tooltip_text("Start Vocalinux automatically when you log in")
autostart_row = PreferenceRow(
title="Start on Login",
subtitle="Automatically start Vocalinux when you log in",
widget=self.autostart_switch,
+ keywords=("autostart", "boot", "startup"),
)
group.add_row(autostart_row)
self.start_minimized_switch = Gtk.Switch()
- self.start_minimized_switch.set_tooltip_text("Start minimized to system tray")
start_minimized_row = PreferenceRow(
title="Start Minimized",
subtitle="Start minimized to system tray instead of showing window",
widget=self.start_minimized_switch,
+ keywords=("tray",),
)
group.add_row(start_minimized_row)
- self.copy_to_clipboard_switch = Gtk.Switch()
- self.copy_to_clipboard_switch.set_tooltip_text(
- "Copy recognized text to clipboard after each transcription. "
- "Useful if injection fails or you want to paste elsewhere."
- )
- copy_to_clipboard_row = PreferenceRow(
- title="Copy to Clipboard",
- subtitle="Always copy recognized text to clipboard for easy pasting",
- widget=self.copy_to_clipboard_switch,
- )
- group.add_row(copy_to_clipboard_row)
-
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)
- self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled)
def _build_auto_pause_section(self):
"""Build Auto-Pause settings: enable toggle + process name list."""
@@ -1311,6 +1529,7 @@ class SettingsDialog(Gtk.Dialog):
"unloads the speech model to free CPU, GPU, and memory. Everything "
"resumes automatically when the app closes."
),
+ keywords=("process", "program", "game"),
)
self.auto_pause_switch = Gtk.Switch()
@@ -1357,14 +1576,29 @@ class SettingsDialog(Gtk.Dialog):
add_row.add(add_box)
group.add_row(add_row)
- # List of configured apps
+ # List of configured apps; the empty-state text is an in-list
+ # placeholder so there is no blank hole when the list is empty.
self.auto_pause_listbox = Gtk.ListBox()
self.auto_pause_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self.auto_pause_listbox.set_activate_on_single_click(False)
+ self.auto_pause_empty_label = Gtk.Label(
+ label="No apps in the list yet. Type a process name or choose a running app above.",
+ xalign=0.5,
+ wrap=True,
+ justify=Gtk.Justification.CENTER,
+ )
+ self.auto_pause_empty_label.get_style_context().add_class("preference-row-subtitle")
+ self.auto_pause_empty_label.set_margin_top(12)
+ self.auto_pause_empty_label.set_margin_bottom(12)
+ self.auto_pause_empty_label.set_margin_start(16)
+ self.auto_pause_empty_label.set_margin_end(16)
+ self.auto_pause_empty_label.show()
+ self.auto_pause_listbox.set_placeholder(self.auto_pause_empty_label)
+
list_scrolled = Gtk.ScrolledWindow()
list_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
- list_scrolled.set_min_content_height(80)
+ list_scrolled.set_min_content_height(48)
list_scrolled.set_max_content_height(160)
list_scrolled.set_margin_start(8)
list_scrolled.set_margin_end(8)
@@ -1376,24 +1610,6 @@ class SettingsDialog(Gtk.Dialog):
list_row.add(list_scrolled)
group.add_row(list_row)
- self.auto_pause_empty_label = Gtk.Label(
- label="No apps in the list yet. Type a process name or choose a running app above.",
- xalign=0,
- )
- self.auto_pause_empty_label.get_style_context().add_class("preference-row-subtitle")
- self.auto_pause_empty_label.set_margin_start(16)
- self.auto_pause_empty_label.set_margin_end(16)
- self.auto_pause_empty_label.set_margin_bottom(12)
-
- empty_row = Gtk.ListBoxRow()
- empty_row.set_activatable(False)
- empty_row.add(self.auto_pause_empty_label)
- group.add_row(empty_row)
- # Keep the dialog-wide show_all() from re-showing this row when the
- # list is non-empty; visibility is managed in _refresh_auto_pause_list.
- empty_row.set_no_show_all(True)
- self._auto_pause_empty_row = empty_row
-
self.power_tab.pack_start(group, False, False, 0)
self.auto_pause_switch.connect("state-set", self._on_auto_pause_enabled_toggled)
@@ -1438,13 +1654,6 @@ class SettingsDialog(Gtk.Dialog):
self.auto_pause_listbox.remove(child)
apps = self._get_auto_pause_apps()
- if hasattr(self, "_auto_pause_empty_row"):
- if len(apps) == 0:
- self.auto_pause_empty_label.show()
- self._auto_pause_empty_row.show()
- else:
- self._auto_pause_empty_row.hide()
-
for name in apps:
row = Gtk.ListBoxRow()
row.set_activatable(False)
@@ -1599,7 +1808,7 @@ class SettingsDialog(Gtk.Dialog):
group.add_row(enable_row)
self.model_keepalive_timeout_combo = Gtk.ComboBoxText()
- self.model_keepalive_timeout_combo.set_size_request(160, -1)
+ self.model_keepalive_timeout_combo.set_size_request(_CONTROL_WIDTH, -1)
# id = seconds as string
for seconds, label in (
(60, "1 minute"),
@@ -1714,7 +1923,7 @@ class SettingsDialog(Gtk.Dialog):
# Engine selection
self.engine_combo = Gtk.ComboBoxText()
- self.engine_combo.set_size_request(180, -1)
+ self.engine_combo.set_size_request(_CONTROL_WIDTH, -1)
_prevent_scroll_on_hover(self.engine_combo)
engine_row = PreferenceRow(
title="Engine",
@@ -1725,7 +1934,7 @@ class SettingsDialog(Gtk.Dialog):
# Model size selection
self.model_combo = Gtk.ComboBoxText()
- self.model_combo.set_size_request(180, -1)
+ self.model_combo.set_size_request(_CONTROL_WIDTH, -1)
self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP)
_prevent_scroll_on_hover(self.model_combo)
self.model_row = PreferenceRow(
@@ -1738,7 +1947,7 @@ class SettingsDialog(Gtk.Dialog):
# whisper.cpp specialization selection
self.model_variant_combo = Gtk.ComboBoxText()
- self.model_variant_combo.set_size_request(220, -1)
+ self.model_variant_combo.set_size_request(_CONTROL_WIDTH, -1)
self.model_variant_combo.set_tooltip_text(MODEL_SPECIALIZATION_TOOLTIP)
_prevent_scroll_on_hover(self.model_variant_combo)
self.model_variant_row = PreferenceRow(
@@ -1751,7 +1960,7 @@ class SettingsDialog(Gtk.Dialog):
# Language selection
self.language_combo = Gtk.ComboBoxText()
- self.language_combo.set_size_request(180, -1)
+ self.language_combo.set_size_request(_CONTROL_WIDTH, -1)
self.language_combo.set_tooltip_text(LANGUAGE_TOOLTIP)
_prevent_scroll_on_hover(self.language_combo)
self.language_row = PreferenceRow(
@@ -1782,30 +1991,22 @@ class SettingsDialog(Gtk.Dialog):
self.model_recommendation.get_style_context().add_class("tip-label")
self.model_info_card.pack_start(self.model_recommendation, False, False, 0)
- self.content_box.pack_start(self.model_info_card, False, False, 0)
-
- # Language warning (for auto-detect)
- self.language_warning = Gtk.Label(label="", use_markup=True, xalign=0)
- self.language_warning.set_margin_start(16)
+ # Language warning (e.g. auto-detect, English-only models) lives in
+ # the card so there is a single explanation surface below the group.
+ self.language_warning = Gtk.Label(label="", use_markup=True, xalign=0, wrap=True)
self.language_warning.get_style_context().add_class("status-warning")
- self.content_box.pack_start(self.language_warning, False, False, 0)
+ self.language_warning.set_no_show_all(True)
+ self.model_info_card.pack_start(self.language_warning, False, False, 0)
- # Legend
- self.legend_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16)
- self.legend_box.set_halign(Gtk.Align.CENTER)
- self.legend_box.set_margin_top(4)
- self.legend_box.set_margin_bottom(4)
+ # Symbol legend as a muted caption inside the card
+ legend = Gtk.Label(
+ label="✓ Downloaded ↓ Will download ★ Recommended",
+ xalign=0,
+ )
+ legend.get_style_context().add_class("tip-label")
+ self.model_info_card.pack_start(legend, False, False, 0)
- for symbol, text in [
- ("✓", "Downloaded"),
- ("↓", "Will download"),
- ("★", "Recommended"),
- ]:
- item = Gtk.Label(label=f"{symbol} {text}")
- item.get_style_context().add_class("status-info")
- self.legend_box.pack_start(item, False, False, 0)
-
- self.content_box.pack_start(self.legend_box, False, False, 0)
+ self.content_box.pack_start(self.model_info_card, False, False, 0)
# Connect signals
self.engine_combo.connect("changed", self._on_engine_changed)
@@ -1904,8 +2105,8 @@ class SettingsDialog(Gtk.Dialog):
threading.Thread(target=test_connection, daemon=True).start()
def _build_recognition_section(self):
- """Build the Recognition Settings section."""
- group = PreferencesGroup(title="Recognition Settings")
+ """Build the Listening and Output groups on the Dictation page."""
+ group = PreferencesGroup(title="Listening")
# VAD Sensitivity
self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1)
@@ -1913,14 +2114,15 @@ class SettingsDialog(Gtk.Dialog):
_prevent_scroll_on_hover(self.vad_spin)
silero_active = is_silero_available()
vad_subtitle = (
- "Sensitivity (1-5) -- backend: Silero neural VAD"
+ "Sensitivity to quiet speech (1-5) — Silero neural VAD"
if silero_active
- else "Sensitivity (1-5) -- backend: amplitude (install vocalinux[vad] for neural)"
+ else "Sensitivity to quiet speech (1-5) — amplitude backend"
)
self.vad_row = PreferenceRow(
- title="VAD Sensitivity",
+ title="Microphone Sensitivity",
subtitle=vad_subtitle,
widget=self.vad_spin,
+ keywords=("vad", "voice activity detection"),
)
group.add_row(self.vad_row)
@@ -1930,13 +2132,18 @@ class SettingsDialog(Gtk.Dialog):
self.silence_spin.set_tooltip_text("Wait time after silence before processing speech")
_prevent_scroll_on_hover(self.silence_spin)
silence_row = PreferenceRow(
- title="Silence Timeout",
- subtitle="Seconds of silence before processing",
+ title="Stop After Silence",
+ subtitle="Seconds of silence before processing what you said",
widget=self.silence_spin,
+ keywords=("timeout", "pause"),
)
group.add_row(silence_row)
- # Voice Commands Toggle
+ self.recognition_settings_tab.pack_start(group, False, False, 0)
+
+ # Output group: what happens with the recognized text
+ output_group = PreferencesGroup(title="Output")
+
self.voice_commands_switch = Gtk.Switch()
self.voice_commands_switch.set_tooltip_text(
"Enable voice commands like 'new line', 'period', 'undo', etc.\n"
@@ -1944,12 +2151,27 @@ class SettingsDialog(Gtk.Dialog):
)
voice_commands_row = PreferenceRow(
title="Voice Commands",
- subtitle="Enable voice commands for punctuation and editing",
+ subtitle="Say 'new line', 'period', 'undo' while dictating",
widget=self.voice_commands_switch,
+ keywords=("punctuation", "editing"),
)
- group.add_row(voice_commands_row)
+ output_group.add_row(voice_commands_row)
- self.recognition_settings_tab.pack_start(group, False, False, 0)
+ self.copy_to_clipboard_switch = Gtk.Switch()
+ self.copy_to_clipboard_switch.set_tooltip_text(
+ "Copy recognized text to clipboard after each transcription. "
+ "Useful if injection fails or you want to paste elsewhere."
+ )
+ copy_to_clipboard_row = PreferenceRow(
+ title="Copy to Clipboard",
+ subtitle="Always copy recognized text to clipboard for easy pasting",
+ widget=self.copy_to_clipboard_switch,
+ keywords=("paste",),
+ )
+ output_group.add_row(copy_to_clipboard_row)
+
+ self.recognition_settings_tab.pack_start(output_group, False, False, 0)
+ self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled)
if not silero_active:
vad_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
@@ -1992,7 +2214,7 @@ class SettingsDialog(Gtk.Dialog):
# Mode selection (Toggle vs Push-to-Talk)
self.shortcut_mode_combo = Gtk.ComboBoxText()
- self.shortcut_mode_combo.set_size_request(200, -1)
+ self.shortcut_mode_combo.set_size_request(_CONTROL_WIDTH, -1)
self.shortcut_mode_combo.set_tooltip_text(
"Choose between toggle (double-tap) or push-to-talk mode"
)
@@ -2016,7 +2238,7 @@ class SettingsDialog(Gtk.Dialog):
# Shortcut selection combo
self.shortcut_combo = Gtk.ComboBoxText()
- self.shortcut_combo.set_size_request(200, -1)
+ self.shortcut_combo.set_size_request(_CONTROL_WIDTH, -1)
self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut for voice typing")
_prevent_scroll_on_hover(self.shortcut_combo)
@@ -2069,7 +2291,10 @@ class SettingsDialog(Gtk.Dialog):
title="Custom Shortcut",
subtitle="Modifier + key combo (great for split keyboards)",
widget=custom_box,
+ keywords=("record", "keybinding", "hotkey"),
)
+ # Only shown while "Custom Shortcut" is selected in the preset combo.
+ self.custom_shortcut_row.set_no_show_all(True)
group.add_row(self.custom_shortcut_row)
# Key-capture state for the Record button.
@@ -2136,9 +2361,11 @@ class SettingsDialog(Gtk.Dialog):
if self._is_preset_shortcut(shortcut):
self._set_shortcut_combo_active_id(shortcut)
self.custom_shortcut_entry.set_text("")
+ self.custom_shortcut_row.hide()
else:
self._set_shortcut_combo_active_id("__custom__")
self.custom_shortcut_entry.set_text(shortcut)
+ self.custom_shortcut_row.show_all()
def _report_shortcut_apply_result(self, display_name: str, applied: bool) -> None:
"""Show success/restart feedback after a shortcut change."""
@@ -2355,6 +2582,7 @@ class SettingsDialog(Gtk.Dialog):
current = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl")
if not self._is_preset_shortcut(current):
self.custom_shortcut_entry.set_text(current)
+ self.custom_shortcut_row.show_all()
self.custom_shortcut_entry.grab_focus()
self.shortcut_info_label.set_markup(
"Record or type a custom shortcut (e.g. alt+r), then click Set."
@@ -2363,6 +2591,7 @@ class SettingsDialog(Gtk.Dialog):
# Preset selected: clear any leftover custom entry so UI matches config.
self.custom_shortcut_entry.set_text("")
+ self.custom_shortcut_row.hide()
self.config_manager.set("shortcuts", "toggle_recognition", shortcut_id)
self.config_manager.save_settings()
@@ -2375,24 +2604,62 @@ class SettingsDialog(Gtk.Dialog):
applied = bool(self.shortcut_update_callback(shortcut_id, mode_id))
self._report_shortcut_apply_result(display_name, applied)
- def _build_test_section(self):
- """Build the Test Recognition section."""
- group = PreferencesGroup(title="Test Recognition")
+ def _build_status_strip(self):
+ """Build the persistent status strip at the bottom of the dialog.
- # Test area inside the group's listbox as a custom row
- test_container = Gtk.ListBoxRow()
- test_container.set_activatable(False)
- test_container.get_style_context().add_class("preference-row")
+ 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.
+ """
+ strip = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ strip.get_style_context().add_class("status-strip")
- test_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
- test_box.set_margin_top(12)
- test_box.set_margin_bottom(12)
- test_box.set_margin_start(16)
- test_box.set_margin_end(16)
+ bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
+
+ 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)
+
+ 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)
+
+ # One shared level bar: recognition progress and the microphone test
+ # both report into it (recognition_audio_level is a legacy alias).
+ self.audio_level_bar = Gtk.LevelBar()
+ self.audio_level_bar.set_min_value(0)
+ self.audio_level_bar.set_max_value(100)
+ 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)
+
+ 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)
+
+ # 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)
+
+ # Test transcription output, revealed while testing
+ self.test_output_revealer = Gtk.Revealer()
+ self.test_output_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
- # Text view for test results
scrolled_window = Gtk.ScrolledWindow()
- scrolled_window.set_min_content_height(80)
+ scrolled_window.set_min_content_height(60)
+ scrolled_window.set_max_content_height(100)
scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled_window.get_style_context().add_class("test-area")
@@ -2403,59 +2670,11 @@ class SettingsDialog(Gtk.Dialog):
self.test_textview.get_style_context().add_class("test-textview")
self.test_buffer = self.test_textview.get_buffer()
scrolled_window.add(self.test_textview)
- test_box.pack_start(scrolled_window, True, True, 0)
+ self.test_output_revealer.add(scrolled_window)
- # Test button
- self.test_button = Gtk.Button(label="Start Test (3 seconds)")
- self.test_button.get_style_context().add_class("suggested-action")
- self.test_button.connect("clicked", self._on_test_clicked)
- test_box.pack_start(self.test_button, False, False, 0)
+ strip.pack_start(self.test_output_revealer, False, False, 0)
- test_container.add(test_box)
- group.listbox.add(test_container)
-
- self.recognition_settings_tab.pack_start(group, False, False, 0)
-
- # Recognition Progress section
- progress_group = PreferencesGroup(title="Recognition Status")
-
- # Status row
- status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
- self.recognition_status_label = Gtk.Label(label="Idle", xalign=0)
- status_box.pack_start(self.recognition_status_label, True, True, 0)
-
- self.recognition_indicator = Gtk.Image.new_from_icon_name(
- "media-record-symbolic", Gtk.IconSize.MENU
- )
- self.recognition_indicator.set_opacity(0.3)
- status_box.pack_end(self.recognition_indicator, False, False, 0)
-
- status_row = PreferenceRow(
- title="Status",
- widget=status_box,
- )
- progress_group.add_row(status_row)
-
- # Audio level row
- self.recognition_audio_level = Gtk.LevelBar()
- self.recognition_audio_level.set_min_value(0)
- self.recognition_audio_level.set_max_value(100)
- self.recognition_audio_level.set_value(0)
- self.recognition_audio_level.set_size_request(150, -1)
-
- level_row = PreferenceRow(
- title="Audio Level",
- widget=self.recognition_audio_level,
- )
- progress_group.add_row(level_row)
-
- 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.recognition_settings_tab.pack_start(self.progress_info_label, False, False, 0)
+ self.get_content_area().pack_start(strip, False, False, 0)
def _build_advanced_section(self):
"""Build the Advanced section with whisper.cpp parameters."""
@@ -2612,26 +2831,16 @@ class SettingsDialog(Gtk.Dialog):
controls_box.pack_start(info_box, False, False, 0)
controls_box.pack_start(group, False, False, 0)
- gpu_group = PreferencesGroup(title="GPU Device")
- self.gpu_device_combo = Gtk.ComboBoxText()
- self.gpu_device_combo.set_size_request(250, -1)
- self.gpu_device_combo.set_tooltip_text(
- "Select which GPU to use for whisper.cpp Vulkan acceleration"
+ # In-page reset action for the decoding parameters above
+ self.advanced_reset_button = Gtk.Button(label="Reset to Defaults")
+ self.advanced_reset_button.set_tooltip_text(
+ "Restore the whisper.cpp advanced parameters to their default values"
)
- _prevent_scroll_on_hover(self.gpu_device_combo)
- self._populate_gpu_devices()
- gpu_row = PreferenceRow(
- title="Vulkan GPU",
- subtitle="Device for hardware acceleration",
- widget=self.gpu_device_combo,
- )
- gpu_group.add_row(gpu_row)
- controls_box.pack_start(gpu_group, False, False, 0)
-
- separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
- separator.set_margin_top(8)
- separator.set_margin_bottom(8)
- controls_box.pack_start(separator, False, False, 0)
+ self.advanced_reset_button.connect("clicked", self._on_reset_advanced_clicked)
+ reset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ reset_row.set_halign(Gtk.Align.END)
+ reset_row.pack_start(self.advanced_reset_button, False, False, 0)
+ controls_box.pack_start(reset_row, False, False, 0)
self.advanced_revealer.add(controls_box)
self.advanced_tab.pack_start(self.advanced_revealer, False, False, 0)
@@ -2647,10 +2856,33 @@ class SettingsDialog(Gtk.Dialog):
self.advanced_initial_prompt_buffer = self.advanced_initial_prompt_textview.get_buffer()
self.advanced_initial_prompt_buffer.connect("changed", self._on_advanced_prompt_changed)
- self.gpu_device_combo.connect("changed", self._on_advanced_param_changed)
-
self.power_user_switch.connect("state-set", self._on_power_user_toggled)
+ def _build_gpu_section(self):
+ """Build the GPU device group on the Performance page.
+
+ Hardware selection, not a decoding knob — so it lives ungated on the
+ Performance page rather than behind the power-user unlock.
+ """
+ gpu_group = PreferencesGroup(title="Hardware Acceleration")
+ self.gpu_device_combo = Gtk.ComboBoxText()
+ self.gpu_device_combo.set_size_request(_CONTROL_WIDTH, -1)
+ self.gpu_device_combo.set_tooltip_text(
+ "Select which GPU to use for whisper.cpp Vulkan acceleration"
+ )
+ _prevent_scroll_on_hover(self.gpu_device_combo)
+ self._populate_gpu_devices()
+ gpu_row = PreferenceRow(
+ title="Vulkan GPU",
+ subtitle="Device used by the whisper.cpp engine",
+ widget=self.gpu_device_combo,
+ keywords=("graphics", "hardware", "acceleration", "device"),
+ )
+ gpu_group.add_row(gpu_row)
+ self.power_tab.pack_start(gpu_group, False, False, 0)
+
+ self.gpu_device_combo.connect("changed", self._on_advanced_param_changed)
+
def _build_remote_server_section(self):
"""Build the Remote Server configuration section (shown when Remote API engine is selected)."""
self.remote_server_group = PreferencesGroup(
@@ -2803,18 +3035,6 @@ class SettingsDialog(Gtk.Dialog):
if response_id in (Gtk.ResponseType.CLOSE, Gtk.ResponseType.DELETE_EVENT):
self._flush_advanced_prompt_if_dirty()
- def _on_settings_page_switched(self, notebook, page, page_num):
- """Update contextual footer actions when the active settings page changes."""
- if page_num != self.advanced_page_num:
- self._flush_advanced_prompt_if_dirty()
- self._update_advanced_reset_button_visibility(page_num)
-
- def _update_advanced_reset_button_visibility(self, page_num: int = None):
- """Show the reset action only on the Advanced settings page."""
- if page_num is None:
- page_num = self.settings_notebook.get_current_page()
- self.advanced_reset_button.set_visible(page_num == self.advanced_page_num)
-
def _on_advanced_prompt_changed(self, buffer):
"""Track prompt edits without applying settings on every keystroke."""
if self._initializing or self._applying_settings:
@@ -3378,8 +3598,9 @@ class SettingsDialog(Gtk.Dialog):
)
self.language_warning.show()
elif lang_info.get("warning"):
+ lang_name = lang_info.get("name", "This language")
self.language_warning.set_markup(
- f"⚠ {lang_info['warning']}"
+ f"⚠ {lang_name}: {lang_info['warning'].lower()}"
)
self.language_warning.show()
else:
@@ -3418,7 +3639,6 @@ class SettingsDialog(Gtk.Dialog):
self.model_row.hide()
self.model_variant_row.hide()
self.model_info_card.hide()
- self.legend_box.hide()
self.remote_server_group.show_all()
self.remote_status_label.show()
else:
@@ -3427,7 +3647,6 @@ class SettingsDialog(Gtk.Dialog):
self.model_variant_row.show_all()
else:
self.model_variant_row.hide()
- self.legend_box.show_all()
self.remote_server_group.hide()
self.remote_status_label.hide()
@@ -3733,7 +3952,8 @@ class SettingsDialog(Gtk.Dialog):
self._test_active = True
self.test_button.set_sensitive(False)
- self.test_button.set_label("Testing... Speak Now!")
+ self.test_button.set_label("Testing… Speak Now!")
+ self.test_output_revealer.set_reveal_child(True)
self.test_buffer.set_text("")
self._test_result = ""
@@ -3778,7 +3998,7 @@ class SettingsDialog(Gtk.Dialog):
self._test_active = False
self.test_button.set_sensitive(True)
- self.test_button.set_label("Start Test (3 seconds)")
+ self.test_button.set_label("Test Dictation")
self.update_recognition_progress("Idle")
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index 75155f7..d4ba596 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -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()
diff --git a/tests/test_settings_shortcuts.py b/tests/test_settings_shortcuts.py
index 3133dab..3b3c4cf 100644
--- a/tests/test_settings_shortcuts.py
+++ b/tests/test_settings_shortcuts.py
@@ -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."""