From 7cdf5a7069408a4f8f07ab18b4af75f64b23cc22 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sun, 30 Aug 2026 00:20:27 -0700 Subject: [PATCH] feat(settings): even out dropdowns and tidy the About page (#754) * feat(settings): even out dropdowns and tidy the About page Pin combos to a shared right-hand column so long labels cannot stretch a row. Fold the dictation-tone preview into a two-stage Start/Stop button, quiet the model status card, shorten Performance copy, and rebuild About with combined links, channel+Check, family platform tiles, and Talk to us moved up next to the license. * fix(settings): keep tone preview on start if the cue fails Do not flip Start/Stop when preview_tone_cue returns false, so a missing file or silent player cannot skip the start cue. Also annotate PreferenceRow.set_title / set_subtitle. * fix(settings): paint About icons for dark dialogs Dark ink on Adwaita/Yaru-dark buttons made the family tiles and Talk to us marks disappear. Pick a light foreground on dark themes (and keep the original ink on light ones) so the marks match the labels. * fix(settings): show Android and iOS marks on VocaPhone VocaPhone covers both, so the family tile now uses the Android robot and the Apple mark instead of a single phone icon. * fix(settings): cut repeated About copy Name the AGPL once on the License row. The VocaHQ blurb is just the private-dictation line. VocaPhone says TestFlight, not source-only, and Gateway is self-hosted and headless. * fix(settings): space About icons from their labels Talk-to-us buttons and the family tiles were packing the mark against the text. Give the image an end margin and a wider gap in the family tile. --- resources/icons/scalable/platform-android.svg | 1 + resources/icons/scalable/platform-apple.svg | 1 + resources/icons/scalable/platform-home.svg | 4 + resources/icons/scalable/platform-linux.svg | 1 + resources/icons/scalable/platform-server.svg | 1 + .../icons/scalable/platform-android.svg | 1 + .../icons/scalable/platform-apple.svg | 1 + .../icons/scalable/platform-home.svg | 4 + .../icons/scalable/platform-linux.svg | 1 + .../icons/scalable/platform-server.svg | 1 + src/vocalinux/ui/audio_feedback.py | 9 + src/vocalinux/ui/settings_dialog.py | 736 ++++++++++++------ tests/test_audio_feedback.py | 13 + tests/test_settings_dialog.py | 140 +++- tests/test_settings_shortcuts.py | 2 +- 15 files changed, 666 insertions(+), 250 deletions(-) create mode 100644 resources/icons/scalable/platform-android.svg create mode 100644 resources/icons/scalable/platform-apple.svg create mode 100644 resources/icons/scalable/platform-home.svg create mode 100644 resources/icons/scalable/platform-linux.svg create mode 100644 resources/icons/scalable/platform-server.svg create mode 100644 src/vocalinux/resources/icons/scalable/platform-android.svg create mode 100644 src/vocalinux/resources/icons/scalable/platform-apple.svg create mode 100644 src/vocalinux/resources/icons/scalable/platform-home.svg create mode 100644 src/vocalinux/resources/icons/scalable/platform-linux.svg create mode 100644 src/vocalinux/resources/icons/scalable/platform-server.svg diff --git a/resources/icons/scalable/platform-android.svg b/resources/icons/scalable/platform-android.svg new file mode 100644 index 0000000..16bbfab --- /dev/null +++ b/resources/icons/scalable/platform-android.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/scalable/platform-apple.svg b/resources/icons/scalable/platform-apple.svg new file mode 100644 index 0000000..c0724ad --- /dev/null +++ b/resources/icons/scalable/platform-apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/scalable/platform-home.svg b/resources/icons/scalable/platform-home.svg new file mode 100644 index 0000000..61fc8e5 --- /dev/null +++ b/resources/icons/scalable/platform-home.svg @@ -0,0 +1,4 @@ + diff --git a/resources/icons/scalable/platform-linux.svg b/resources/icons/scalable/platform-linux.svg new file mode 100644 index 0000000..d9a5b20 --- /dev/null +++ b/resources/icons/scalable/platform-linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/scalable/platform-server.svg b/resources/icons/scalable/platform-server.svg new file mode 100644 index 0000000..766c2cd --- /dev/null +++ b/resources/icons/scalable/platform-server.svg @@ -0,0 +1 @@ + diff --git a/src/vocalinux/resources/icons/scalable/platform-android.svg b/src/vocalinux/resources/icons/scalable/platform-android.svg new file mode 100644 index 0000000..16bbfab --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/platform-android.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vocalinux/resources/icons/scalable/platform-apple.svg b/src/vocalinux/resources/icons/scalable/platform-apple.svg new file mode 100644 index 0000000..c0724ad --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/platform-apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vocalinux/resources/icons/scalable/platform-home.svg b/src/vocalinux/resources/icons/scalable/platform-home.svg new file mode 100644 index 0000000..61fc8e5 --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/platform-home.svg @@ -0,0 +1,4 @@ + diff --git a/src/vocalinux/resources/icons/scalable/platform-linux.svg b/src/vocalinux/resources/icons/scalable/platform-linux.svg new file mode 100644 index 0000000..d9a5b20 --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/platform-linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/vocalinux/resources/icons/scalable/platform-server.svg b/src/vocalinux/resources/icons/scalable/platform-server.svg new file mode 100644 index 0000000..766c2cd --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/platform-server.svg @@ -0,0 +1 @@ + diff --git a/src/vocalinux/ui/audio_feedback.py b/src/vocalinux/ui/audio_feedback.py index f7f1f29..e3536b6 100644 --- a/src/vocalinux/ui/audio_feedback.py +++ b/src/vocalinux/ui/audio_feedback.py @@ -223,6 +223,15 @@ def play_error_sound(): return _play_sound_file(ERROR_SOUND) if _is_sound_effects_enabled() else False +def preview_tone_cue(tone_id: Optional[str], kind: str) -> bool: + """Play one catalog cue. ``kind`` is ``start`` or ``stop``. Off plays nothing.""" + tone = normalize_sound_effect_tone(tone_id if tone_id is not None else _resolved_tone()) + if tone == "off": + return False + cue = "stop" if kind == "stop" else "start" + return bool(_play_sound_file(tone_sound_path(tone, cue))) + + def preview_tone(tone_id: Optional[str] = None) -> bool: """Play start then stop for a catalog tone. Off plays nothing.""" tone = normalize_sound_effect_tone(tone_id if tone_id is not None else _resolved_tone()) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index de561eb..f898688 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -20,9 +20,10 @@ UX Design Notes: import logging import os import re +import subprocess import threading import time -from typing import TYPE_CHECKING, NamedTuple, Optional +from typing import TYPE_CHECKING, Any, NamedTuple, Optional import gi @@ -273,8 +274,64 @@ 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 +# Uniform width for right-hand row controls so they align down a page. +# ComboBoxText sizes itself to the longest item unless the cell is ellipsized, +# which is why Shortcut Mode used to dwarf Shortcut Key. +_CONTROL_WIDTH = 220 +_COMBO_CHARS = 18 +_ACTION_WIDTH = 96 +_SPIN_WIDTH = 88 +_ICON_BUTTON_WIDTH = 36 +_PAIRED_COMBO_WIDTH = _CONTROL_WIDTH - _ICON_BUTTON_WIDTH - 8 + + +def _style_combo(combo: Gtk.ComboBox, width: int = _CONTROL_WIDTH) -> Gtk.ComboBox: + """Pin a combo to the shared control column so long items cannot stretch it.""" + combo.set_size_request(width, -1) + combo.set_halign(Gtk.Align.END) + combo.set_hexpand(False) + combo.set_popup_fixed_width(False) + for cell in combo.get_cells(): + cell.set_property("ellipsize", Pango.EllipsizeMode.END) + cell.set_property("width-chars", _COMBO_CHARS) + cell.set_property("max-width-chars", _COMBO_CHARS) + # Pixel cap so the closed combo cannot grow with the longest item. + cell.set_fixed_size(max(width - 40, 80), -1) + child = combo.get_child() + if isinstance(child, Gtk.Entry): + child.set_width_chars(_COMBO_CHARS) + child.set_max_width_chars(_COMBO_CHARS) + child.set_hexpand(False) + return combo + + +def _style_spin(spin: Gtk.SpinButton) -> Gtk.SpinButton: + """Keep plus/minus steppers compact and right-aligned.""" + spin.set_size_request(_SPIN_WIDTH, -1) + spin.set_halign(Gtk.Align.END) + return spin + + +def _style_action_button(button: Gtk.Button, width: int = _ACTION_WIDTH) -> Gtk.Button: + """Give compact action buttons a shared minimum width.""" + button.set_size_request(width, -1) + button.set_halign(Gtk.Align.END) + return button + + +def _combo_with_suffix( + combo: Gtk.ComboBox, + suffix: Gtk.Widget, + combo_width: int = _PAIRED_COMBO_WIDTH, +) -> Gtk.Box: + """Right-align a combo and a sibling button as one control cluster.""" + _style_combo(combo, combo_width) + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + box.set_halign(Gtk.Align.END) + box.pack_start(combo, False, False, 0) + box.pack_start(suffix, False, False, 0) + return box + VOCALINUX_SITE_URL = "https://vocalinux.com" VOCAHQ_SITE_URL = "https://vocahq.com" @@ -304,28 +361,127 @@ _ABOUT_OPEN_URLS = frozenset( ) # Official Talk-to-us marks from VocaHQ/.github brand/vocahq/social (commit 61c8eee). -# Do not redraw. fill is currentColor; paint iron-white ink at load time. +# Do not redraw. fill is currentColor; paint to match the dialog foreground so +# the marks stay visible on both light and dark surfaces. _ABOUT_INK = "#14231C" -_ABOUT_ICON_TEXT_PX = 16 +_ABOUT_INK_ON_DARK = "#E6E1D8" +_ABOUT_ICON_TEXT_PX = 18 +_FAMILY_ICON_PX = 22 + +def _color_luminance(color: Any) -> float: + """Relative luminance of a Gdk.RGBA (0 = black, 1 = white).""" + return 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue + + +def _rgba_to_hex(color: Any) -> str: + """Format a Gdk.RGBA as #rrggbb.""" + r = max(0, min(255, int(round(color.red * 255)))) + g = max(0, min(255, int(round(color.green * 255)))) + b = max(0, min(255, int(round(color.blue * 255)))) + return f"#{r:02x}{g:02x}{b:02x}" + + +def _about_surface_is_dark() -> bool: + """Return True when GTK is using a dark theme.""" + settings = Gtk.Settings.get_default() + if settings is not None: + try: + if bool(settings.get_property("gtk-application-prefer-dark-theme")): + return True + except Exception: + pass + try: + theme = str(settings.get_property("gtk-theme-name") or "").lower() + if "dark" in theme: + return True + except Exception: + pass + try: + from ..utils.host_process import host_env + + result = subprocess.run( + ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"], + capture_output=True, + text=True, + timeout=1, + env=host_env(), + ) + if result.returncode == 0 and "prefer-dark" in (result.stdout or "").lower(): + return True + except Exception: + pass + return False + + +def _about_ink_hex(widget: Optional[Gtk.Widget] = None) -> str: + """Ink for currentColor marks: theme fg when known, else light/dark defaults.""" + dark = _about_surface_is_dark() + if widget is not None: + ctx = widget.get_style_context() + for name in ("theme_fg_color", "theme_text_color", "fg_color"): + try: + found, color = ctx.lookup_color(name) + except Exception: + continue + if not found: + continue + lum = _color_luminance(color) + if dark and lum >= 0.45: + return _rgba_to_hex(color) + if not dark and lum <= 0.40: + return _ABOUT_INK + return _ABOUT_INK_ON_DARK if dark else _ABOUT_INK + + +# Official platform marks from VocaHQ/.github brand/promo/cards/platform +# (currentColor copies live in web/public/brand/platforms). Home is a simple +# house so HQ does not reuse a product lockup. _VOCAHQ_FAMILY_LINKS = ( - (VOCAHQ_SITE_URL, "vocahq.com", "Family site", "Open vocahq.com"), - (VOCALINUX_SITE_URL, "vocalinux.com", "Linux, Available now", "Open vocalinux.com"), - (VOCAMAC_SITE_URL, "vocamac.com", "macOS, Beta", "Open vocamac.com"), + ( + VOCAHQ_SITE_URL, + "VocaHQ", + "Family site", + ("platform-home",), + "Open vocahq.com", + ), + ( + VOCALINUX_SITE_URL, + "Vocalinux", + "Linux, available now", + ("platform-linux",), + "Open vocalinux.com", + ), + ( + VOCAMAC_SITE_URL, + "VocaMac", + "macOS, Beta", + ("platform-apple",), + "Open vocamac.com", + ), ( VOCAPHONE_SITE_URL, - "vocaphone.vocahq.com", - "Android beta / iOS source build", + "VocaPhone", + "Android beta / iOS TestFlight", + ("platform-android", "platform-apple"), "Open vocaphone.vocahq.com", ), ( VOCAGATEWAY_SITE_URL, - "vocagateway.vocahq.com", - "Optional self-hosted compute", + "VocaGateway", + "Self-hosted, headless", + ("platform-server",), "Open vocagateway.vocahq.com", ), ) +# Settings combo labels. The row subtitle already explains the behavior, so +# these stay short enough to match Shortcut Key. +_SHORTCUT_MODE_COMBO_LABELS = { + "toggle": "Toggle", + "push_to_talk": "Push-to-Talk", +} + def _can_open_url(url: str) -> bool: """Return True for GitHub project URLs or the About page allowlist.""" @@ -638,10 +794,22 @@ levelbar block.empty { } /* Combo boxes and spin buttons */ +combobox { + min-width: 0; +} + combobox button, combobox entry, spinbutton { min-height: 32px; + min-width: 0; + border-radius: 6px; +} + +.suffix-button { + min-height: 32px; + min-width: 36px; + padding: 0 8px; border-radius: 6px; } @@ -709,17 +877,40 @@ spinbutton { background-color: transparent; } -/* Model info card */ +/* Model status: caption under the engine group, not a second card */ .model-info-card { - background-color: alpha(@theme_base_color, 0.8); - border-radius: 8px; - padding: 12px 16px; - margin: 8px 0; + background-color: transparent; + border-radius: 0; + padding: 2px 16px 8px 16px; + margin: 0; } .model-info-title { - font-weight: bold; - font-size: 1.0em; + font-weight: 500; + font-size: 0.95em; +} + +.family-tile { + padding: 8px 10px; + border-radius: 8px; + background: transparent; +} + +.family-tile:hover { + background-color: alpha(@theme_selected_bg_color, 0.1); +} + +.family-tile-title { + font-weight: 500; +} + +.family-tile-subtitle { + font-size: 0.85em; + color: @theme_unfocused_fg_color; +} + +.about-mark-button image { + margin-right: 8px; } /* Unused downloads: one collapsed row until expanded */ @@ -1209,6 +1400,7 @@ class PreferenceRow(Gtk.ListBoxRow): widget: Gtk.Widget = None, activatable: bool = False, keywords=(), + leading: Optional[Gtk.Widget] = None, ): super().__init__() self.set_activatable(activatable) @@ -1223,13 +1415,17 @@ class PreferenceRow(Gtk.ListBoxRow): hbox.set_margin_start(16) hbox.set_margin_end(16) + if leading is not None: + leading.set_valign(Gtk.Align.CENTER) + hbox.pack_start(leading, False, False, 0) + # Text container (title + subtitle) text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) text_box.set_valign(Gtk.Align.CENTER) - title_label = Gtk.Label(label=title, xalign=0) - title_label.get_style_context().add_class("preference-row-title") - text_box.pack_start(title_label, False, False, 0) + self.title_label = Gtk.Label(label=title, xalign=0) + self.title_label.get_style_context().add_class("preference-row-title") + text_box.pack_start(self.title_label, False, False, 0) # Store subtitle label reference for later updates self.subtitle_label = None @@ -1250,7 +1446,12 @@ class PreferenceRow(Gtk.ListBoxRow): self.add(hbox) - def set_subtitle(self, subtitle: str): + def set_title(self, title: str) -> None: + """Update the title text.""" + self.title = title + self.title_label.set_text(title) + + def set_subtitle(self, subtitle: str) -> None: """Update the subtitle text.""" self.subtitle = subtitle if self.subtitle_label: @@ -1855,25 +2056,22 @@ class SettingsDialog(Gtk.Dialog): group = PreferencesGroup(title="Audio Input") # Device selection row - device_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self.audio_device_combo = Gtk.ComboBoxText() self.audio_device_combo.set_tooltip_text( "Select the microphone to use for voice recognition" ) - 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) refresh_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON) refresh_btn.set_tooltip_text("Refresh device list") - refresh_btn.get_style_context().add_class("flat-button") + refresh_btn.get_style_context().add_class("suffix-button") + refresh_btn.set_size_request(_ICON_BUTTON_WIDTH, -1) refresh_btn.connect("clicked", self._on_refresh_audio_devices) - device_box.pack_start(refresh_btn, False, False, 0) device_row = PreferenceRow( title="Input Device", - subtitle="Select the microphone for voice recognition", - widget=device_box, + subtitle="Microphone used for voice recognition", + widget=_combo_with_suffix(self.audio_device_combo, refresh_btn), keywords=("microphone", "mic", "input"), ) group.add_row(device_row) @@ -1881,6 +2079,7 @@ class SettingsDialog(Gtk.Dialog): # 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("Record 2 seconds and check the level") + _style_action_button(self.test_audio_btn) self.test_audio_btn.connect("clicked", self._on_test_audio_clicked) level_row = PreferenceRow( @@ -1897,7 +2096,8 @@ class SettingsDialog(Gtk.Dialog): sound_group = PreferencesGroup( title="Sound Effects", description=( - "The switch mutes every cue, including errors. Off only skips start and stop." + "Cues play when recording starts, stops, or hits an error. " + "Turn the switch off to mute all of them." ), ) self.sound_effects_switch = Gtk.Switch() @@ -1913,28 +2113,29 @@ class SettingsDialog(Gtk.Dialog): sound_group.add_row(sound_row) self.sound_tone_combo = Gtk.ComboBoxText() - self.sound_tone_combo.set_size_request(_CONTROL_WIDTH, -1) self.sound_tone_combo.set_tooltip_text("Start and stop sound used while dictating") _prevent_scroll_on_hover(self.sound_tone_combo) for tone_id, label in SOUND_EFFECT_TONES: self.sound_tone_combo.append(tone_id, label) - tone_row = PreferenceRow( + + self._tone_preview_kind = "start" + self.preview_tone_btn = Gtk.Button(label="Start") + self.preview_tone_btn.get_style_context().add_class("suffix-button") + self.preview_tone_btn.set_size_request(_ICON_BUTTON_WIDTH + 28, -1) + self.preview_tone_btn.set_tooltip_text("Play the start cue. Click again for the stop cue.") + _set_accessible_name(self.preview_tone_btn, "Play start cue") + + self.tone_row = PreferenceRow( title="Dictation Tone", subtitle="Start and stop cues. Off is silent for those two.", - widget=self.sound_tone_combo, + widget=_combo_with_suffix( + self.sound_tone_combo, + self.preview_tone_btn, + combo_width=_CONTROL_WIDTH - 72, + ), keywords=("tone", "chime", "voca", "lift", "preview"), ) - sound_group.add_row(tone_row) - - self.preview_tone_btn = Gtk.Button(label="Preview") - self.preview_tone_btn.set_tooltip_text("Play the start cue, then the stop cue") - self.preview_tone_row = PreferenceRow( - title="Preview Tone", - subtitle="Play the start cue, then the stop cue", - widget=self.preview_tone_btn, - keywords=("tone", "preview", "listen"), - ) - sound_group.add_row(self.preview_tone_row) + sound_group.add_row(self.tone_row) self.audio_tab.pack_start(sound_group, False, False, 0) self.sound_effects_switch.connect("state-set", self._on_sound_effects_toggled) @@ -1987,9 +2188,8 @@ class SettingsDialog(Gtk.Dialog): group = PreferencesGroup( title="Auto-Pause for Games & Apps", description=( - "While any app in the list is running, Vocalinux pauses dictation and " - "unloads the speech model to free CPU, GPU, and memory. Everything " - "resumes automatically when the app closes." + "Pauses dictation and unloads the speech model while any listed app " + "is running. Dictation comes back when the app closes." ), keywords=("process", "program", "game"), ) @@ -1999,8 +2199,8 @@ class SettingsDialog(Gtk.Dialog): "Pause dictation and unload the speech model while any listed app is running" ) enable_row = PreferenceRow( - title="Pause for Listed Apps", - subtitle="Free resources while any app below is running", + title="Pause for listed apps", + subtitle="Unload the model while these apps are open", widget=self.auto_pause_switch, ) group.add_row(enable_row) @@ -2251,10 +2451,9 @@ class SettingsDialog(Gtk.Dialog): group = PreferencesGroup( title="Unload When Idle", description=( - "After a period of inactivity, unload the speech model to save memory " - "and battery — on hybrid-GPU laptops (NVIDIA Optimus) this lets the " - "GPU sleep. The next dictation reloads the model automatically, which " - "may take a moment on larger models." + "Unload the speech model after you stop dictating. That frees RAM and " + "lets hybrid-GPU laptops sleep the GPU. The next dictation loads it " + "again, which can take a few seconds on larger models." ), ) @@ -2263,14 +2462,14 @@ class SettingsDialog(Gtk.Dialog): "Unload the speech model after the idle timeout to save memory and battery" ) enable_row = PreferenceRow( - title="Unload Model When Idle", - subtitle="Free memory and GPU power after a period of inactivity", + title="Unload model when idle", + subtitle="Free RAM and GPU after this much inactivity", widget=self.model_keepalive_switch, ) group.add_row(enable_row) self.model_keepalive_timeout_combo = Gtk.ComboBoxText() - self.model_keepalive_timeout_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.model_keepalive_timeout_combo) # id = seconds as string for seconds, label in ( (60, "1 minute"), @@ -2286,7 +2485,7 @@ class SettingsDialog(Gtk.Dialog): _prevent_scroll_on_hover(self.model_keepalive_timeout_combo) timeout_row = PreferenceRow( title="Idle Timeout", - subtitle="Unload the model after this much inactivity", + subtitle="How long to wait after the last dictation", widget=self.model_keepalive_timeout_combo, ) group.add_row(timeout_row) @@ -2424,29 +2623,50 @@ class SettingsDialog(Gtk.Dialog): logger.info(f"Sound effects {'enabled' if enabled else 'disabled'}") return False - def _update_tone_preview_subtitle(self, tone_id: str) -> None: + def _sync_tone_preview_button(self, tone_id: str) -> None: + """Update the two-stage preview button next to the tone combo.""" if tone_id == "off": - self.preview_tone_row.set_subtitle("Off has no start or stop cue") + self._tone_preview_kind = "start" + self.preview_tone_btn.set_sensitive(False) + self.preview_tone_btn.set_label("Start") self.preview_tone_btn.set_tooltip_text("Off has no start or stop cue") + _set_accessible_name(self.preview_tone_btn, "Play start cue") + self.tone_row.set_subtitle("Off skips the start and stop cues") + return + + self.preview_tone_btn.set_sensitive(True) + if self._tone_preview_kind == "stop": + self.preview_tone_btn.set_label("Stop") + self.preview_tone_btn.set_tooltip_text("Play the stop cue") + _set_accessible_name(self.preview_tone_btn, "Play stop cue") + self.tone_row.set_subtitle("Next click plays the stop cue") else: - self.preview_tone_row.set_subtitle("Play the start cue, then the stop cue") - self.preview_tone_btn.set_tooltip_text("Play the start cue, then the stop cue") + self.preview_tone_btn.set_label("Start") + self.preview_tone_btn.set_tooltip_text( + "Play the start cue. Click again for the stop cue." + ) + _set_accessible_name(self.preview_tone_btn, "Play start cue") + self.tone_row.set_subtitle("Start and stop cues. Off is silent for those two.") def _on_sound_tone_changed(self, combo): tone_id = combo.get_active_id() or DEFAULT_SOUND_EFFECT_TONE - self._update_tone_preview_subtitle(tone_id) + self._tone_preview_kind = "start" + self._sync_tone_preview_button(tone_id) if self._initializing or self._applying_settings: return logger.info("Dictation tone selected: %s", tone_id) self.config_manager.set_sound_effects_tone(tone_id) self.config_manager.save_settings() - def _on_preview_tone_clicked(self, _widget): - from .audio_feedback import preview_tone + def _on_preview_tone_clicked(self, _widget) -> None: + from .audio_feedback import preview_tone_cue tone_id = self.sound_tone_combo.get_active_id() or DEFAULT_SOUND_EFFECT_TONE - self._update_tone_preview_subtitle(tone_id) - preview_tone(tone_id) + kind = self._tone_preview_kind + if not preview_tone_cue(tone_id, kind): + return + self._tone_preview_kind = "stop" if kind == "start" else "start" + self._sync_tone_preview_button(tone_id) def _build_engine_section(self): """Build the Speech Engine section.""" @@ -2454,7 +2674,7 @@ class SettingsDialog(Gtk.Dialog): # Engine selection self.engine_combo = Gtk.ComboBoxText() - self.engine_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.engine_combo) _prevent_scroll_on_hover(self.engine_combo) engine_row = PreferenceRow( title="Engine", @@ -2465,7 +2685,7 @@ class SettingsDialog(Gtk.Dialog): # Model size selection self.model_combo = Gtk.ComboBoxText() - self.model_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.model_combo) self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP) _prevent_scroll_on_hover(self.model_combo) self.model_row = PreferenceRow( @@ -2478,7 +2698,7 @@ class SettingsDialog(Gtk.Dialog): # whisper.cpp specialization selection self.model_variant_combo = Gtk.ComboBoxText() - self.model_variant_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.model_variant_combo) self.model_variant_combo.set_tooltip_text(MODEL_SPECIALIZATION_TOOLTIP) _prevent_scroll_on_hover(self.model_variant_combo) self.model_variant_row = PreferenceRow( @@ -2491,7 +2711,7 @@ class SettingsDialog(Gtk.Dialog): # Language selection (searchable: type to filter the 30+ language list) self.language_combo = Gtk.ComboBoxText.new_with_entry() - self.language_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.language_combo) self.language_combo.set_tooltip_text(LANGUAGE_TOOLTIP) _prevent_scroll_on_hover(self.language_combo) _attach_language_combo_search(self.language_combo) @@ -2524,7 +2744,8 @@ class SettingsDialog(Gtk.Dialog): self.model_info_card.pack_start(self.model_info_subtitle, False, False, 0) self.model_recommendation = Gtk.Label(xalign=0, wrap=True) - self.model_recommendation.get_style_context().add_class("tip-label") + self.model_recommendation.get_style_context().add_class("model-info-subtitle") + self.model_recommendation.set_no_show_all(True) self.model_info_card.pack_start(self.model_recommendation, False, False, 0) # Language warning (e.g. auto-detect, English-only models) lives in @@ -2534,14 +2755,6 @@ class SettingsDialog(Gtk.Dialog): self.language_warning.set_no_show_all(True) self.model_info_card.pack_start(self.language_warning, False, False, 0) - # 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) - self.content_box.pack_start(self.model_info_card, False, False, 0) self.unused_models_group = PreferencesGroup( @@ -2685,6 +2898,7 @@ class SettingsDialog(Gtk.Dialog): # VAD Sensitivity self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1) + _style_spin(self.vad_spin) self.vad_spin.set_tooltip_text("Higher = more sensitive to quiet speech") _prevent_scroll_on_hover(self.vad_spin) silero_active = is_silero_available() @@ -2704,6 +2918,7 @@ class SettingsDialog(Gtk.Dialog): # Silence Timeout self.silence_spin = Gtk.SpinButton.new_with_range(0.5, 5.0, 0.1) self.silence_spin.set_digits(1) + _style_spin(self.silence_spin) self.silence_spin.set_tooltip_text("Wait time after silence before processing speech") _prevent_scroll_on_hover(self.silence_spin) silence_row = PreferenceRow( @@ -2774,7 +2989,7 @@ class SettingsDialog(Gtk.Dialog): output_group.add_row(append_trailing_space_row) self.paste_shortcut_combo = Gtk.ComboBoxText() - self.paste_shortcut_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.paste_shortcut_combo) self.paste_shortcut_combo.set_tooltip_text( "Clipboard injection uses Ctrl+V in ordinary fields and Ctrl+Shift+V " "in terminal windows. Override this when a nested terminal panel " @@ -2841,15 +3056,17 @@ class SettingsDialog(Gtk.Dialog): # Mode selection (Toggle vs Push-to-Talk) self.shortcut_mode_combo = Gtk.ComboBoxText() - self.shortcut_mode_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.shortcut_mode_combo) self.shortcut_mode_combo.set_tooltip_text( "Choose between toggle (double-tap) or push-to-talk mode" ) _prevent_scroll_on_hover(self.shortcut_mode_combo) # Populate mode options - for mode_id, display_name in SHORTCUT_MODES.items(): - self.shortcut_mode_combo.append(mode_id, display_name) + for mode_id in SHORTCUT_MODES: + self.shortcut_mode_combo.append( + mode_id, _SHORTCUT_MODE_COMBO_LABELS.get(mode_id, SHORTCUT_MODES[mode_id]) + ) # Load current mode from config current_mode = self.config_manager.get_str("shortcuts", "mode", DEFAULT_SHORTCUT_MODE) @@ -2865,7 +3082,7 @@ class SettingsDialog(Gtk.Dialog): # Shortcut selection combo self.shortcut_combo = Gtk.ComboBoxText() - self.shortcut_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.shortcut_combo) self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut for voice typing") _prevent_scroll_on_hover(self.shortcut_combo) @@ -3533,8 +3750,10 @@ class SettingsDialog(Gtk.Dialog): self.power_user_switch.connect("state-set", self._on_power_user_toggled) - def _about_mark_image(self, icon_name: str) -> Optional[Gtk.Image]: - """Load a Design Talk-to-us SVG and paint currentColor as iron-white ink.""" + def _about_mark_image( + self, icon_name: str, pixel_size: int = _ABOUT_ICON_TEXT_PX + ) -> Optional[Gtk.Image]: + """Load a currentColor SVG and paint it to match the dialog foreground.""" from gi.repository import GdkPixbuf from ..utils.resource_manager import ResourceManager @@ -3543,17 +3762,18 @@ class SettingsDialog(Gtk.Dialog): if not os.path.exists(path): return None try: + ink = _about_ink_hex(self) with open(path, encoding="utf-8") as handle: - svg = handle.read().replace("currentColor", _ABOUT_INK) + svg = handle.read().replace("currentColor", ink) loader = GdkPixbuf.PixbufLoader.new_with_type("svg") - loader.set_size(_ABOUT_ICON_TEXT_PX, _ABOUT_ICON_TEXT_PX) + loader.set_size(pixel_size, pixel_size) loader.write(svg.encode("utf-8")) loader.close() pixbuf = loader.get_pixbuf() if pixbuf is None: return None image = Gtk.Image.new_from_pixbuf(pixbuf) - image.set_pixel_size(_ABOUT_ICON_TEXT_PX) + image.set_pixel_size(pixel_size) return image except Exception as exc: logger.warning("Failed to load About mark %s: %s", icon_name, exc) @@ -3569,7 +3789,7 @@ class SettingsDialog(Gtk.Dialog): ) -> Gtk.Button: """Button that opens a trusted About URL. - Unique labels (Report a bug or idea, Discord, X, Email) are the + Unique labels (Website, Source code, Report a bug or idea) are the accessible name. Shared "Open" labels use the tooltip instead. """ button = Gtk.Button(label=label) @@ -3580,8 +3800,47 @@ class SettingsDialog(Gtk.Dialog): if icon_name: image = self._about_mark_image(icon_name) if image is not None: + image.set_margin_end(8) button.set_image(image) button.set_always_show_image(True) + button.get_style_context().add_class("about-mark-button") + button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest)) + return button + + def _family_tile( + self, + url: str, + title: str, + subtitle: str, + icon_names: tuple[str, ...], + tooltip: str, + ) -> Gtk.Button: + """Compact icon + name tile that opens a family site.""" + inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) + marks = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + marks.set_valign(Gtk.Align.CENTER) + for icon_name in icon_names: + icon = self._about_mark_image(icon_name, pixel_size=_FAMILY_ICON_PX) + if icon is not None: + marks.pack_start(icon, False, False, 0) + inner.pack_start(marks, False, False, 0) + text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + name_label = Gtk.Label(label=title, xalign=0) + name_label.get_style_context().add_class("family-tile-title") + status_label = Gtk.Label(label=subtitle, xalign=0) + status_label.get_style_context().add_class("family-tile-subtitle") + status_label.set_line_wrap(True) + text.pack_start(name_label, False, False, 0) + text.pack_start(status_label, False, False, 0) + inner.pack_start(text, True, True, 0) + + button = Gtk.Button() + button.add(inner) + button.get_style_context().add_class("family-tile") + button.set_tooltip_text(tooltip) + button.set_hexpand(True) + button.set_halign(Gtk.Align.FILL) + _set_accessible_name(button, tooltip) button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest)) return button @@ -3623,29 +3882,36 @@ class SettingsDialog(Gtk.Dialog): ) app_group.add_row(version_row) - website_btn = self._about_open_button( - VOCALINUX_SITE_URL, - "Open vocalinux.com website", + link_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + link_box.pack_start( + self._about_open_button( + VOCALINUX_SITE_URL, + "Open vocalinux.com website", + label="Website", + width=110, + ), + False, + False, + 0, ) - website_row = PreferenceRow( - title="Website", - subtitle="Product site at vocalinux.com", - widget=website_btn, - keywords=("website", "vocalinux.com"), + link_box.pack_start( + self._about_open_button( + GITHUB_REPO_URL, + "Open the Vocalinux GitHub repository", + label="Source code", + width=120, + ), + False, + False, + 0, ) - app_group.add_row(website_row) - - source_btn = self._about_open_button( - GITHUB_REPO_URL, - "Open the Vocalinux GitHub repository", + links_row = PreferenceRow( + title="Open source", + subtitle="Product site at vocalinux.com, source on GitHub.", + widget=link_box, + keywords=("website", "vocalinux.com", "github", "source", "repo", "code", "agpl"), ) - source_row = PreferenceRow( - title="Source code", - subtitle=GITHUB_REPO_URL, - widget=source_btn, - keywords=("github", "source", "repo", "code"), - ) - app_group.add_row(source_row) + app_group.add_row(links_row) license_row = PreferenceRow( title="License", @@ -3655,105 +3921,6 @@ class SettingsDialog(Gtk.Dialog): app_group.add_row(license_row) self.about_tab.pack_start(app_group, False, False, 0) - updates_group = PreferencesGroup( - title="Updates", - description="Choose a release channel, then check GitHub for a newer build.", - keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"), - ) - - self.update_channel_combo = Gtk.ComboBoxText() - self.update_channel_combo.set_size_request(_CONTROL_WIDTH, -1) - self.update_channel_combo.append("stable", "Stable") - self.update_channel_combo.append("nightly", "Nightly") - self.update_channel_combo.set_tooltip_text( - "Stable uses the latest numbered release. Nightly uses the newest nightly-YYYY-MM-DD build." - ) - _prevent_scroll_on_hover(self.update_channel_combo) - saved_channel = normalize_channel( - self.config_manager.get_str("updates", "channel", DEFAULT_UPDATE_CHANNEL) - ) - self.update_channel_combo.set_active_id(saved_channel) - channel_row = PreferenceRow( - title="Channel", - subtitle="Stable for releases, Nightly for dated development builds", - widget=self.update_channel_combo, - keywords=("channel", "stable", "nightly", "beta"), - ) - updates_group.add_row(channel_row) - - self.check_updates_btn = Gtk.Button(label="Check") - self.check_updates_btn.set_size_request(100, -1) - self.check_updates_btn.set_tooltip_text( - "Look up the latest release for this channel on GitHub" - ) - self.check_updates_btn.connect("clicked", self._on_check_updates_clicked) - self.update_status_row = PreferenceRow( - title="Status", - subtitle="Not checked yet", - widget=self.check_updates_btn, - keywords=("status", "check"), - ) - updates_group.add_row(self.update_status_row) - - self.open_release_btn = Gtk.Button(label="Open") - self.open_release_btn.set_size_request(100, -1) - self.open_release_btn.set_sensitive(False) - self.open_release_btn.set_tooltip_text( - "Open the release page in your browser for download links and install steps" - ) - _set_accessible_name( - self.open_release_btn, - "Open the latest release page", - ) - self.open_release_btn.connect("clicked", self._on_open_release_clicked) - self.latest_release_row = PreferenceRow( - title="Latest Release", - subtitle="-", - widget=self.open_release_btn, - keywords=("latest", "download", "changelog"), - ) - updates_group.add_row(self.latest_release_row) - self.about_tab.pack_start(updates_group, False, False, 0) - - self.update_channel_combo.connect("changed", self._on_update_channel_changed) - - self.release_notes_group = PreferencesGroup( - title="What's New", - keywords=("changelog", "what's new", "release notes"), - ) - self.release_notes_row = PreferenceRow( - title="Release notes", - subtitle="Notes appear here after a successful update check.", - keywords=("notes", "changelog"), - ) - # Allow a longer wrap than typical preference subtitles. - if self.release_notes_row.subtitle_label is not None: - self.release_notes_row.subtitle_label.set_max_width_chars(72) - self.release_notes_group.add_row(self.release_notes_row) - self.about_tab.pack_start(self.release_notes_group, False, False, 0) - self.release_notes_group.hide() - - family_group = PreferencesGroup( - title="Part of VocaHQ", - description=( - "Vocalinux is part of VocaHQ. The same private dictation idea also ships " - "as VocaMac (macOS, beta), VocaWin (Windows, unsigned beta, v0.1.0-beta.1), " - "and VocaPhone (Android beta / iOS source build). VocaGateway is optional " - "self-hosted compute. It is not on-device." - ), - keywords=("vocahq", "vocamac", "vocawin", "vocaphone", "vocagateway", "family"), - ) - for url, title, subtitle, open_name in _VOCAHQ_FAMILY_LINKS: - family_group.add_row( - PreferenceRow( - title=title, - subtitle=subtitle, - widget=self._about_open_button(url, open_name), - keywords=(title, subtitle.lower()), - ) - ) - self.about_tab.pack_start(family_group, False, False, 0) - talk_group = PreferencesGroup( title="Talk to us", description=( @@ -3778,10 +3945,10 @@ class SettingsDialog(Gtk.Dialog): ) ) contact_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - for url, label, tooltip, icon_name in ( - (VOCAHQ_DISCORD_URL, "Discord", "Open the VocaHQ Discord", "discord"), - (VOCAHQ_X_URL, "X", "Open X @vocahq", "x"), - (VOCAHQ_MAILTO_URL, "Email", "Email hello@vocahq.com", "mail"), + for url, label, tooltip, icon_name, width in ( + (VOCAHQ_DISCORD_URL, "Join Discord", "Open the VocaHQ Discord", "discord", 140), + (VOCAHQ_X_URL, "Follow on X", "Open X @vocahq", "x", 130), + (VOCAHQ_MAILTO_URL, "Email us", "Email hello@vocahq.com", "mail", 120), ): contact_box.pack_start( self._about_open_button( @@ -3789,7 +3956,7 @@ class SettingsDialog(Gtk.Dialog): tooltip, label=label, icon_name=icon_name, - width=110, + width=width, ), False, False, @@ -3805,6 +3972,97 @@ class SettingsDialog(Gtk.Dialog): ) self.about_tab.pack_start(talk_group, False, False, 0) + updates_group = PreferencesGroup( + title="Updates", + description="Stable for numbered releases. Nightly for dated development builds.", + keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"), + ) + + self.update_channel_combo = Gtk.ComboBoxText() + self.update_channel_combo.append("stable", "Stable") + self.update_channel_combo.append("nightly", "Nightly") + self.update_channel_combo.set_tooltip_text( + "Stable uses the latest numbered release. Nightly uses the newest nightly-YYYY-MM-DD build." + ) + _prevent_scroll_on_hover(self.update_channel_combo) + saved_channel = normalize_channel( + self.config_manager.get_str("updates", "channel", DEFAULT_UPDATE_CHANNEL) + ) + self.update_channel_combo.set_active_id(saved_channel) + + self.check_updates_btn = Gtk.Button(label="Check") + self.check_updates_btn.get_style_context().add_class("suffix-button") + _style_action_button(self.check_updates_btn, width=80) + self.check_updates_btn.set_tooltip_text( + "Look up the latest release for this channel on GitHub" + ) + self.check_updates_btn.connect("clicked", self._on_check_updates_clicked) + + self.update_status_row = PreferenceRow( + title="Channel", + subtitle="Not checked yet", + widget=_combo_with_suffix( + self.update_channel_combo, + self.check_updates_btn, + combo_width=_CONTROL_WIDTH - 88, + ), + keywords=("channel", "stable", "nightly", "beta", "status", "check"), + ) + updates_group.add_row(self.update_status_row) + self.about_tab.pack_start(updates_group, False, False, 0) + + self.update_channel_combo.connect("changed", self._on_update_channel_changed) + + self.release_notes_group = PreferencesGroup( + title="What's New", + keywords=("changelog", "what's new", "release notes", "latest", "download"), + ) + self.open_release_btn = Gtk.Button(label="Open") + _style_action_button(self.open_release_btn) + self.open_release_btn.set_sensitive(False) + self.open_release_btn.set_tooltip_text( + "Open the release page in your browser for download links and install steps" + ) + _set_accessible_name( + self.open_release_btn, + "Open the latest release page", + ) + self.open_release_btn.connect("clicked", self._on_open_release_clicked) + self.release_notes_row = PreferenceRow( + title="Release notes", + subtitle="Notes appear here after a successful update check.", + widget=self.open_release_btn, + keywords=("notes", "changelog", "latest"), + ) + self.latest_release_row = self.release_notes_row + if self.release_notes_row.subtitle_label is not None: + self.release_notes_row.subtitle_label.set_max_width_chars(72) + self.release_notes_group.add_row(self.release_notes_row) + self.about_tab.pack_start(self.release_notes_group, False, False, 0) + self.release_notes_group.hide() + + family_group = PreferencesGroup( + title="Part of VocaHQ", + description="Private dictation for Linux, Mac, Windows, and phone.", + keywords=("vocahq", "vocamac", "vocawin", "vocaphone", "vocagateway", "family"), + ) + family_grid = Gtk.Grid() + family_grid.set_column_spacing(8) + family_grid.set_row_spacing(4) + family_grid.set_column_homogeneous(True) + family_grid.set_margin_start(8) + family_grid.set_margin_end(8) + family_grid.set_margin_top(4) + family_grid.set_margin_bottom(8) + for index, (url, title, subtitle, icon_names, open_name) in enumerate(_VOCAHQ_FAMILY_LINKS): + tile = self._family_tile(url, title, subtitle, icon_names, open_name) + family_grid.attach(tile, index % 2, index // 2, 1, 1) + family_row = Gtk.ListBoxRow() + family_row.set_activatable(False) + family_row.add(family_grid) + family_group.add_row(family_row) + self.about_tab.pack_start(family_group, False, False, 0) + def _set_about_update_badge(self, visible: bool) -> None: """Show or hide the green New badge on the About sidebar row.""" about_page = next((page for page in self._pages if page.name == "about"), None) @@ -3918,6 +4176,7 @@ class SettingsDialog(Gtk.Dialog): self.update_status_row.set_subtitle( f"Could not find a {missing}. Check your connection, or try again later." ) + self.latest_release_row.set_title("Release notes") self.latest_release_row.set_subtitle("Unavailable") self.open_release_btn.set_sensitive(True) self.open_release_btn.set_tooltip_text("Open the Vocalinux project page") @@ -3961,7 +4220,6 @@ class SettingsDialog(Gtk.Dialog): except Exception: logger.error("Update status callback failed", exc_info=True) - self.latest_release_row.set_subtitle(release_label) self.open_release_btn.set_sensitive(True) self.open_release_btn.set_tooltip_text( "Open the release page in your browser for download links and install steps" @@ -3971,6 +4229,7 @@ class SettingsDialog(Gtk.Dialog): # Keep the card readable; full notes remain on the release page. if len(notes) > 900: notes = notes[:900].rstrip() + "…" + self.latest_release_row.set_title(release_label) self.release_notes_row.set_subtitle(notes) self.release_notes_group.show() return False @@ -3982,12 +4241,12 @@ class SettingsDialog(Gtk.Dialog): bundled = detect_pywhispercpp_gpu_backend() except (ImportError, OSError, AttributeError): - return "Device used by the whisper.cpp engine" + return "GPU used by whisper.cpp" if bundled == "cpu": - return "This install has no GPU libraries; whisper.cpp will run on the CPU" + return "This install has no GPU libraries, so whisper.cpp runs on the CPU" if bundled == "cuda": - return "CUDA build uses NVIDIA device 0; this picker applies to Vulkan builds" - return "Device used by the whisper.cpp Vulkan engine" + return "CUDA uses NVIDIA device 0. This picker is for Vulkan builds." + return "GPU used by the whisper.cpp Vulkan engine" def _build_gpu_section(self): """Build the GPU device group on the Performance page. @@ -3997,7 +4256,7 @@ class SettingsDialog(Gtk.Dialog): """ gpu_group = PreferencesGroup(title="Hardware Acceleration") self.gpu_device_combo = Gtk.ComboBoxText() - self.gpu_device_combo.set_size_request(_CONTROL_WIDTH, -1) + _style_combo(self.gpu_device_combo) self.gpu_device_combo.set_tooltip_text( "Select which GPU to use for whisper.cpp Vulkan acceleration. " "Has no effect when pywhispercpp was built without GPU libraries." @@ -4032,7 +4291,7 @@ class SettingsDialog(Gtk.Dialog): "URL of the remote speech recognition server\n" "Supports OpenAI compatible API and whisper.cpp server" ) - self.remote_api_url_entry.set_size_request(280, -1) + self.remote_api_url_entry.set_size_request(_CONTROL_WIDTH, -1) remote_url_row = PreferenceRow( title="Server URL", subtitle="Remote speech recognition server address", @@ -4045,7 +4304,7 @@ class SettingsDialog(Gtk.Dialog): self.remote_api_key_entry.set_placeholder_text("(optional)") self.remote_api_key_entry.set_visibility(False) self.remote_api_key_entry.set_tooltip_text("API Key for authentication (optional)") - self.remote_api_key_entry.set_size_request(280, -1) + self.remote_api_key_entry.set_size_request(_CONTROL_WIDTH, -1) remote_key_row = PreferenceRow( title="API Key", subtitle="Authentication key (optional)", @@ -4055,7 +4314,7 @@ class SettingsDialog(Gtk.Dialog): # API Endpoint self.remote_api_endpoint_combo = Gtk.ComboBoxText() - self.remote_api_endpoint_combo.set_size_request(280, -1) + _style_combo(self.remote_api_endpoint_combo) self.remote_api_endpoint_combo.set_tooltip_text( "Select the API format of the remote server (API Endpoint Format)" ) @@ -4077,7 +4336,7 @@ class SettingsDialog(Gtk.Dialog): self.remote_api_model_entry.set_tooltip_text( "Model identifier sent to OpenAI-compatible and FunASR servers" ) - self.remote_api_model_entry.set_size_request(280, -1) + self.remote_api_model_entry.set_size_request(_CONTROL_WIDTH, -1) remote_model_row = PreferenceRow( title="Model", subtitle="Remote model name, for example whisper-1 or sensevoice", @@ -4248,7 +4507,8 @@ class SettingsDialog(Gtk.Dialog): tone_id = self.config_manager.get_sound_effects_tone() if not self.sound_tone_combo.set_active_id(tone_id): self.sound_tone_combo.set_active_id(DEFAULT_SOUND_EFFECT_TONE) - self._update_tone_preview_subtitle(self.sound_tone_combo.get_active_id() or tone_id) + self._tone_preview_kind = "start" + self._sync_tone_preview_button(self.sound_tone_combo.get_active_id() or tone_id) auto_pause_settings = self.config_manager.get_settings().get("auto_pause", {}) auto_pause_enabled = bool(auto_pause_settings.get("enabled", False)) @@ -5117,28 +5377,26 @@ class SettingsDialog(Gtk.Dialog): model_display_name = _model_display_name(model_name) recommended_display_name = _model_display_name(recommended) - # Update title self.model_info_title.set_markup(f"{model_display_name}: {info['desc']}") - # Update subtitle with status if is_downloaded: - status = "✓ Downloaded and ready" + status = "Downloaded" else: - status = f"↓ Will download ~{_format_size(info['size_mb'])}" - self.model_info_subtitle.set_markup(f"{extra_info} • {status}") + status = f"Download ~{_format_size(info['size_mb'])}" + self.model_info_subtitle.set_markup(f"{extra_info} · {status}") - # Update recommendation if model_name == recommended: - self.model_recommendation.set_markup( - f"★ Recommended for your system ({reason})" - ) + self.model_recommendation.hide() else: - self.model_recommendation.set_markup( - f"Tip: {recommended_display_name} is recommended for your system ({reason})" + self.model_recommendation.set_text( + f"Recommended: {recommended_display_name} ({reason})" ) + self.model_recommendation.show() self._update_model_picker_tooltips() - self.model_info_card.show_all() + self.model_info_card.show() + self.model_info_title.show() + self.model_info_subtitle.show() def _auto_apply_settings(self): """Automatically apply settings when changed.""" @@ -5695,7 +5953,7 @@ For now, the engine has been reverted to VOSK.""" def _populate_gpu_devices(self): """Populate the GPU device dropdown with available Vulkan devices.""" self.gpu_device_combo.remove_all() - self.gpu_device_combo.append("-1", "Auto (prefer discrete GPU)") + self.gpu_device_combo.append("-1", "Auto (discrete GPU)") devices = detect_vulkan_devices() for device in devices: diff --git a/tests/test_audio_feedback.py b/tests/test_audio_feedback.py index 92691e7..ba53b0d 100644 --- a/tests/test_audio_feedback.py +++ b/tests/test_audio_feedback.py @@ -537,3 +537,16 @@ class TestAudioFeedback(unittest.TestCase): mock_play.reset_mock() self.assertTrue(audio_feedback.preview_tone("not-a-tone")) self.assertTrue(mock_play.call_args_list[0].args[0].endswith("voca_start.wav")) + + def test_preview_tone_cue_plays_one_side(self): + import vocalinux.ui.audio_feedback as audio_feedback + + with patch.object(audio_feedback, "_play_sound_file", return_value=True) as mock_play: + self.assertTrue(audio_feedback.preview_tone_cue("voca", "start")) + self.assertTrue(mock_play.call_args_list[0].args[0].endswith("voca_start.wav")) + mock_play.reset_mock() + self.assertTrue(audio_feedback.preview_tone_cue("voca", "stop")) + self.assertTrue(mock_play.call_args_list[0].args[0].endswith("voca_stop.wav")) + mock_play.reset_mock() + self.assertFalse(audio_feedback.preview_tone_cue("off", "start")) + mock_play.assert_not_called() diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 3920425..f385d2a 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -815,9 +815,12 @@ class TestLanguageComboSearch(unittest.TestCase): self.assertIn("SOUND_EFFECT_TONES", source_code) self.assertIn('title="Dictation Tone"', source_code) self.assertIn("def _on_preview_tone_clicked", source_code) - self.assertIn("preview_tone(tone_id)", source_code) + self.assertIn("preview_tone_cue(tone_id, kind)", source_code) + self.assertIn("if not preview_tone_cue(tone_id, kind):", source_code) + self.assertIn("self._tone_preview_kind", source_code) self.assertNotIn("fifth", source_code) self.assertNotIn("01-linux-glide", source_code) + self.assertNotIn('title="Preview Tone"', source_code) class TestSettingsSearch(unittest.TestCase): @@ -1041,15 +1044,17 @@ class TestAboutPage(unittest.TestCase): def test_product_status_and_honest_network_copy(self): self.assertIn("Available now on Linux (X11 and Wayland)", self.about) self.assertIn("After a model is downloaded", self.about) - self.assertIn("unsigned beta, v0.1.0-beta.1", self.about) - self.assertIn("Android beta / iOS source build", self.about) - self.assertIn("optional", self.about.lower()) - self.assertIn("self-hosted compute", self.about) + self.assertIn("Private dictation for Linux, Mac, Windows, and phone.", self.about) + self.assertIn("Android beta / iOS TestFlight", self.source) + self.assertIn("Self-hosted, headless", self.source) + self.assertNotIn("iOS source build", self.source) self.assertNotIn("SmartScreen", self.about) self.assertNotIn("100% offline", self.about) self.assertNotIn("fully offline", self.about) self.assertNotIn("Coming soon", self.about) self.assertNotIn("releases/download", self.about) + # License is named once, on the License row, not in the intro or links. + self.assertEqual(self.about.count("GNU AGPL v3"), 1) def test_family_and_talk_urls(self): for url in ( @@ -1065,9 +1070,9 @@ class TestAboutPage(unittest.TestCase): ): self.assertIn(url, self.source) self.assertIn('label="Report a bug or idea"', self.about) - self.assertIn('"Discord"', self.about) - self.assertIn('"X"', self.about) - self.assertIn('"Email"', self.about) + self.assertIn("Join Discord", self.about) + self.assertIn("Follow on X", self.about) + self.assertIn("Email us", self.about) self.assertIn("https://github.com/VocaHQ/vocalinux/issues", self.about) self.assertIn("https://discord.gg/t6muquAJbm", self.source) self.assertIn("https://x.com/vocahq", self.source) @@ -1096,10 +1101,38 @@ class TestAboutPage(unittest.TestCase): self.assertNotIn("Open the Vocalinux GitHub page", self.about) def test_source_code_row_sits_beside_website(self): - self.assertIn('title="Source code"', self.about) + self.assertIn('label="Website"', self.about) + self.assertIn('label="Source code"', self.about) self.assertIn("GITHUB_REPO_URL", self.about) self.assertIn("Open the Vocalinux GitHub repository", self.about) - self.assertLess(self.about.find("Website"), self.about.find("Source code")) + self.assertLess(self.about.find('label="Website"'), self.about.find('label="Source code"')) + + def test_talk_to_us_sits_above_updates(self): + self.assertLess(self.about.find('title="Talk to us"'), self.about.find('title="Updates"')) + self.assertLess( + self.about.find("self.about_tab.pack_start(talk_group"), + self.about.find("self.about_tab.pack_start(updates_group"), + ) + + def test_channel_check_shares_one_row(self): + self.assertIn("_combo_with_suffix", self.about) + self.assertIn("self.check_updates_btn", self.about) + self.assertNotIn('title="Status"', self.about) + self.assertNotIn('title="Latest Release"', self.about) + self.assertIn("self.latest_release_row = self.release_notes_row", self.about) + + def test_family_uses_platform_icons(self): + self.assertIn("def _family_tile", self.source) + self.assertIn("platform-linux", self.source) + self.assertIn("platform-apple", self.source) + self.assertIn("platform-android", self.source) + self.assertIn("platform-server", self.source) + self.assertIn("platform-home", self.source) + self.assertIn('("platform-android", "platform-apple")', self.source) + self.assertIn("family_grid.attach", self.about) + self.assertIn("image.set_margin_end(8)", self.source) + helper = self.source.split("def _family_tile")[1].split("\n def ")[0] + self.assertIn("spacing=16", helper) def test_can_open_about_urls(self): from vocalinux.ui.settings_dialog import ( @@ -1210,6 +1243,93 @@ class TestAboutPage(unittest.TestCase): self.assertNotIn("{title}", packaged, name) + + def test_about_marks_use_theme_aware_ink(self): + self.assertIn("_ABOUT_INK_ON_DARK", self.source) + self.assertIn("def _about_ink_hex(", self.source) + self.assertIn("def _about_surface_is_dark(", self.source) + self.assertIn('replace("currentColor", ink)', self.source) + self.assertIn("_FAMILY_ICON_PX", self.source) + + +class TestAboutInk(unittest.TestCase): + """Ink for About marks follows light vs dark surfaces.""" + + def test_dark_theme_pref_picks_light_ink(self): + from vocalinux.ui.settings_dialog import ( + _ABOUT_INK, + _ABOUT_INK_ON_DARK, + _about_ink_hex, + _about_surface_is_dark, + ) + + self.assertNotEqual(_ABOUT_INK, _ABOUT_INK_ON_DARK) + self.assertTrue(_ABOUT_INK_ON_DARK.startswith("#")) + if _about_surface_is_dark(): + self.assertEqual(_about_ink_hex(), _ABOUT_INK_ON_DARK) + else: + self.assertIn(_about_ink_hex(), {_ABOUT_INK, _ABOUT_INK_ON_DARK}) + + +class TestSettingsControlColumn(unittest.TestCase): + """Dropdowns, steppers, and action buttons share one right-hand column.""" + + @classmethod + def setUpClass(cls): + cls.source = TestSettingsSearch._settings_source() + + def test_style_helpers_exist(self): + self.assertIn("def _style_combo(", self.source) + self.assertIn("def _style_spin(", self.source) + self.assertIn("def _combo_with_suffix(", self.source) + self.assertIn("ellipsize", self.source) + self.assertIn("Pango.EllipsizeMode.END", self.source) + + def test_combos_use_style_helper(self): + for name in ( + "self.engine_combo", + "self.model_combo", + "self.model_variant_combo", + "self.language_combo", + "self.paste_shortcut_combo", + "self.shortcut_mode_combo", + "self.shortcut_combo", + "self.gpu_device_combo", + "self.model_keepalive_timeout_combo", + ): + self.assertIn(f"_style_combo({name})", self.source, name) + + def test_model_card_has_no_legend(self): + self.assertNotIn("✓ Downloaded ↓ Will download ★ Recommended", self.source) + self.assertNotIn("Tip: ", self.source) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_settings_shortcuts.py b/tests/test_settings_shortcuts.py index 90035df..72a2215 100644 --- a/tests/test_settings_shortcuts.py +++ b/tests/test_settings_shortcuts.py @@ -43,7 +43,7 @@ class TestSettingsDialogShortcutsSection(unittest.TestCase): def test_shortcut_combo_size_request(self): """Test that shortcut combo uses the shared control width.""" - self.assertIn("self.shortcut_combo.set_size_request(_CONTROL_WIDTH, -1)", self.source_code) + self.assertIn("_style_combo(self.shortcut_combo)", self.source_code) def test_shortcut_combo_tooltip(self): """Test that shortcut combo has a tooltip."""