User-facing docs, About page and website now point at the fork and the Void xbps repository, with one credit line to upstream Vocalinux. Channels this fork does not publish are removed from navigation.
10141 lines
433 KiB
Python
10141 lines
433 KiB
Python
"""
|
||
Settings Dialog for Voculinux.
|
||
|
||
Allows users to configure speech recognition engine, model size,
|
||
and other relevant parameters.
|
||
|
||
UX Design Notes:
|
||
- Follows GNOME Human Interface Guidelines (HIG) for modern desktop look
|
||
- 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
|
||
- Sidebar footer (always visible from any page): recognition state, mic
|
||
level, a dictation test, and the Close action
|
||
- Settings apply immediately when changed (instant-apply pattern)
|
||
- In-window Close button for WM compatibility (some WMs hide title bar close)
|
||
- Multi-modal feedback (text + icon + audio level) for accessibility
|
||
- Modal dialog for model downloads (explicit confirmation for large downloads)
|
||
"""
|
||
|
||
import itertools
|
||
import logging
|
||
import math
|
||
import os
|
||
import re
|
||
import threading
|
||
import time
|
||
from contextlib import contextmanager
|
||
from functools import partial
|
||
from typing import TYPE_CHECKING, Any, Callable, Iterator, Literal, NamedTuple, Optional, cast
|
||
from urllib.parse import urlparse
|
||
|
||
import gi
|
||
|
||
gi.require_version("Gtk", "3.0")
|
||
gi.require_version("Gdk", "3.0")
|
||
# Need GLib for idle_add
|
||
from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402
|
||
|
||
from ..app_identity import APP_NAME, RESOURCE_DIR_NAME, user_agent # noqa: E402
|
||
from ..audio.pulse_sources import ( # noqa: E402
|
||
PULSE_INDEX_BASE,
|
||
PulseSource,
|
||
get_pulse_sources,
|
||
is_pulse_device_index,
|
||
pulse_label_for_name,
|
||
)
|
||
from ..common_types import ( # noqa: E402
|
||
RecognitionState,
|
||
SpeechRecognitionManagerProtocol,
|
||
_EvdevCaptureDevice,
|
||
)
|
||
from ..custom_dictionary import normalize_corrections # noqa: E402
|
||
from ..gateway_embed import GatewayStatus, get_gateway_embed_manager # noqa: E402
|
||
from ..speech_recognition.silero_vad import is_silero_available # noqa: E402
|
||
from ..utils import faster_whisper_model_info as faster_whisper
|
||
from ..utils.faster_whisper_model_info import delete_model as delete_faster_whisper_model
|
||
from ..utils.faster_whisper_model_info import ( # noqa: E402
|
||
get_recommended_model as get_recommended_faster_whisper_model,
|
||
)
|
||
from ..utils.faster_whisper_model_info import (
|
||
is_model_downloaded as is_faster_whisper_model_downloaded,
|
||
)
|
||
from ..utils.faster_whisper_model_info import (
|
||
list_downloaded_models as list_downloaded_faster_whisper_models,
|
||
)
|
||
from ..utils.model_choice import (
|
||
BALANCED,
|
||
PRIORITIES,
|
||
PRIORITY_LABELS,
|
||
priority_for_size,
|
||
size_for_priority,
|
||
)
|
||
from ..utils.packaging import ( # noqa: E402
|
||
PACKAGED_UPDATE_HINT,
|
||
is_packaged_install,
|
||
)
|
||
from ..utils.paths import models_dir # noqa: E402
|
||
from ..utils.proxy import ( # noqa: E402
|
||
DEFAULT_PROXY_MODE,
|
||
DEFAULT_PROXY_PROTOCOL,
|
||
default_port_for_protocol,
|
||
normalize_proxy_mode,
|
||
normalize_proxy_protocol,
|
||
requests_proxies,
|
||
)
|
||
from ..utils.snap import SNAP_GPU_NOTE, running_in_snap # noqa: E402
|
||
from ..utils.system_language import ( # noqa: E402
|
||
LANGUAGE_FOLLOWS_LAYOUT,
|
||
language_for_active_layout,
|
||
)
|
||
from ..utils.update_checker import ( # noqa: E402
|
||
DEFAULT_CHECK_AUTOMATICALLY,
|
||
DEFAULT_UPDATE_CHANNEL,
|
||
ReleaseInfo,
|
||
build_channel,
|
||
fetch_latest_release,
|
||
format_release_notes,
|
||
is_trusted_release_url,
|
||
is_update_available,
|
||
normalize_channel,
|
||
)
|
||
from ..utils.vosk_model_info import ( # noqa: E402
|
||
delete_vosk_model,
|
||
list_downloaded_vosk_models,
|
||
vosk_model_dirname,
|
||
)
|
||
from ..utils.whisper_model_info import ( # noqa: E402
|
||
migrate_legacy_checkpoint_names,
|
||
whisper_model_file,
|
||
whisper_model_url,
|
||
)
|
||
from ..utils.whispercpp_model_info import delete_model as delete_whispercpp_model
|
||
from ..utils.whispercpp_model_info import (
|
||
detect_compute_backend,
|
||
detect_vulkan_devices,
|
||
get_backend_display_name,
|
||
)
|
||
from ..utils.whispercpp_model_info import get_model_size as get_whispercpp_model_size
|
||
from ..utils.whispercpp_model_info import get_model_variants as get_whispercpp_model_variants
|
||
from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model
|
||
from ..utils.whispercpp_model_info import is_english_only_model as is_english_only_whispercpp_model
|
||
from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded
|
||
from ..utils.whispercpp_model_info import (
|
||
list_downloaded_models as list_downloaded_whispercpp_models,
|
||
)
|
||
from ..utils.whispercpp_model_info import on_disk_stand_in as whispercpp_on_disk_stand_in
|
||
from ..version import __url__, __version__ # noqa: E402
|
||
from .config_manager import ( # noqa: E402
|
||
DEFAULT_CONFIG,
|
||
DEFAULT_PASTE_SHORTCUT,
|
||
DEFAULT_SOUND_EFFECT_TONE,
|
||
PASTE_SHORTCUTS,
|
||
SOUND_EFFECT_TONES,
|
||
normalize_language_shortcuts,
|
||
resolve_whispercpp_variant,
|
||
)
|
||
from .keyboard_backends import ( # noqa: E402
|
||
DEFAULT_SHORTCUT,
|
||
DEFAULT_SHORTCUT_MODE,
|
||
SHORTCUT_DISPLAY_NAMES,
|
||
SHORTCUT_GROUPS,
|
||
SHORTCUT_MODES,
|
||
SUPPORTED_SHORTCUTS,
|
||
get_shortcut_display_name,
|
||
is_valid_shortcut,
|
||
parse_shortcut_spec,
|
||
)
|
||
from .keyboard_backends.evdev_backend import MODIFIER_KEY_CODES # noqa: E402
|
||
from .settings_about import ( # noqa: F401
|
||
_ABOUT_ICON_TEXT_PX,
|
||
_ABOUT_INK,
|
||
_ABOUT_INK_ON_DARK,
|
||
_ABOUT_OPEN_URLS,
|
||
SOURCE_ISSUES_URL,
|
||
SOURCE_REPO_URL,
|
||
UPSTREAM_CREDIT_LINE,
|
||
UPSTREAM_REPO_URL,
|
||
VOCULINUX_SITE_URL,
|
||
AboutSectionMixin,
|
||
_about_ink_hex,
|
||
_about_surface_is_dark,
|
||
_can_open_url,
|
||
_color_luminance,
|
||
_rgba_to_hex,
|
||
)
|
||
from .settings_engines import ( # noqa: F401
|
||
FASTER_WHISPER_MODEL_INFO,
|
||
NON_DICTATION_MODELS,
|
||
SUPPORTED_LANGUAGES,
|
||
VOSK_MODEL_INFO,
|
||
WHISPER_MODEL_INFO,
|
||
WHISPERCPP_MODEL_INFO,
|
||
WHISPERCPP_MODEL_SIZES,
|
||
default_variant_for_size,
|
||
get_available_engines,
|
||
parakeet,
|
||
)
|
||
from .transcription_history import sanitize_max_items # noqa: E402
|
||
|
||
# Avoid circular imports for type checking
|
||
if TYPE_CHECKING:
|
||
from ..speech_recognition.recognition_manager import SpeechRecognitionManager # noqa: E402
|
||
from .config_manager import ConfigManager # noqa: E402
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Serializes settings applies against deferred-edit persistence across dialog
|
||
# instances: a dialog closed mid-apply hands its pending edits to a worker, and
|
||
# a reopened dialog shares the same engine and config manager, so both sides
|
||
# must agree on whose snapshot is newest.
|
||
_apply_settings_lock = threading.Lock()
|
||
# Live edits can arrive on GTK while a model applies on a worker. Hold this
|
||
# only around their short update/save boundary, never around a model load.
|
||
_live_settings_lock = threading.RLock()
|
||
_apply_settings_generation = 0
|
||
# Generation of the apply that last wrote each advanced key. A deferred
|
||
# persist uses it to tell "a newer apply rewrote this key" apart from "the
|
||
# config still holds the baseline" — a newer user choice can legally equal
|
||
# that baseline, so comparing values alone is not enough.
|
||
_apply_settings_written: dict[str, int] = {}
|
||
|
||
|
||
def _raw_audio_device_name(device_name: Optional[str]) -> Optional[str]:
|
||
"""Return the persisted device name without UI-only suffixes."""
|
||
if device_name is None:
|
||
return None
|
||
return device_name.removesuffix(" (default)")
|
||
|
||
|
||
def _resolve_audio_device_selection(
|
||
devices: list,
|
||
saved_index: Optional[int],
|
||
saved_name: Optional[str],
|
||
pulse_sources: Optional[list[PulseSource]] = None,
|
||
) -> Optional[int]:
|
||
"""Resolve a saved audio device to a currently listed index.
|
||
|
||
Returns the matched device index, or None when the saved device is gone
|
||
(caller should fall back to System Default).
|
||
|
||
A snap PulseAudio source is saved by its server name, which the list does
|
||
not show, so it is matched through the source's current label. A named
|
||
snap source is never matched by index: positions shift as sources come
|
||
and go, so the index may now belong to a different microphone. Pass the
|
||
``pulse_sources`` that built ``devices`` to avoid listing them again.
|
||
"""
|
||
saved_raw_name = _raw_audio_device_name(saved_name)
|
||
named_pulse_source = bool(saved_raw_name) and is_pulse_device_index(saved_index)
|
||
if named_pulse_source:
|
||
live_label = pulse_label_for_name(saved_raw_name, pulse_sources)
|
||
if live_label:
|
||
for idx, name, _is_default in devices:
|
||
if name == live_label and is_pulse_device_index(idx):
|
||
return int(idx)
|
||
if saved_raw_name:
|
||
for idx, name, _is_default in devices:
|
||
if name == saved_raw_name:
|
||
return int(idx)
|
||
if saved_index is not None and not named_pulse_source:
|
||
for idx, _name, _is_default in devices:
|
||
if idx == saved_index:
|
||
return saved_index
|
||
return None
|
||
|
||
|
||
# Define available models for each engine
|
||
ENGINE_MODELS = {
|
||
"vosk": [
|
||
"small",
|
||
"medium",
|
||
"large",
|
||
], # Note: 'large' maps to medium internally, as higher version wasn't available
|
||
"whisper": [
|
||
"tiny",
|
||
"base",
|
||
"small",
|
||
"medium",
|
||
"large",
|
||
], # Add more whisper sizes if needed
|
||
"whisper_cpp": [
|
||
*WHISPERCPP_MODEL_SIZES,
|
||
], # whisper.cpp top-level size buckets; variants are selected separately
|
||
"parakeet": [
|
||
*parakeet.MODEL_SIZES,
|
||
], # Parakeet TDT 0.6B int8 bundles
|
||
"faster_whisper": [
|
||
"tiny",
|
||
"tiny.en",
|
||
"base",
|
||
"base.en",
|
||
"small",
|
||
"small.en",
|
||
"medium",
|
||
"medium.en",
|
||
"large-v3",
|
||
], # catalog sizes plus English-only .en variants (no large-v3.en)
|
||
"remote_api": [], # Remote API does not need local models
|
||
}
|
||
|
||
# Tallest the "Unused downloads" list may grow before it starts scrolling
|
||
_UNUSED_DOWNLOADS_MAX_HEIGHT = 220
|
||
|
||
|
||
def _clamp_unused_downloads_height(natural_height: int) -> int:
|
||
"""Clamp a measured list height to the height the page gives the list."""
|
||
return min(_UNUSED_DOWNLOADS_MAX_HEIGHT, natural_height)
|
||
|
||
|
||
# Engine display name mapping
|
||
ENGINE_DISPLAY_NAMES = {
|
||
"vosk": "Vosk",
|
||
"whisper": "Whisper",
|
||
"whisper_cpp": "whisper.cpp",
|
||
"parakeet": "Parakeet",
|
||
"faster_whisper": "Faster Whisper",
|
||
"remote_api": "Remote API",
|
||
}
|
||
|
||
|
||
def _engine_display_name(engine: str) -> str:
|
||
"""Get the display name of the engine."""
|
||
return ENGINE_DISPLAY_NAMES.get(engine, engine.capitalize())
|
||
|
||
|
||
def _engine_from_display(display_name: str) -> str:
|
||
"""Reverse lookup engine ID from display name."""
|
||
for engine_id, name in ENGINE_DISPLAY_NAMES.items():
|
||
if name == display_name:
|
||
return engine_id
|
||
return display_name.lower()
|
||
|
||
|
||
MODEL_SELECTION_DOC_URL = (
|
||
"https://git.bongbetic.com/xavierk/voculinux/src/branch/main/docs/MODEL_SELECTION.md"
|
||
)
|
||
|
||
|
||
def _model_display_name(model_name: str) -> str:
|
||
"""Get a user-friendly model display name."""
|
||
if model_name == "large":
|
||
return "Large v3"
|
||
|
||
display_parts = []
|
||
for part in model_name.split("-"):
|
||
if part.endswith(".en"):
|
||
display_parts.append(part[:-3].capitalize())
|
||
display_parts.append("EN")
|
||
elif part.startswith("q"):
|
||
display_parts.append(part.upper())
|
||
elif part == "turbo":
|
||
display_parts.append("Turbo")
|
||
elif part == "tdrz":
|
||
display_parts.append("TinyDiarize")
|
||
elif part.startswith("v") and part[1:].isdigit():
|
||
display_parts.append(part)
|
||
else:
|
||
display_parts.append(part.capitalize())
|
||
|
||
return " ".join(display_parts)
|
||
|
||
|
||
def _model_specialization_display_name(model_name: str) -> str:
|
||
"""Get a concise label for a whisper.cpp model variant."""
|
||
if model_name == "large":
|
||
return "Standard v3"
|
||
|
||
quantization = None
|
||
for part in model_name.split("-"):
|
||
if part.startswith("q"):
|
||
quantization = part.upper()
|
||
break
|
||
|
||
if model_name.startswith("large-v") and "turbo" not in model_name:
|
||
version = next(
|
||
(part for part in model_name.split("-") if part.startswith("v")),
|
||
"large",
|
||
)
|
||
if quantization:
|
||
return f"{version} {quantization}"
|
||
return version
|
||
|
||
if is_english_only_whispercpp_model(model_name):
|
||
return f"English-only {quantization}" if quantization else "English-only"
|
||
|
||
if "turbo" in model_name:
|
||
if quantization:
|
||
return f"Turbo {quantization}"
|
||
return "Turbo"
|
||
|
||
if quantization:
|
||
return f"Quantized {quantization}"
|
||
|
||
return "Standard multilingual"
|
||
|
||
|
||
#: Engines that can honour the follow-layout mode. VOSK loads a different model
|
||
#: per language, so following a layout would stall the hotkey on a model reload;
|
||
#: Parakeet never consumes a catalog language at all. Module level, not a class
|
||
#: attribute, so tests driving the methods against a Mock still reach it.
|
||
FOLLOW_LAYOUT_ENGINES = ("whisper", "whisper_cpp", "faster_whisper", "remote_api")
|
||
|
||
#: Selected-setting keys that make up the model spec. While a spec that needs a
|
||
#: download is staged (#894), these stay out of the saved config until the
|
||
#: explicit Download confirmation; every other key applies and saves as usual.
|
||
_MODEL_SPEC_KEYS = ("engine", "model_size", "model_variant", "language")
|
||
|
||
#: Engine attributes a Settings edit can update live with the same clamping
|
||
#: ``reconfigure()`` applies — minus its recording cancel and any model
|
||
#: reload. Every other key (all ``whispercpp_*`` load-time params) stays
|
||
#: saved-only while a download is staged (#894).
|
||
_LIVE_ENGINE_SETTINGS = {
|
||
"vad_sensitivity": lambda value: max(1, min(5, int(value))),
|
||
"silence_timeout": lambda value: max(0.5, min(5.0, float(value))),
|
||
}
|
||
|
||
|
||
def _is_following_layout(dialog: Any) -> bool:
|
||
"""Whether the follow-keyboard-layout mode is on for this dialog (#821).
|
||
|
||
A module function reading a cached bool, deliberately not a method. Several
|
||
tests drive the dialog methods unbound against a ``Mock``, where a method
|
||
call on self resolves to a truthy Mock attribute and would invert every
|
||
guard below. The ``is True`` keeps a Mock attribute from passing either.
|
||
"""
|
||
return getattr(dialog, "_follow_layout_active", False) is True
|
||
|
||
|
||
def _language_for_whispercpp_variant(dialog: Any) -> Optional[str]:
|
||
"""Language that drives .en vs multilingual whisper.cpp derivation.
|
||
|
||
Follow-the-layout can land on any language, so it must keep multilingual
|
||
weights even when the picker is showing the current English layout. Using
|
||
that display as the derivation language selects ``.en``, Settings then
|
||
refuses the mode as English-only, and the next auto-apply persists a
|
||
concrete language and disarms follow (#821).
|
||
"""
|
||
if _is_following_layout(dialog):
|
||
return LANGUAGE_FOLLOWS_LAYOUT
|
||
# Load path: the saved mode is follow, but the switch has not been synced
|
||
# into ``_follow_layout_active`` yet. ``is True`` so a Mock stays out.
|
||
if (
|
||
getattr(dialog, "_follow_layout_saved", False) is True
|
||
and getattr(dialog, "_initializing", False) is True
|
||
):
|
||
return LANGUAGE_FOLLOWS_LAYOUT
|
||
value = dialog.language_combo.get_active_id() or dialog.language
|
||
return str(value)
|
||
|
||
|
||
def _language_is_english(language_id: str) -> bool:
|
||
"""Return whether a language ID maps to English for Whisper."""
|
||
info = SUPPORTED_LANGUAGES.get(language_id, {})
|
||
return isinstance(info, dict) and info.get("whisper") == "en"
|
||
|
||
|
||
def _decode_simple_languages(primary: str, wants_second: bool, secondary: Optional[str]) -> str:
|
||
"""Resolve the two simple language answers into what the engine accepts.
|
||
|
||
whisper takes one language or none, so naming a second language has to mean
|
||
automatic detection. The one exception is two English entries - en-US plus
|
||
en-IN, say - where English can still be pinned and the English-only weights,
|
||
which are the same size and better at English, stay available.
|
||
"""
|
||
if not wants_second:
|
||
return primary
|
||
if not secondary or secondary == primary:
|
||
return primary
|
||
if secondary == "auto":
|
||
return "auto"
|
||
if _language_is_english(primary) and _language_is_english(secondary):
|
||
return primary
|
||
return "auto"
|
||
|
||
|
||
def _vosk_display_is_coerced_fallback(
|
||
previous_engine: Optional[str],
|
||
displayed: Optional[str],
|
||
remembered: Optional[str],
|
||
) -> bool:
|
||
"""Return whether ``displayed`` is Vosk's en-us stand-in for ``remembered``.
|
||
|
||
Vosk has no auto-detect and no model for some catalog languages (e.g. Greek).
|
||
Those selections are shown as en-us; that fallback is not a user preference
|
||
and must not replace ``remembered`` (including auto).
|
||
"""
|
||
return (
|
||
previous_engine == "vosk"
|
||
and displayed == "en-us"
|
||
and bool(remembered)
|
||
and remembered != "en-us"
|
||
)
|
||
|
||
|
||
def _recommended_whispercpp_variant_for_language(
|
||
recommended_model: str,
|
||
reason: str,
|
||
language_id: str,
|
||
) -> tuple[str, str]:
|
||
"""Adjust a hardware recommendation to the selected language."""
|
||
recommended_size = get_whispercpp_model_size(recommended_model)
|
||
english_variant = f"{recommended_size}.en"
|
||
|
||
if _language_is_english(language_id) and english_variant in WHISPERCPP_MODEL_INFO:
|
||
return english_variant, reason
|
||
|
||
return recommended_model, reason
|
||
|
||
|
||
def _recommended_faster_whisper_variant_for_language(
|
||
recommended_model: str,
|
||
reason: str,
|
||
language_id: str,
|
||
) -> tuple[str, str]:
|
||
"""Adjust a Faster Whisper hardware recommendation to the selected language."""
|
||
english_variant = (
|
||
recommended_model if recommended_model.endswith(".en") else f"{recommended_model}.en"
|
||
)
|
||
if _language_is_english(language_id) and english_variant in FASTER_WHISPER_MODEL_INFO:
|
||
return english_variant, reason
|
||
return recommended_model, reason
|
||
|
||
|
||
def _default_whispercpp_variant_for_size(model_size: str, language_id: str) -> Optional[str]:
|
||
"""Return the default specialization for a user-selected size and language."""
|
||
return default_variant_for_size(model_size, _language_is_english(language_id))
|
||
|
||
|
||
def _is_language_paired_standard_variant(model_name: str) -> bool:
|
||
"""Return whether a model id is the bare multilingual / .en pair for its size.
|
||
|
||
Size bucket names (tiny, base, small, medium) are also the multilingual
|
||
specialization ids. Config often stores those ambiguous size-level defaults,
|
||
so they must not be treated as a locked multilingual choice.
|
||
"""
|
||
model_name = model_name.lower()
|
||
size = get_whispercpp_model_size(model_name)
|
||
if size == "large":
|
||
return model_name == "large"
|
||
return model_name in {size, f"{size}.en"}
|
||
|
||
|
||
def _mirror_whispercpp_english_variant(model_name: str) -> str:
|
||
"""Map medium <-> medium.en and medium-q5_0 <-> medium.en-q5_0 style ids."""
|
||
model_name = model_name.lower()
|
||
size = get_whispercpp_model_size(model_name)
|
||
english_prefix = f"{size}.en"
|
||
if model_name.startswith(english_prefix):
|
||
return size + model_name[len(english_prefix) :]
|
||
if model_name.startswith(size):
|
||
return english_prefix + model_name[len(size) :]
|
||
return model_name
|
||
|
||
|
||
def _whispercpp_variant_for_language(selected_model: str, language_id: str) -> str:
|
||
"""Retarget a saved specialization when language implies .en vs multilingual."""
|
||
selected_model = selected_model.lower()
|
||
size = get_whispercpp_model_size(selected_model)
|
||
variants = get_whispercpp_model_variants(size)
|
||
if selected_model not in variants:
|
||
return selected_model
|
||
|
||
wants_english = _language_is_english(language_id)
|
||
is_english_only = is_english_only_whispercpp_model(selected_model)
|
||
|
||
if _is_language_paired_standard_variant(selected_model):
|
||
default = _default_whispercpp_variant_for_size(size, language_id)
|
||
return default if default in variants else selected_model
|
||
|
||
if wants_english != is_english_only:
|
||
mirrored = _mirror_whispercpp_english_variant(selected_model)
|
||
if mirrored in variants:
|
||
return mirrored
|
||
if not wants_english and is_english_only:
|
||
default = _default_whispercpp_variant_for_size(size, language_id)
|
||
if default in variants:
|
||
return default
|
||
|
||
return selected_model
|
||
|
||
|
||
# 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
|
||
# Term rows are built on the UI thread, so a scanner-managed file with
|
||
# thousands of entries is capped and the remainder summarized in one row.
|
||
_MAX_TERMS_DISPLAYED = 500
|
||
# Rows appended per GLib idle slice: building thousands at once stalls the
|
||
# UI the same way an uncapped list would.
|
||
_TERMS_ROWS_PER_IDLE = 150
|
||
|
||
|
||
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)
|
||
if not isinstance(combo, Gtk.ComboBox):
|
||
return combo # a SearchablePicker sizes its own button
|
||
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
|
||
|
||
|
||
# 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 _set_accessible_name(widget: Gtk.Widget, name: str) -> None:
|
||
"""Set the ATK name so identical visible labels stay distinguishable."""
|
||
accessible = widget.get_accessible()
|
||
if accessible is not None:
|
||
accessible.set_name(name)
|
||
|
||
|
||
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."
|
||
)
|
||
MODEL_SPECIALIZATION_TOOLTIP = (
|
||
"Choose Standard multilingual unless you specifically need English-only accuracy, "
|
||
"lower-memory quantized models, Turbo speed, or a legacy large model."
|
||
)
|
||
FOLLOW_LAYOUT_TOOLTIP = (
|
||
"Re-reads your active keyboard layout at the start of every dictation and "
|
||
"picks the matching language. Useful if you switch layouts to work in more "
|
||
"than one language. Needs a multilingual model; not available for VOSK, "
|
||
"which loads a separate model per language."
|
||
)
|
||
|
||
LANGUAGE_TOOLTIP = (
|
||
"Choose the language you dictate in. Search the list. Picking a language "
|
||
"other than English switches off an English-only model."
|
||
)
|
||
|
||
|
||
def _model_specialization_tooltip(model_name: str) -> str:
|
||
"""Return hover guidance for a whisper.cpp specialization."""
|
||
is_english_only = is_english_only_whispercpp_model(model_name)
|
||
is_quantized = any(part.startswith("q") for part in model_name.split("-"))
|
||
|
||
if "turbo" in model_name:
|
||
if is_quantized:
|
||
return (
|
||
"Choose this for a faster large-v3 Turbo model with lower disk and memory use; "
|
||
"expect a small accuracy tradeoff from quantization."
|
||
)
|
||
return (
|
||
"Choose Turbo when you want high accuracy from a large model with less memory use "
|
||
"and faster inference than full large v3."
|
||
)
|
||
|
||
if model_name.startswith("large-v") and model_name != "large":
|
||
return (
|
||
"Choose this only if you specifically want that legacy large model version; "
|
||
"Standard v3 or Turbo is the better default for most users."
|
||
)
|
||
|
||
if is_english_only and is_quantized:
|
||
return (
|
||
"Choose this for English-only dictation on lower-memory systems; choose "
|
||
"multilingual if you use auto-detect or any non-English language."
|
||
)
|
||
|
||
if is_english_only:
|
||
return (
|
||
"Choose this when you dictate only in English. It can be better for English, "
|
||
"but it will not work for other languages."
|
||
)
|
||
|
||
if is_quantized:
|
||
return (
|
||
"Choose this on lower-memory systems or when download size matters; it uses "
|
||
"less disk and RAM with a possible accuracy tradeoff."
|
||
)
|
||
|
||
if model_name == "large":
|
||
return (
|
||
"Choose Standard v3 when you want the highest default accuracy and have enough "
|
||
"memory for a large model."
|
||
)
|
||
|
||
return (
|
||
"Choose Standard multilingual for most users, auto-detect, or any supported "
|
||
"non-English language."
|
||
)
|
||
|
||
|
||
# Models directory
|
||
MODELS_DIR = models_dir()
|
||
SYSTEM_MODELS_DIRS = [
|
||
f"/usr/local/share/{RESOURCE_DIR_NAME}/models",
|
||
f"/usr/share/{RESOURCE_DIR_NAME}/models",
|
||
]
|
||
|
||
# CSS for modern styling
|
||
SETTINGS_CSS = """
|
||
/* 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;
|
||
}
|
||
|
||
.sidebar-row {
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
margin: 1px 6px;
|
||
}
|
||
|
||
.sidebar-row:hover {
|
||
background-color: alpha(@theme_fg_color, 0.07);
|
||
}
|
||
|
||
.sidebar-row:selected {
|
||
background-color: alpha(@theme_selected_bg_color, 0.85);
|
||
color: @theme_selected_fg_color;
|
||
}
|
||
|
||
.sidebar-row label {
|
||
font-weight: 500;
|
||
font-size: 0.95em;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.sidebar-row:selected .sidebar-match-count {
|
||
color: @theme_selected_fg_color;
|
||
background-color: alpha(@theme_selected_fg_color, 0.2);
|
||
}
|
||
|
||
.sidebar-update-badge {
|
||
font-size: 0.75em;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
background-color: #26a269;
|
||
border-radius: 10px;
|
||
padding: 1px 7px;
|
||
}
|
||
|
||
.settings-search {
|
||
margin: 8px 6px 14px 6px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* Sidebar footer: dictation status, test action, and dialog close */
|
||
.sidebar-footer {
|
||
padding: 10px 6px 12px 6px;
|
||
}
|
||
|
||
.sidebar-footer button {
|
||
min-height: 30px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* Empty search results */
|
||
.search-empty-title {
|
||
font-size: 1.1em;
|
||
font-weight: bold;
|
||
color: @theme_unfocused_fg_color;
|
||
}
|
||
|
||
/* Recognition state label in the sidebar footer */
|
||
.status-strip-state {
|
||
font-weight: 500;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
/* Modern GNOME-style settings dialog */
|
||
.settings-dialog {
|
||
background-color: @theme_bg_color;
|
||
}
|
||
|
||
/* Preference group styling - card-like appearance */
|
||
.preferences-group {
|
||
background-color: @theme_base_color;
|
||
border-radius: 12px;
|
||
padding: 0;
|
||
margin: 6px 0;
|
||
border: 1px solid alpha(@borders, 0.5);
|
||
}
|
||
|
||
/* Keep child list backgrounds inside the card's rounded lower corners. */
|
||
.preferences-group-list {
|
||
background-color: transparent;
|
||
border-radius: 0 0 11px 11px;
|
||
}
|
||
|
||
.preferences-group-list > row:last-child,
|
||
.preferences-group-list > row:last-child:hover {
|
||
border-radius: 0 0 11px 11px;
|
||
}
|
||
|
||
.dictionary-entry-list {
|
||
background-color: transparent;
|
||
}
|
||
|
||
.preferences-group-title {
|
||
font-weight: bold;
|
||
font-size: 0.9em;
|
||
color: @theme_unfocused_fg_color;
|
||
padding: 12px 16px 6px 16px;
|
||
margin: 0;
|
||
}
|
||
|
||
/* Row styling */
|
||
.preference-row {
|
||
padding: 12px 16px;
|
||
min-height: 32px;
|
||
border-bottom: 1px solid alpha(@borders, 0.3);
|
||
}
|
||
|
||
.preference-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.preference-row:hover {
|
||
background-color: alpha(@theme_selected_bg_color, 0.1);
|
||
}
|
||
|
||
.preference-row-title {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.preference-row-subtitle {
|
||
font-size: 0.85em;
|
||
color: @theme_unfocused_fg_color;
|
||
}
|
||
|
||
/* Status indicators */
|
||
.status-success {
|
||
color: #26a269;
|
||
}
|
||
|
||
.status-warning {
|
||
color: #e5a50a;
|
||
}
|
||
|
||
.status-error {
|
||
color: #c01c28;
|
||
}
|
||
|
||
.status-info {
|
||
color: @theme_unfocused_fg_color;
|
||
}
|
||
|
||
/* Test area styling */
|
||
.test-area {
|
||
background-color: @theme_base_color;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
border: 1px solid alpha(@borders, 0.5);
|
||
}
|
||
|
||
.test-textview {
|
||
font-family: monospace;
|
||
font-size: 0.95em;
|
||
padding: 8px;
|
||
background-color: alpha(@theme_bg_color, 0.5);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
/* Level bars */
|
||
levelbar block.filled {
|
||
background-color: @theme_selected_bg_color;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
levelbar block.empty {
|
||
background-color: alpha(@theme_fg_color, 0.1);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* Section headers */
|
||
.section-header {
|
||
font-size: 1.1em;
|
||
font-weight: bold;
|
||
margin-top: 12px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
/* Flat helper notices — muted surface, no accent strip */
|
||
.info-box {
|
||
background-color: alpha(@theme_fg_color, 0.04);
|
||
border: 1px solid alpha(@borders, 0.45);
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.info-box image {
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.info-box-warning {
|
||
background-color: alpha(#e5a50a, 0.08);
|
||
border-color: alpha(#e5a50a, 0.4);
|
||
}
|
||
|
||
/* Recognition status */
|
||
.recognition-idle {
|
||
color: @theme_unfocused_fg_color;
|
||
}
|
||
|
||
.recognition-listening {
|
||
color: #26a269;
|
||
}
|
||
|
||
.recognition-processing {
|
||
color: #e5a50a;
|
||
}
|
||
|
||
.recognition-error {
|
||
color: #c01c28;
|
||
}
|
||
|
||
/* Buttons */
|
||
.suggested-action {
|
||
background-color: @theme_selected_bg_color;
|
||
color: @theme_selected_fg_color;
|
||
}
|
||
|
||
.flat-button {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 8px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.flat-button:hover {
|
||
background-color: alpha(@theme_fg_color, 0.1);
|
||
}
|
||
|
||
/* Scrolled content */
|
||
.scrolled-content {
|
||
background-color: transparent;
|
||
}
|
||
|
||
/* Model status: caption under the engine group, not a second card */
|
||
.model-info-card {
|
||
background-color: transparent;
|
||
border-radius: 0;
|
||
padding: 2px 16px 8px 16px;
|
||
margin: 0;
|
||
}
|
||
|
||
.model-info-title {
|
||
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;
|
||
}
|
||
|
||
/* Sibling expander cards on Speech Model (Advanced, Unused downloads).
|
||
Padding lives on the expander, not the title class: .preferences-group-title
|
||
already has 16px inset, which stacked with expander margin and shoved the
|
||
chevron off the unused-downloads title. */
|
||
.expander-card expander {
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.expander-card-title {
|
||
font-weight: bold;
|
||
font-size: 0.9em;
|
||
color: @theme_unfocused_fg_color;
|
||
}
|
||
|
||
.expander-card-subtitle {
|
||
font-size: 0.85em;
|
||
color: @theme_unfocused_fg_color;
|
||
}
|
||
|
||
.expander-card list {
|
||
background-color: transparent;
|
||
}
|
||
|
||
.model-info-subtitle {
|
||
font-size: 0.9em;
|
||
color: @theme_unfocused_fg_color;
|
||
}
|
||
|
||
/* Tip styling */
|
||
.tip-label {
|
||
font-size: 0.85em;
|
||
color: @theme_unfocused_fg_color;
|
||
font-style: italic;
|
||
}
|
||
|
||
.tip-highlight {
|
||
font-weight: bold;
|
||
color: @theme_selected_bg_color;
|
||
}
|
||
"""
|
||
|
||
|
||
def _setup_css():
|
||
"""Set up CSS styling for the settings dialog."""
|
||
css_provider = Gtk.CssProvider()
|
||
css_provider.load_from_data(SETTINGS_CSS.encode())
|
||
Gtk.StyleContext.add_provider_for_screen(
|
||
Gdk.Screen.get_default(),
|
||
css_provider,
|
||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||
)
|
||
|
||
|
||
def _prevent_scroll_on_hover(widget: Gtk.Widget):
|
||
"""
|
||
Prevent scroll events from modifying widget values when hovering.
|
||
|
||
Unfocused ComboBox/SpinButton widgets ignore wheel input so scrolling a
|
||
settings tab does not accidentally change values. The event is applied to
|
||
the nearest ancestor ScrolledWindow instead, so the page still scrolls.
|
||
"""
|
||
|
||
def on_scroll(widget, event):
|
||
if widget.has_focus():
|
||
return False
|
||
scrolled = widget.get_ancestor(Gtk.ScrolledWindow)
|
||
if scrolled is not None:
|
||
scrolled.event(event)
|
||
return True
|
||
|
||
widget.connect("scroll-event", on_scroll)
|
||
widget.set_can_focus(True)
|
||
|
||
|
||
def _combo_text_matches_query(query: str, item_id: str, item_text: str) -> bool:
|
||
"""Return whether a combo row matches a case-insensitive search query."""
|
||
needle = (query or "").strip().lower()
|
||
if not needle:
|
||
return True
|
||
return needle in (item_text or "").lower() or needle in (item_id or "").lower()
|
||
|
||
|
||
def _resolve_combo_text_query(query: str, rows: list) -> Optional[str]:
|
||
"""Return a unique matching combo id, preferring an exact name or id."""
|
||
needle = (query or "").strip()
|
||
if not needle:
|
||
return None
|
||
needle_l = needle.lower()
|
||
matches = []
|
||
for item_id, item_text in rows:
|
||
item_id = item_id or ""
|
||
item_text = item_text or ""
|
||
if item_text.lower() == needle_l or item_id.lower() == needle_l:
|
||
return item_id
|
||
if _combo_text_matches_query(needle, item_id, item_text):
|
||
matches.append(item_id)
|
||
if len(matches) == 1:
|
||
return matches[0]
|
||
return None
|
||
|
||
|
||
def _combo_text_rows(combo: Gtk.ComboBoxText) -> list:
|
||
"""Return ``(id, display text)`` pairs from a ComboBoxText model.
|
||
|
||
Gtk.ComboBoxText stores display text in column 0 and the id in column 1.
|
||
"""
|
||
# A SearchablePicker keeps its rows in base_model (ComboBoxText column order);
|
||
# resolve typed text against every row, not only the ones currently shown.
|
||
model = getattr(combo, "base_model", None) or combo.get_model()
|
||
if not model:
|
||
return []
|
||
return [(row[1], row[0]) for row in model]
|
||
|
||
|
||
def _combo_completion_match(completion, key, tree_iter, *_args) -> bool:
|
||
"""GtkEntryCompletion match function for ComboBoxText text/id columns."""
|
||
model = completion.get_model()
|
||
if model is None:
|
||
return False
|
||
row = model[tree_iter]
|
||
return _combo_text_matches_query(key, row[1], row[0])
|
||
|
||
|
||
class SearchablePicker(Gtk.Box):
|
||
"""A picker you can type into while its list is open.
|
||
|
||
A GtkComboBox dropdown takes the keyboard for its own first-letter jump the
|
||
moment it opens, so nothing typed reaches a filter. This one opens a popover
|
||
holding a search entry, focused on open, above the list; every keystroke
|
||
narrows the rows, Enter takes the first match after a non-empty filter, and
|
||
a click takes that row. Empty Enter is a no-op so it cannot auto-apply the
|
||
first store row.
|
||
|
||
Keeps the handful of GtkComboBoxText methods the dialog relies on, so it
|
||
drops in where one stood. ``base_model`` mirrors the ComboBoxText store —
|
||
display text in column 0, id in column 1 — for the shared row helpers.
|
||
"""
|
||
|
||
__gsignals__ = {"changed": (GObject.SignalFlags.RUN_FIRST, None, ())}
|
||
|
||
_LIST_HEIGHT = 300
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||
self.base_model: list[list] = []
|
||
self._active_id: Optional[str] = None
|
||
self._rows_by_id: dict = {}
|
||
|
||
self._label = Gtk.Label(xalign=0)
|
||
self._label.set_ellipsize(Pango.EllipsizeMode.END)
|
||
arrow = Gtk.Image.new_from_icon_name("pan-down-symbolic", Gtk.IconSize.BUTTON)
|
||
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||
button_box.pack_start(self._label, True, True, 0)
|
||
button_box.pack_end(arrow, False, False, 0)
|
||
self._button = Gtk.Button()
|
||
self._button.add(button_box)
|
||
self._button.connect("clicked", self._on_button_clicked)
|
||
self.pack_start(self._button, True, True, 0)
|
||
|
||
self._search = Gtk.SearchEntry()
|
||
self._search.set_placeholder_text("Search…")
|
||
self._search.connect("search-changed", self._on_search_changed)
|
||
self._search.connect("activate", self._on_search_activate)
|
||
|
||
self._list = Gtk.ListBox()
|
||
self._list.set_selection_mode(Gtk.SelectionMode.NONE)
|
||
self._list.set_filter_func(self._row_is_visible)
|
||
self._list.connect("row-activated", self._on_row_activated)
|
||
|
||
scroller = Gtk.ScrolledWindow()
|
||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
scroller.set_size_request(-1, self._LIST_HEIGHT)
|
||
scroller.add(self._list)
|
||
|
||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||
content.set_border_width(6)
|
||
content.pack_start(self._search, False, False, 0)
|
||
content.pack_start(scroller, True, True, 0)
|
||
content.show_all()
|
||
|
||
self._popover = Gtk.Popover.new(self._button)
|
||
self._popover.set_position(Gtk.PositionType.BOTTOM)
|
||
self._popover.add(content)
|
||
self._popover.connect("closed", self._on_popover_closed)
|
||
|
||
# -- GtkComboBoxText-compatible surface --------------------------------
|
||
|
||
def append(self, item_id: Optional[str], text: str) -> None:
|
||
"""Add a row, taking the arguments in GtkComboBoxText order."""
|
||
self.base_model.append([text, item_id])
|
||
row = Gtk.ListBoxRow()
|
||
label = Gtk.Label(label=text, xalign=0)
|
||
label.set_margin_top(6)
|
||
label.set_margin_bottom(6)
|
||
label.set_margin_start(8)
|
||
label.set_margin_end(8)
|
||
row.add(label)
|
||
row.item_id = item_id
|
||
row.item_text = text
|
||
row.show_all()
|
||
self._list.add(row)
|
||
self._rows_by_id[item_id] = row
|
||
|
||
def remove_all(self) -> None:
|
||
"""Drop every row and clear the active selection."""
|
||
self.base_model.clear()
|
||
self._rows_by_id.clear()
|
||
for row in list(self._list.get_children()):
|
||
self._list.remove(row)
|
||
self._active_id = None
|
||
self._label.set_text("")
|
||
|
||
def clear_active(self, text: str = "") -> None:
|
||
"""Clear the selected id and show an optional readout."""
|
||
self._active_id = None
|
||
self._label.set_text(text)
|
||
|
||
def get_model(self) -> list:
|
||
"""The ComboBoxText-shaped store: [display text, id] per row."""
|
||
return self.base_model
|
||
|
||
def get_active_id(self) -> Optional[str]:
|
||
"""The id of the selected row, or None."""
|
||
return self._active_id
|
||
|
||
def get_active_text(self) -> Optional[str]:
|
||
"""The display text of the selected row, or None."""
|
||
row = self._rows_by_id.get(self._active_id)
|
||
return row.item_text if row is not None else None
|
||
|
||
def set_active_id(self, item_id: Optional[str]) -> bool:
|
||
"""Select by id; False if that id is not in the list."""
|
||
row = self._rows_by_id.get(item_id)
|
||
if row is None:
|
||
return False
|
||
self._select(item_id, row.item_text)
|
||
return True
|
||
|
||
def set_active(self, index: int) -> None:
|
||
"""Select by store index; no-op if the index is out of range."""
|
||
if 0 <= index < len(self.base_model):
|
||
text, item_id = self.base_model[index]
|
||
self._select(item_id, text)
|
||
|
||
def get_child(self) -> Gtk.SearchEntry:
|
||
"""The search entry, for callers that hook a ComboBoxText's entry."""
|
||
return self._search
|
||
|
||
# -- behaviour ---------------------------------------------------------
|
||
|
||
def _select(self, item_id: Optional[str], text: Optional[str]) -> None:
|
||
changed = item_id != self._active_id
|
||
self._active_id = item_id
|
||
self._label.set_text(text or "")
|
||
if changed:
|
||
self.emit("changed")
|
||
|
||
def _on_button_clicked(self, _button: Gtk.Button) -> None:
|
||
self._search.set_text("")
|
||
self._list.invalidate_filter()
|
||
self._popover.show_all()
|
||
self._popover.popup()
|
||
self._search.grab_focus()
|
||
|
||
def _on_popover_closed(self, _popover: Gtk.Popover) -> None:
|
||
self._search.set_text("")
|
||
self._list.invalidate_filter()
|
||
|
||
def _row_is_visible(self, row: Gtk.ListBoxRow) -> bool:
|
||
needle = (self._search.get_text() or "").strip()
|
||
if not needle:
|
||
return True
|
||
# Same rule the typed-text resolver uses, so the list and Enter agree.
|
||
return _combo_text_matches_query(needle, row.item_id, row.item_text)
|
||
|
||
def _on_search_changed(self, _entry: Gtk.SearchEntry) -> None:
|
||
self._list.invalidate_filter()
|
||
|
||
def _first_visible_row(self) -> Optional[Gtk.ListBoxRow]:
|
||
for row in self._list.get_children():
|
||
if row.get_visible() and row.get_child_visible() and self._row_is_visible(row):
|
||
return row
|
||
return None
|
||
|
||
def _on_search_activate(self, _entry: Gtk.SearchEntry) -> None:
|
||
# Empty (or whitespace-only) Enter must not grab the first store row and
|
||
# auto-apply a language the user never chose. First-match only after a
|
||
# non-empty filter.
|
||
needle = (self._search.get_text() or "").strip()
|
||
if not needle:
|
||
return
|
||
row = self._first_visible_row()
|
||
if row is not None:
|
||
self._on_row_activated(self._list, row)
|
||
|
||
def _on_row_activated(self, _listbox: Gtk.ListBox, row: Gtk.ListBoxRow) -> None:
|
||
self._popover.popdown()
|
||
self._select(row.item_id, row.item_text)
|
||
|
||
|
||
def _attach_language_combo_search(combo: Gtk.ComboBoxText) -> None:
|
||
"""Filter language choices as the user types in a ComboBoxText entry."""
|
||
if isinstance(combo, SearchablePicker):
|
||
return # the picker filters its own list; a completion popup would fight it
|
||
entry = combo.get_child()
|
||
if entry is None:
|
||
return
|
||
entry.set_placeholder_text("Search languages…")
|
||
completion = Gtk.EntryCompletion()
|
||
completion.set_model(getattr(combo, "base_model", None) or combo.get_model())
|
||
# ComboBoxText store: column 0 is display text, column 1 is id.
|
||
completion.set_text_column(0)
|
||
completion.set_inline_completion(False)
|
||
completion.set_popup_completion(True)
|
||
completion.set_minimum_key_length(1)
|
||
completion.set_match_func(_combo_completion_match)
|
||
entry.set_completion(completion)
|
||
|
||
|
||
def _get_whisper_cache_dir() -> str:
|
||
"""Get the Whisper model cache directory."""
|
||
return os.path.join(MODELS_DIR, "whisper")
|
||
|
||
|
||
def _whisper_model_files(model_name: str) -> list[str]:
|
||
"""Return existing OpenAI Whisper weight files for a catalog model name."""
|
||
if model_name not in WHISPER_MODEL_INFO:
|
||
return []
|
||
|
||
filename = whisper_model_file(model_name)
|
||
candidates = [
|
||
os.path.join(_get_whisper_cache_dir(), filename),
|
||
os.path.join(os.path.expanduser("~/.cache/whisper"), filename),
|
||
]
|
||
allowed_parents = {
|
||
os.path.realpath(_get_whisper_cache_dir()),
|
||
os.path.realpath(os.path.expanduser("~/.cache/whisper")),
|
||
}
|
||
|
||
found: list[str] = []
|
||
seen: set[str] = set()
|
||
for candidate in candidates:
|
||
if not os.path.isfile(candidate):
|
||
continue
|
||
real = os.path.realpath(candidate)
|
||
if real in seen or os.path.dirname(real) not in allowed_parents:
|
||
continue
|
||
if os.path.basename(real) != filename:
|
||
continue
|
||
seen.add(real)
|
||
found.append(real)
|
||
return found
|
||
|
||
|
||
def _is_whisper_model_downloaded(model_name: str) -> bool:
|
||
"""Check if a Whisper model is downloaded.
|
||
|
||
Only the directory the engine passes to ``whisper.load_model`` as
|
||
``download_root`` counts. A copy in the default ~/.cache/whisper is not used
|
||
by the engine, so reporting it as downloaded would promise the user a model
|
||
that selecting it then spends up to 2.9GB fetching.
|
||
|
||
That has a corollary worth stating plainly, because this function is what
|
||
``_list_downloaded_whisper_models`` filters on and the dialog can only delete
|
||
what it lists: deleting a model here also removes a copy in
|
||
~/.cache/whisper, since ``_whisper_model_files`` returns both, but a model
|
||
that exists *only* there is not listed and so cannot be reclaimed from this
|
||
dialog at all.
|
||
"""
|
||
model_file = os.path.join(_get_whisper_cache_dir(), whisper_model_file(model_name))
|
||
return os.path.exists(model_file)
|
||
|
||
|
||
def _list_downloaded_whisper_models() -> list[str]:
|
||
"""Return OpenAI Whisper catalog names that are present on disk.
|
||
|
||
Checkpoints an earlier release stored under the catalog name are renamed
|
||
first, so a "large" downloaded back then is listed here (and can therefore be
|
||
deleted) instead of sitting on disk unreachable from this dialog.
|
||
"""
|
||
migrate_legacy_checkpoint_names(_get_whisper_cache_dir())
|
||
return [name for name in WHISPER_MODEL_INFO if _is_whisper_model_downloaded(name)]
|
||
|
||
|
||
def _delete_whisper_model(model_name: str) -> list[str]:
|
||
"""Delete OpenAI Whisper weight files for a catalog model name."""
|
||
if model_name not in WHISPER_MODEL_INFO:
|
||
raise ValueError(f"Unknown Whisper model: {model_name}")
|
||
|
||
files = _whisper_model_files(model_name)
|
||
if not files:
|
||
raise FileNotFoundError(model_name)
|
||
|
||
for path in files:
|
||
os.remove(path)
|
||
logger.info("Deleted Whisper model %s (%s)", model_name, path)
|
||
return files
|
||
|
||
|
||
def _format_size(size_mb: int) -> str:
|
||
"""Format size in MB to human readable string."""
|
||
if size_mb >= 1000:
|
||
return f"{size_mb / 1000:.1f} GB"
|
||
return f"{size_mb} MB"
|
||
|
||
|
||
def _get_recommended_whisper_model() -> tuple:
|
||
"""Get recommended model based on system configuration."""
|
||
import warnings
|
||
|
||
try:
|
||
import psutil
|
||
|
||
ram_gb = math.ceil(psutil.virtual_memory().total / (1024**3))
|
||
|
||
# Check for CUDA - suppress warnings during detection
|
||
has_cuda = False
|
||
cuda_memory_gb = 0
|
||
try:
|
||
with warnings.catch_warnings():
|
||
warnings.simplefilter("ignore")
|
||
import torch
|
||
|
||
if torch.cuda.is_available():
|
||
has_cuda = True
|
||
cuda_memory_gb = torch.cuda.get_device_properties(0).total_memory // (1024**3)
|
||
except Exception:
|
||
pass
|
||
|
||
if has_cuda and cuda_memory_gb >= 8:
|
||
return "medium", f"GPU with {cuda_memory_gb}GB VRAM"
|
||
elif has_cuda and cuda_memory_gb >= 4:
|
||
return "small", f"GPU with {cuda_memory_gb}GB VRAM"
|
||
elif ram_gb >= 8:
|
||
return "small", f"{ram_gb}GB RAM - good balance"
|
||
elif ram_gb >= 4:
|
||
return "base", f"{ram_gb}GB RAM"
|
||
else:
|
||
return "tiny", f"Limited RAM ({ram_gb}GB)"
|
||
except Exception:
|
||
return "base", "Default recommendation"
|
||
|
||
|
||
def _is_vosk_model_downloaded(size: str, language: str) -> bool:
|
||
"""Check if a VOSK model is downloaded."""
|
||
if size not in VOSK_MODEL_INFO:
|
||
return False
|
||
|
||
# Auto-detect is not supported by VOSK, fall back to en-us
|
||
languages = cast(dict[str, str], VOSK_MODEL_INFO[size]["languages"])
|
||
if language == "auto" or language not in languages:
|
||
language = "en-us"
|
||
|
||
model_name = languages[language]
|
||
|
||
# Check user's local models directory
|
||
user_model_path = os.path.join(MODELS_DIR, model_name)
|
||
if os.path.exists(user_model_path):
|
||
return True
|
||
|
||
# Check system-wide installation directories
|
||
for system_dir in SYSTEM_MODELS_DIRS:
|
||
system_model_path = os.path.join(system_dir, model_name)
|
||
if os.path.exists(system_model_path):
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def _get_recommended_vosk_model() -> tuple:
|
||
"""Get recommended VOSK model based on system configuration."""
|
||
try:
|
||
import psutil
|
||
|
||
ram_gb = math.ceil(psutil.virtual_memory().total / (1024**3))
|
||
|
||
# VOSK models are CPU-based, so we recommend based on RAM and disk space
|
||
if ram_gb >= 4:
|
||
return "medium", f"{ram_gb}GB RAM - better accuracy"
|
||
else:
|
||
return "small", f"Limited RAM ({ram_gb}GB) - optimized for speed"
|
||
except Exception:
|
||
return "small", "Default recommendation"
|
||
|
||
|
||
class ModelRecommendation(NamedTuple):
|
||
"""The model this system should use for an engine, ready to present."""
|
||
|
||
model_id: str
|
||
reason: str
|
||
display_name: str
|
||
size_label: str
|
||
|
||
|
||
def recommended_model_for_engine(
|
||
engine: str, language: str = "auto"
|
||
) -> Optional[ModelRecommendation]:
|
||
"""Return the model to download for an engine, or None if it needs none.
|
||
|
||
This is the recommendation the model picker marks with a star, exposed for
|
||
callers outside the dialog (the tray offers it when dictation is attempted
|
||
without a model).
|
||
"""
|
||
if engine == "whisper_cpp":
|
||
recommended_model, reason = get_recommended_whispercpp_model()
|
||
model_id, reason = _recommended_whispercpp_variant_for_language(
|
||
recommended_model, reason, language
|
||
)
|
||
size_mb = WHISPERCPP_MODEL_INFO.get(model_id, {}).get("size_mb", 0)
|
||
elif engine == "whisper":
|
||
model_id, reason = _get_recommended_whisper_model()
|
||
size_mb = WHISPER_MODEL_INFO.get(model_id, {}).get("size_mb", 0)
|
||
elif engine == "vosk":
|
||
model_id, reason = _get_recommended_vosk_model()
|
||
size_mb = VOSK_MODEL_INFO.get(model_id, {}).get("size_mb", 0)
|
||
elif engine == "parakeet":
|
||
model_id = parakeet.RECOMMENDED_MODEL
|
||
reason = parakeet.RECOMMENDED_REASON
|
||
size_mb = parakeet.PARAKEET_MODEL_INFO.get(model_id, {}).get("size_mb", 0)
|
||
elif engine == "faster_whisper":
|
||
recommended_model, reason = get_recommended_faster_whisper_model()
|
||
model_id, reason = _recommended_faster_whisper_variant_for_language(
|
||
recommended_model, reason, language
|
||
)
|
||
size_mb = FASTER_WHISPER_MODEL_INFO.get(model_id, {}).get("size_mb", 0)
|
||
else:
|
||
# Remote API transcribes server-side; there is nothing to download.
|
||
return None
|
||
|
||
size_mb = size_mb if isinstance(size_mb, int) else 0
|
||
return ModelRecommendation(
|
||
model_id=model_id,
|
||
reason=reason,
|
||
display_name=_model_display_name(model_id),
|
||
size_label=_format_size(size_mb),
|
||
)
|
||
|
||
|
||
# GDK key-symbol names that are themselves modifiers (skipped while recording).
|
||
_GDK_MODIFIER_KEYNAMES = {
|
||
"Control_L",
|
||
"Control_R",
|
||
"Alt_L",
|
||
"Alt_R",
|
||
"Shift_L",
|
||
"Shift_R",
|
||
"Super_L",
|
||
"Super_R",
|
||
"Meta_L",
|
||
"Meta_R",
|
||
"Hyper_L",
|
||
"Hyper_R",
|
||
"ISO_Level3_Shift",
|
||
}
|
||
|
||
# GDK key-symbol names -> canonical main-key tokens (irregular names only;
|
||
# single letters/digits and function keys are handled by rule).
|
||
_GDK_KEYNAME_TOKENS = {
|
||
"space": "space",
|
||
"Return": "enter",
|
||
"KP_Enter": "enter",
|
||
"Tab": "tab",
|
||
"Escape": "esc",
|
||
"BackSpace": "backspace",
|
||
"Delete": "delete",
|
||
"Insert": "insert",
|
||
"Home": "home",
|
||
"End": "end",
|
||
"Page_Up": "pageup",
|
||
"Page_Down": "pagedown",
|
||
"Up": "up",
|
||
"Down": "down",
|
||
"Left": "left",
|
||
"Right": "right",
|
||
"comma": "comma",
|
||
"period": "period",
|
||
"slash": "slash",
|
||
"semicolon": "semicolon",
|
||
"apostrophe": "apostrophe",
|
||
"grave": "grave",
|
||
"minus": "minus",
|
||
"equal": "equal",
|
||
"bracketleft": "leftbracket",
|
||
"bracketright": "rightbracket",
|
||
"backslash": "backslash",
|
||
}
|
||
|
||
|
||
def _gdk_keyname_to_token(name: Optional[str]) -> Optional[str]:
|
||
"""Map a GDK keyval name to a canonical main-key token, or None."""
|
||
if not name:
|
||
return None
|
||
if name in _GDK_KEYNAME_TOKENS:
|
||
return _GDK_KEYNAME_TOKENS[name]
|
||
if len(name) == 1 and name.isalnum():
|
||
return name.lower()
|
||
if re.fullmatch(r"[Ff]([1-9]|1[0-9]|2[0-4])", name):
|
||
return name.lower()
|
||
return None
|
||
|
||
|
||
def function_token_from_gdk_hardware_keycode(hardware_keycode: int) -> Optional[str]:
|
||
"""Map XKB/GDK hardware keycodes 191–202 (FK13–FK24) to f13–f24.
|
||
|
||
Default evdev maps those keys to XF86* keysyms or NoSymbol; the XKB
|
||
keycode is the unambiguous signal. F1–F12 use other keycodes and already
|
||
arrive as F1–F12 keysyms.
|
||
"""
|
||
try:
|
||
code = int(hardware_keycode)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if 191 <= code <= 202:
|
||
return f"f{code - 178}"
|
||
return None
|
||
|
||
|
||
def function_token_from_evdev_code(code: int) -> Optional[str]:
|
||
"""Map evdev KEY_F13–KEY_F24 (183–194) to f13–f24."""
|
||
try:
|
||
key = int(code)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if 183 <= key <= 194:
|
||
return f"f{key - 170}"
|
||
return None
|
||
|
||
|
||
def modifiers_from_active_evdev_codes(active_codes: set[int]) -> list[str]:
|
||
"""Return ctrl/alt/shift/super currently held in *active_codes*, in that order."""
|
||
held: list[str] = []
|
||
for name in ("ctrl", "alt", "shift", "super"):
|
||
if active_codes & MODIFIER_KEY_CODES[name]:
|
||
held.append(name)
|
||
return held
|
||
|
||
|
||
def _shortcut_from_capture(modifiers: list[str], token: Optional[str]) -> Optional[str]:
|
||
"""Build a canonical shortcut from recorded modifiers and a main key."""
|
||
if token is None:
|
||
return None
|
||
candidate = "+".join((*modifiers, token)) if modifiers else token
|
||
return candidate if is_valid_shortcut(candidate) else None
|
||
|
||
|
||
def _gdk_capture_to_shortcut(
|
||
modifiers: list[str],
|
||
keyval_name: Optional[str],
|
||
hardware_keycode: int = 0,
|
||
) -> Optional[str]:
|
||
"""Build a shortcut from recorder inputs, preferring FK13–FK24 keycodes."""
|
||
token = function_token_from_gdk_hardware_keycode(hardware_keycode)
|
||
if token is None:
|
||
token = _gdk_keyname_to_token(keyval_name)
|
||
return _shortcut_from_capture(modifiers, token)
|
||
|
||
|
||
# linux/input.h EV_KEY; python-evdev uses the same value.
|
||
_EVDEV_EV_KEY = 1
|
||
|
||
|
||
class _EvdevShortcutRecorder:
|
||
"""Read-only F13–F24 capture from keyboard evdev nodes (no grab, no thread).
|
||
|
||
GTK 3 on Wayland drops XKB NoSymbol keys before they become GdkEvents, so
|
||
FK19/FK24 never reach the dialog key-press handler on the default map.
|
||
Unions modifiers across watched keyboards, and drops a failed device while
|
||
still scanning until recording stops.
|
||
"""
|
||
|
||
def __init__(self, on_shortcut: Callable[[str], None]) -> None:
|
||
self._on_shortcut = on_shortcut
|
||
self._active = False
|
||
self._devices: dict[str, _EvdevCaptureDevice] = {}
|
||
self._watch_ids: dict[str, Any] = {}
|
||
self._held: dict[str, set[int]] = {}
|
||
self._scan_timeout_id: Optional[Any] = None
|
||
self._input_device_cls: Any = None
|
||
self._find_keyboard_devices: Optional[Callable[[], list[str]]] = None
|
||
|
||
def start(self) -> None:
|
||
try:
|
||
from .keyboard_backends.evdev_backend import (
|
||
DEVICE_RESCAN_SECONDS,
|
||
EVDEV_AVAILABLE,
|
||
find_keyboard_devices,
|
||
)
|
||
except ImportError:
|
||
logger.debug("evdev unavailable for shortcut recording; using GDK events only")
|
||
return
|
||
if not EVDEV_AVAILABLE:
|
||
logger.debug("evdev unavailable for shortcut recording; using GDK events only")
|
||
return
|
||
try:
|
||
from evdev import InputDevice
|
||
except ImportError:
|
||
logger.debug("evdev unavailable for shortcut recording; using GDK events only")
|
||
return
|
||
|
||
self._active = True
|
||
self._input_device_cls = InputDevice
|
||
self._find_keyboard_devices = find_keyboard_devices
|
||
|
||
paths: list[str] = []
|
||
listed = False
|
||
try:
|
||
paths = list(find_keyboard_devices())
|
||
listed = True
|
||
except Exception as e:
|
||
logger.warning("Cannot list keyboard devices for shortcut recording: %s", e)
|
||
|
||
open_error: Optional[tuple[str, BaseException]] = None
|
||
for path in paths:
|
||
err = self._attach_device(path)
|
||
if err is not None and open_error is None:
|
||
open_error = (path, err)
|
||
|
||
if not self._devices:
|
||
if open_error is not None:
|
||
logger.warning(
|
||
"Cannot open keyboard devices for shortcut recording "
|
||
"(%s: %s); F-keys without keysyms will not be captured",
|
||
open_error[0],
|
||
open_error[1],
|
||
)
|
||
elif listed and not paths:
|
||
logger.debug("No keyboard devices found for shortcut recording")
|
||
elif listed:
|
||
logger.debug("No keyboard devices opened for shortcut recording")
|
||
|
||
if self._active:
|
||
self._scan_timeout_id = GLib.timeout_add(
|
||
int(DEVICE_RESCAN_SECONDS * 1000),
|
||
self._on_device_rescan,
|
||
)
|
||
|
||
def stop(self) -> None:
|
||
self._active = False
|
||
timeout_id = self._scan_timeout_id
|
||
self._scan_timeout_id = None
|
||
if timeout_id is not None:
|
||
try:
|
||
GLib.source_remove(timeout_id)
|
||
except Exception:
|
||
pass
|
||
for watch_id in list(self._watch_ids.values()):
|
||
try:
|
||
GLib.source_remove(watch_id)
|
||
except Exception:
|
||
pass
|
||
self._watch_ids.clear()
|
||
for device in list(self._devices.values()):
|
||
self._close_device(device)
|
||
self._devices.clear()
|
||
self._held.clear()
|
||
|
||
def _close_device(self, device: _EvdevCaptureDevice) -> None:
|
||
try:
|
||
device.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _attach_device(self, path: str, *, hotplug: bool = False) -> Optional[BaseException]:
|
||
"""Open and watch *path* if recording is active and it is not already watched.
|
||
|
||
Returns the InputDevice open error, or None if attached/skipped.
|
||
"""
|
||
if not self._active or path in self._devices or self._input_device_cls is None:
|
||
return None
|
||
try:
|
||
device = self._input_device_cls(path)
|
||
except (OSError, IOError) as e:
|
||
return e
|
||
if not self._active:
|
||
self._close_device(device)
|
||
return None
|
||
# Do not grab: compositor and the runtime backend must still see keys.
|
||
try:
|
||
held = set(device.active_keys())
|
||
except Exception:
|
||
held = set()
|
||
try:
|
||
watch_id = GLib.io_add_watch(
|
||
device.fileno(),
|
||
GLib.IO_IN | GLib.IO_ERR | GLib.IO_HUP,
|
||
self._make_io_callback(device),
|
||
)
|
||
except Exception as e:
|
||
logger.debug("GLib.io_add_watch failed for %s: %s", path, e)
|
||
self._close_device(device)
|
||
return None
|
||
if not self._active:
|
||
try:
|
||
GLib.source_remove(watch_id)
|
||
except Exception:
|
||
pass
|
||
self._close_device(device)
|
||
return None
|
||
self._devices[path] = device
|
||
self._watch_ids[path] = watch_id
|
||
self._held[path] = held
|
||
if hotplug:
|
||
logger.debug("Added keyboard for shortcut recording: %s", path)
|
||
return None
|
||
|
||
def _drop_device(self, path: str) -> None:
|
||
"""Close and forget *path* without source_remove of its in-flight watch."""
|
||
self._watch_ids.pop(path, None)
|
||
device = self._devices.pop(path, None)
|
||
self._held.pop(path, None)
|
||
if device is not None:
|
||
self._close_device(device)
|
||
logger.debug("Dropped keyboard from shortcut recording: %s", path)
|
||
|
||
def _union_held_codes(self) -> set[int]:
|
||
union: set[int] = set()
|
||
for codes in self._held.values():
|
||
union.update(codes)
|
||
return union
|
||
|
||
def _path_for_device(self, device: _EvdevCaptureDevice) -> Optional[str]:
|
||
for path, tracked in self._devices.items():
|
||
if tracked is device:
|
||
return path
|
||
return None
|
||
|
||
def _on_device_rescan(self) -> bool:
|
||
if not self._active:
|
||
return False
|
||
finder = self._find_keyboard_devices
|
||
if finder is None:
|
||
return False
|
||
try:
|
||
paths = list(finder())
|
||
except Exception:
|
||
return True
|
||
for path in paths:
|
||
if not self._active:
|
||
return False
|
||
self._attach_device(path, hotplug=True)
|
||
return self._active
|
||
|
||
def _make_io_callback(self, device: _EvdevCaptureDevice) -> Callable[[int, int], bool]:
|
||
def _on_io(fd: int, condition: int) -> bool:
|
||
return self._on_fd(device, condition)
|
||
|
||
return _on_io
|
||
|
||
def _on_fd(self, device: _EvdevCaptureDevice, condition: int) -> bool:
|
||
if not self._active:
|
||
return False
|
||
path = self._path_for_device(device)
|
||
if path is None:
|
||
return False
|
||
try:
|
||
if condition & (GLib.IO_ERR | GLib.IO_HUP):
|
||
self._drop_device(path)
|
||
return False
|
||
except Exception:
|
||
pass
|
||
held = self._held.setdefault(path, set())
|
||
try:
|
||
events = list(device.read())
|
||
except BlockingIOError:
|
||
return True
|
||
except (OSError, IOError):
|
||
self._drop_device(path)
|
||
return False
|
||
|
||
for event in events:
|
||
if getattr(event, "type", None) != _EVDEV_EV_KEY:
|
||
continue
|
||
code = event.code
|
||
value = event.value
|
||
if value == 1:
|
||
held.add(code)
|
||
token = function_token_from_evdev_code(code)
|
||
if token is None:
|
||
continue
|
||
shortcut = _shortcut_from_capture(
|
||
modifiers_from_active_evdev_codes(self._union_held_codes()),
|
||
token,
|
||
)
|
||
if shortcut:
|
||
# Pop before on_shortcut: the dialog stop() must not
|
||
# source_remove this in-flight watch (returning False does).
|
||
self._watch_ids.pop(path, None)
|
||
self._on_shortcut(shortcut)
|
||
return False
|
||
elif value == 0:
|
||
held.discard(code)
|
||
return True
|
||
|
||
|
||
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)
|
||
|
||
|
||
def _make_expander_card(
|
||
title: str, subtitle: str
|
||
) -> tuple[Gtk.Box, Gtk.Expander, Gtk.Box, Gtk.Label]:
|
||
"""Card with a compact title+subtitle expander, used for Advanced and Unused."""
|
||
island = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||
island.get_style_context().add_class("preferences-group")
|
||
island.get_style_context().add_class("expander-card")
|
||
|
||
expander = Gtk.Expander()
|
||
header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||
title_label = Gtk.Label(label=title, xalign=0)
|
||
title_label.get_style_context().add_class("expander-card-title")
|
||
subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
|
||
subtitle_label.get_style_context().add_class("expander-card-subtitle")
|
||
header.pack_start(title_label, False, False, 0)
|
||
header.pack_start(subtitle_label, False, False, 0)
|
||
expander.set_label_widget(header)
|
||
|
||
body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||
expander.add(body)
|
||
island.pack_start(expander, False, False, 0)
|
||
return island, expander, body, subtitle_label
|
||
|
||
|
||
class PreferencesGroup(Gtk.Box):
|
||
"""A card-style group of preferences, similar to libadwaita's AdwPreferencesGroup."""
|
||
|
||
def __init__(
|
||
self,
|
||
title: str = "",
|
||
description: str = "",
|
||
keywords=(),
|
||
header_icon: Optional[Gtk.Widget] = None,
|
||
):
|
||
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: list[Gtk.Widget] = []
|
||
|
||
# Header with title (optional icon aligned to the top-right)
|
||
if title:
|
||
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||
|
||
title_label = Gtk.Label(label=title, xalign=0)
|
||
title_label.get_style_context().add_class("preferences-group-title")
|
||
text_box.pack_start(title_label, False, False, 0)
|
||
|
||
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)
|
||
text_box.pack_start(desc_label, False, False, 0)
|
||
|
||
if header_icon is not None:
|
||
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||
header_box.set_margin_top(12)
|
||
header_box.set_margin_bottom(4)
|
||
header_box.set_margin_start(16)
|
||
header_box.set_margin_end(16)
|
||
header_box.pack_start(text_box, True, True, 0)
|
||
header_icon.set_valign(Gtk.Align.CENTER)
|
||
header_box.pack_end(header_icon, False, False, 0)
|
||
else:
|
||
header_box = text_box
|
||
header_box.set_margin_top(12)
|
||
header_box.set_margin_bottom(4)
|
||
header_box.set_margin_start(16)
|
||
header_box.set_margin_end(16)
|
||
|
||
self.pack_start(header_box, False, False, 0)
|
||
|
||
# Content area with listbox for rows
|
||
self.listbox = Gtk.ListBox()
|
||
self.listbox.get_style_context().add_class("preferences-group-list")
|
||
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||
self.listbox.set_activate_on_single_click(False)
|
||
self.pack_start(self.listbox, False, False, 0)
|
||
|
||
def add_row(self, widget):
|
||
"""Add a widget as a row in the preferences group."""
|
||
self.listbox.add(widget)
|
||
self.rows.append(widget)
|
||
|
||
def clear_rows(self):
|
||
"""Remove all preference rows from the group."""
|
||
for row in list(self.listbox.get_children()):
|
||
self.listbox.remove(row)
|
||
self.rows.clear()
|
||
|
||
|
||
class PreferenceRow(Gtk.ListBoxRow):
|
||
"""A single preference row with title, subtitle, and a control widget."""
|
||
|
||
def __init__(
|
||
self,
|
||
title: str,
|
||
subtitle: str = "",
|
||
widget: Gtk.Widget = None,
|
||
activatable: bool = False,
|
||
keywords=(),
|
||
leading: Optional[Gtk.Widget] = None,
|
||
):
|
||
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)
|
||
hbox.set_margin_bottom(12)
|
||
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)
|
||
|
||
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
|
||
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(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)
|
||
|
||
hbox.pack_start(text_box, True, True, 0)
|
||
|
||
# Control widget on the right
|
||
if widget:
|
||
widget.set_valign(Gtk.Align.CENTER)
|
||
hbox.pack_end(widget, False, False, 0)
|
||
|
||
self.add(hbox)
|
||
|
||
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:
|
||
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)
|
||
|
||
|
||
def _set_no_show_all_visible(widget: Gtk.Widget, visible: bool) -> None:
|
||
"""Show or hide a widget flagged ``no_show_all``.
|
||
|
||
``show_all()`` skips flagged widgets, so revealing one means clearing the
|
||
flag first; hiding re-arms it so the next dialog-level ``show_all()``
|
||
leaves it alone. The second-language and custom-shortcut rows use this
|
||
helper.
|
||
"""
|
||
if visible:
|
||
widget.set_no_show_all(False)
|
||
widget.show_all()
|
||
else:
|
||
widget.hide()
|
||
widget.set_no_show_all(True)
|
||
|
||
|
||
def _add_switch_row(
|
||
group: PreferencesGroup,
|
||
title: str,
|
||
subtitle: str = "",
|
||
keywords: tuple = (),
|
||
tooltip: Optional[str] = None,
|
||
) -> Gtk.Switch:
|
||
"""Append a titled on/off row to ``group`` and return its switch."""
|
||
switch = Gtk.Switch()
|
||
if tooltip is not None:
|
||
switch.set_tooltip_text(tooltip)
|
||
row = PreferenceRow(title=title, subtitle=subtitle, widget=switch, keywords=keywords)
|
||
group.add_row(row)
|
||
return switch
|
||
|
||
|
||
def _combo_chrome(combo: Gtk.Widget, tooltip: Optional[str] = None) -> None:
|
||
"""Attach the shared dropdown chrome: tooltip plus the scroll-wheel guard."""
|
||
if tooltip is not None:
|
||
combo.set_tooltip_text(tooltip)
|
||
_prevent_scroll_on_hover(combo)
|
||
|
||
|
||
_AUDIO_DEVICE_TOOLTIP = "Select the microphone to use for voice recognition"
|
||
|
||
|
||
def _sync_audio_device_tooltip(combo: Gtk.ComboBoxText) -> None:
|
||
"""Show the selected microphone's full name, which the fixed-width combo truncates."""
|
||
label = combo.get_active_text()
|
||
if not label or combo.get_active_id() in (None, "-1"):
|
||
combo.set_tooltip_text(_AUDIO_DEVICE_TOOLTIP)
|
||
else:
|
||
combo.set_tooltip_text(label)
|
||
|
||
|
||
def _new_spin(
|
||
lower: float, upper: float, step: float, digits: int, tooltip: Optional[str] = None
|
||
) -> Gtk.SpinButton:
|
||
"""Create a SpinButton with digits, tooltip, and the wheel guard applied."""
|
||
spin = Gtk.SpinButton.new_with_range(lower, upper, step)
|
||
spin.set_digits(digits)
|
||
if tooltip is not None:
|
||
spin.set_tooltip_text(tooltip)
|
||
_prevent_scroll_on_hover(spin)
|
||
return spin
|
||
|
||
|
||
def _connect_picker_entry(
|
||
picker: SearchablePicker, on_activate: Callable, on_focus_out: Callable
|
||
) -> None:
|
||
"""Wire a SearchablePicker's entry: Enter commits, focus-out restores."""
|
||
entry = picker.get_child()
|
||
if entry is not None:
|
||
entry.connect("activate", on_activate)
|
||
entry.connect("focus-out-event", on_focus_out)
|
||
|
||
|
||
# Handler-suppression flags a block can hold via _suppressed().
|
||
_HandlerFlag = Literal[
|
||
"_applying_settings",
|
||
"_populating_models",
|
||
"_processing_language_change",
|
||
"_simple_driving",
|
||
"_simple_syncing",
|
||
]
|
||
|
||
|
||
def _handlers_suppressed(dialog: "SettingsDialog") -> bool:
|
||
"""Whether change handlers should ignore programmatic widget updates.
|
||
|
||
True while the dialog builds itself (``_initializing``) and while saved
|
||
settings are applied (``_applying_settings``) — the pair every handler
|
||
used to re-check on entry. The narrower guards (``_populating_models``,
|
||
``_processing_language_change``, ``_simple_syncing``, ``_simple_driving``)
|
||
still apply on top for the controls they cover.
|
||
"""
|
||
return dialog._initializing or dialog._applying_settings
|
||
|
||
|
||
@contextmanager
|
||
def _suppressed(dialog: "SettingsDialog", flag: _HandlerFlag) -> Iterator[None]:
|
||
"""Raise a handler-suppression flag for the block, then clear it.
|
||
|
||
Every site that used to open-code ``flag = True / try / finally /
|
||
flag = False`` now goes through here. Blocks that must preserve a flag
|
||
already held by an outer frame (the apply-guard handoff and the engine
|
||
resync) keep their explicit save/restore instead.
|
||
"""
|
||
setattr(dialog, flag, True)
|
||
try:
|
||
yield
|
||
finally:
|
||
setattr(dialog, flag, False)
|
||
|
||
|
||
def _undownloaded_model_info(language: str, engine: str, model_name: str) -> Optional[dict]:
|
||
"""Catalogue info for the selected model when it is not on disk yet.
|
||
|
||
Both apply paths gate the download prompt on this; the per-engine
|
||
branch lives here so a new engine means one edit.
|
||
"""
|
||
if engine == "whisper" and not _is_whisper_model_downloaded(model_name):
|
||
return WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500})
|
||
if engine == "whisper_cpp" and not is_whispercpp_model_downloaded(model_name):
|
||
return WHISPERCPP_MODEL_INFO.get(model_name, {"size_mb": 39})
|
||
if engine == "vosk" and not _is_vosk_model_downloaded(model_name, language):
|
||
return VOSK_MODEL_INFO.get(model_name, {"size_mb": 50})
|
||
if engine == "parakeet" and not parakeet.is_model_downloaded(model_name):
|
||
return parakeet.PARAKEET_MODEL_INFO.get(model_name, {"size_mb": 639})
|
||
if engine == "faster_whisper" and not is_faster_whisper_model_downloaded(model_name):
|
||
return FASTER_WHISPER_MODEL_INFO.get(model_name, {"size_mb": 39})
|
||
return None
|
||
|
||
|
||
def _model_download_source_url(
|
||
engine: str, model_name: str, language: str, info: Optional[dict] = None
|
||
) -> str:
|
||
"""Return the URL used to download a selected model."""
|
||
catalog_url = (info or {}).get("url")
|
||
if catalog_url:
|
||
return str(catalog_url)
|
||
if engine == "whisper":
|
||
return whisper_model_url(model_name)
|
||
if engine == "whisper_cpp":
|
||
return str(WHISPERCPP_MODEL_INFO.get(model_name, {}).get("url", ""))
|
||
if engine == "vosk":
|
||
model_dir = vosk_model_dirname(model_name, language)
|
||
return f"https://alphacephei.com/vosk/models/{model_dir}.zip" if model_dir else ""
|
||
if engine == "parakeet":
|
||
files = parakeet.model_files(model_name)
|
||
return parakeet.get_model_file_url(model_name, files[0]) if files else ""
|
||
if engine == "faster_whisper":
|
||
files = faster_whisper.model_files(model_name)
|
||
return faster_whisper.get_model_file_url(model_name, files[0]) if files else ""
|
||
return ""
|
||
|
||
|
||
def _make_download_dialog(
|
||
dialog: "SettingsDialog", model_name: str, size_mb: int, engine: str
|
||
) -> "ModelDownloadDialog":
|
||
"""Build the modal progress dialog for a pending model download."""
|
||
return ModelDownloadDialog(
|
||
dialog,
|
||
model_name,
|
||
size_mb,
|
||
engine=engine,
|
||
language=dialog.language,
|
||
)
|
||
|
||
|
||
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: list[Any] = []
|
||
self.extras: list[Any] = []
|
||
self.sidebar_row = None
|
||
self.match_count_label = None
|
||
self.update_badge_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)
|
||
|
||
|
||
# What the downloaders report when the transfer itself ends. It is not the
|
||
# end of the dialog's work, so it must not be shown as one.
|
||
_DOWNLOAD_DONE_STATUSES = frozenset({"complete", "completed", "done"})
|
||
|
||
|
||
class ModelDownloadDialog(Gtk.Dialog):
|
||
"""Dialog showing model download progress with cancel support."""
|
||
|
||
def __init__(
|
||
self,
|
||
parent,
|
||
model_name: str,
|
||
model_size_mb: int,
|
||
engine: str = "whisper",
|
||
language: str = "en-us",
|
||
):
|
||
super().__init__(
|
||
title=f"Downloading {_model_display_name(model_name)} Model",
|
||
transient_for=parent,
|
||
flags=Gtk.DialogFlags.MODAL,
|
||
)
|
||
self.set_default_size(450, 200)
|
||
self.set_deletable(False) # Prevent closing during download
|
||
|
||
self.cancelled = False
|
||
self.engine = engine
|
||
self.model_name = model_name
|
||
|
||
engine_display = engine.upper() if engine == "vosk" else engine.capitalize()
|
||
|
||
box = self.get_content_area()
|
||
box.set_spacing(16)
|
||
box.set_margin_start(24)
|
||
box.set_margin_end(24)
|
||
box.set_margin_top(24)
|
||
box.set_margin_bottom(20)
|
||
|
||
# Info label
|
||
self.info_label = Gtk.Label(
|
||
label=(
|
||
f"Downloading {engine_display} {_model_display_name(model_name)} model "
|
||
f"(~{_format_size(model_size_mb)})..."
|
||
),
|
||
wrap=True,
|
||
justify=Gtk.Justification.CENTER,
|
||
)
|
||
box.pack_start(self.info_label, False, False, 0)
|
||
|
||
# Progress bar
|
||
self.progress_bar = Gtk.ProgressBar()
|
||
self.progress_bar.set_show_text(True)
|
||
self.progress_bar.set_text("Connecting...")
|
||
box.pack_start(self.progress_bar, False, False, 8)
|
||
|
||
# Status label (shows speed and ETA)
|
||
self.status_label = Gtk.Label(label="")
|
||
self.status_label.set_markup("<i>Please wait...</i>")
|
||
self.status_label.get_style_context().add_class("status-info")
|
||
box.pack_start(self.status_label, False, False, 0)
|
||
|
||
# Cancel button
|
||
self.cancel_button = Gtk.Button(label="Cancel")
|
||
self.cancel_button.connect("clicked", self._on_cancel_clicked)
|
||
self.cancel_button.set_halign(Gtk.Align.CENTER)
|
||
self.cancel_button.set_margin_top(12)
|
||
box.pack_start(self.cancel_button, False, False, 0)
|
||
|
||
self.show_all()
|
||
|
||
self._pulse_timeout = None
|
||
# A destroyed dialog must not leave its pulse timer firing: the
|
||
# timeout retains the widget and keeps calling back into it.
|
||
self.connect("destroy", self._on_destroy)
|
||
# For Whisper, we can't track progress, so pulse
|
||
if engine == "whisper":
|
||
self._start_pulsing()
|
||
|
||
def _on_destroy(self, *_args: object) -> None:
|
||
"""Stop the pulse timer when the dialog goes away."""
|
||
self._stop_pulsing()
|
||
|
||
def _start_pulsing(self) -> None:
|
||
"""Bounce the bar, for work that reports no measurable progress."""
|
||
if self._pulse_timeout is None:
|
||
self._pulse_timeout = GLib.timeout_add(100, self._pulse_progress)
|
||
|
||
def _stop_pulsing(self) -> None:
|
||
"""Stop the bounce if it is running."""
|
||
if self._pulse_timeout is not None:
|
||
GLib.source_remove(self._pulse_timeout)
|
||
self._pulse_timeout = None
|
||
|
||
def _pulse_progress(self):
|
||
"""Pulse the progress bar while downloading (for Whisper)."""
|
||
if self.cancelled:
|
||
self._pulse_timeout = None
|
||
return False
|
||
self.progress_bar.pulse()
|
||
return True # Continue pulsing
|
||
|
||
def _on_cancel_clicked(self, widget):
|
||
"""Handle cancel button click."""
|
||
self.cancelled = True
|
||
self.cancel_button.set_sensitive(False)
|
||
self.cancel_button.set_label("Cancelling...")
|
||
self.status_label.set_markup("<i>Cancelling download...</i>")
|
||
|
||
def update_progress(self, fraction: float, speed_mbps: float, status_text: str):
|
||
"""Update the progress bar with actual download progress."""
|
||
if self.cancelled:
|
||
return
|
||
|
||
if fraction >= 1.0:
|
||
self._show_finishing(status_text)
|
||
return
|
||
|
||
self._stop_pulsing()
|
||
|
||
self.progress_bar.set_fraction(fraction)
|
||
self.progress_bar.set_text(f"{fraction * 100:.0f}%")
|
||
self.status_label.set_markup(f"<i>{status_text}</i>")
|
||
|
||
def _show_finishing(self, status_text: str) -> None:
|
||
"""Show the work that follows the last byte as work, not as an end.
|
||
|
||
The bytes being in is not the dialog being done: the checksum still
|
||
has to run and the engine still has to load the file, seconds of it
|
||
on a large model, and neither reports progress or can be interrupted.
|
||
A bar parked at 100% — or worse, at the downloader's "Complete!" —
|
||
under a lone Cancel button reads as "finished, waiting for me to
|
||
confirm", or as a hang. So bounce the bar, name the step, and stop
|
||
offering a cancel that no longer cancels anything.
|
||
"""
|
||
self._start_pulsing()
|
||
|
||
step = status_text.strip()
|
||
if not step or step.strip("!.").casefold() in _DOWNLOAD_DONE_STATUSES:
|
||
step = "Finishing..."
|
||
self.progress_bar.set_text(step)
|
||
self.status_label.set_markup(
|
||
"<i>Download finished, still working - this can take a moment...</i>"
|
||
)
|
||
|
||
if self.cancel_button.get_sensitive():
|
||
self.cancel_button.set_sensitive(False)
|
||
self.cancel_button.set_tooltip_text(
|
||
"The download is finished. Verifying and loading the model "
|
||
"cannot be interrupted."
|
||
)
|
||
|
||
def set_complete(self, success: bool, message: str = ""):
|
||
"""Mark download as complete."""
|
||
self._stop_pulsing()
|
||
|
||
# Hide cancel button
|
||
self.cancel_button.hide()
|
||
|
||
if success:
|
||
self.progress_bar.set_fraction(1.0)
|
||
self.progress_bar.set_text("Complete!")
|
||
self.status_label.set_markup(
|
||
"<span foreground='#26a269'><b>✓ Model ready to use</b></span>"
|
||
)
|
||
else:
|
||
self.progress_bar.set_fraction(0)
|
||
self.progress_bar.set_text("Failed")
|
||
if "cancelled" in message.lower():
|
||
self.status_label.set_markup(
|
||
"<span foreground='#e5a50a'>✗ Download cancelled</span>"
|
||
)
|
||
else:
|
||
self.status_label.set_markup(f"<span foreground='#c01c28'>✗ {message}</span>")
|
||
|
||
# Allow closing now
|
||
self.set_deletable(True)
|
||
self.add_button("OK", Gtk.ResponseType.OK)
|
||
|
||
|
||
def _populate_gpu_device_choices(combo: Any, saved_device: Optional[int]) -> None:
|
||
combo.remove_all()
|
||
combo.append("-1", "Auto (discrete GPU)")
|
||
|
||
from ..utils.whispercpp_model_info import detect_ggml_gpu_devices
|
||
|
||
devices = detect_ggml_gpu_devices()
|
||
if devices is None:
|
||
devices = detect_vulkan_devices()
|
||
for device in devices:
|
||
type_label = device["device_type"].capitalize()
|
||
label = f"[{device['index']}] {device['name']} ({type_label})"
|
||
combo.append(str(device["index"]), label)
|
||
|
||
if saved_device is None or not combo.set_active_id(str(saved_device)):
|
||
combo.set_active_id("-1")
|
||
|
||
|
||
class SettingsDialog(AboutSectionMixin):
|
||
"""Modern GTK Dialog for configuring Voculinux settings."""
|
||
|
||
def __init__(
|
||
self,
|
||
parent: Gtk.Window,
|
||
config_manager: "ConfigManager",
|
||
speech_engine: "SpeechRecognitionManagerProtocol",
|
||
shortcut_update_callback: Optional[Callable[..., Any]] = None,
|
||
initial_page: Optional[str] = None,
|
||
pending_update: Optional[ReleaseInfo] = None,
|
||
update_status_callback: Optional[Callable[..., Any]] = None,
|
||
update_checks_callback: Optional[Callable[[bool], None]] = None,
|
||
overlay_enabled_callback: Optional[Callable[[bool], None]] = None,
|
||
hotkey_listener_update_callback: Optional[Callable[[], None]] = None,
|
||
language_shortcuts_update_callback: Optional[Callable[[], None]] = None,
|
||
history_update_callback: Optional[Callable[[], None]] = None,
|
||
history_clear_callback: Optional[Callable[[], None]] = None,
|
||
hotkey_passthrough_update_callback: Optional[Callable[[bool], None]] = None,
|
||
dictation_pad_open_callback: Optional[Callable[[], None]] = None,
|
||
) -> None:
|
||
super().__init__(title=f"{APP_NAME} Settings", transient_for=parent, flags=0)
|
||
# Force window decorations (title-bar close) on all WMs. An in-window
|
||
# Close button also lives in the sidebar footer so the dialog always
|
||
# has a visible way to dismiss it, even on window managers that hide
|
||
# the title-bar close button for Gtk.Dialog windows (fixes #323).
|
||
self.set_decorated(True)
|
||
# A parentless Gtk.Dialog carries the DIALOG type hint, which lands it
|
||
# on GTK's Wayland "orphan dialog" list: the next toplevel to receive
|
||
# keyboard focus silently becomes its xdg_toplevel parent, and GNOME
|
||
# then minimizes, raises and moves the two windows as a unit (#984).
|
||
# Settings is a standalone window, so keep the NORMAL hint and stay
|
||
# independent of the dictation pad and overlay.
|
||
self.set_type_hint(Gdk.WindowTypeHint.NORMAL)
|
||
|
||
self.config_manager = config_manager
|
||
self.speech_engine = speech_engine
|
||
self.dictionary_manager = getattr(speech_engine, "dictionary_manager", None)
|
||
self.shortcut_update_callback = shortcut_update_callback
|
||
self.update_status_callback = update_status_callback
|
||
self.update_checks_callback = update_checks_callback
|
||
self.overlay_enabled_callback = overlay_enabled_callback
|
||
self.hotkey_listener_update_callback = hotkey_listener_update_callback
|
||
self.hotkey_passthrough_update_callback = hotkey_passthrough_update_callback
|
||
self.language_shortcuts_update_callback = language_shortcuts_update_callback
|
||
self.history_update_callback = history_update_callback
|
||
self.history_clear_callback = history_clear_callback
|
||
self.dictation_pad_open_callback = dictation_pad_open_callback
|
||
# Per-language shortcut rows (#805): a dict of row widgets per binding,
|
||
# populated by _build_language_shortcuts_section.
|
||
self._language_shortcut_rows: list[dict] = []
|
||
self._language_shortcut_add_row: Optional[PreferenceRow] = None
|
||
self._recording_shortcut_target: Optional[tuple[Any, Callable[[str], None], Any, Any]] = (
|
||
None
|
||
)
|
||
self._audio_test_cancel: Optional[threading.Event] = None
|
||
self.connect("destroy", self._cancel_audio_test)
|
||
self._test_active = False
|
||
self._test_token = 0
|
||
self._test_cancelled = False
|
||
self._test_result = ""
|
||
self._initializing = True # Flag to prevent auto-apply during initialization
|
||
# Server name of each snap PulseAudio entry in the device dropdown.
|
||
self._pulse_source_names: dict[int, str] = {}
|
||
self._populating_models = False # Flag to prevent model change handler during population
|
||
# Guards the simple-mode widgets while they are being pointed at the
|
||
# live configuration, so syncing them does not look like a user edit.
|
||
self._simple_syncing = False
|
||
# Set while simple mode steers the advanced controls, so their own change
|
||
# handlers do not each trigger a separate engine reload.
|
||
self._simple_driving = False
|
||
self.advanced_box: Any = None
|
||
self.advanced_island = None
|
||
self.advanced_expander: Any = None
|
||
self.unused_island = None
|
||
self.unused_expander = None
|
||
self.simple_page = None
|
||
self.simple_group: Any = None
|
||
self.engine_group = None
|
||
self._processing_language_change = (
|
||
False # Flag to prevent recursive language change handling
|
||
)
|
||
# Last user catalog-language preference across Parakeet visits and across
|
||
# engine-coerced fallbacks (e.g. Vosk forcing en-us for Greek). Parakeet
|
||
# forces language=auto for honesty; this restores the preference when the
|
||
# user returns to Whisper/cpp. Do not store engine-coerced fallbacks here.
|
||
self._last_non_parakeet_language: Optional[str] = None
|
||
self._engine_for_language_memory = None
|
||
# Set from the saved config in _load_and_apply_settings; the switch itself
|
||
# is the source of truth once the dialog is built (#821).
|
||
self._follow_layout_saved = False
|
||
# Cached so Mock-driven tests and early calls read a real bool.
|
||
self._follow_layout_active = False
|
||
self._applying_settings = False # Flag to prevent recursive settings application
|
||
# The last Advanced picker spec that needs a download and is still
|
||
# unconfirmed: picker rebuilds keep offering it instead of falling back
|
||
# to the saved model, and a real apply or resync clears it (#894).
|
||
self._staged_model_spec: Optional[dict] = None
|
||
self._advanced_prompt_dirty = False
|
||
self._language_candidates_dirty = False
|
||
# Deferred text edits stashed when the dialog closes mid-apply; persisted
|
||
# by _finish_auto_apply once the running apply releases the guard.
|
||
self._pending_text_edits: Optional[dict] = None
|
||
self._pending_text_edit_baseline: Optional[dict] = None
|
||
self._pending_apply_generation = 0
|
||
self._about_release_url = ""
|
||
self._update_check_in_progress = False
|
||
self._update_check_generation = 0
|
||
self._update_auto_checked = False
|
||
self._initial_page = initial_page
|
||
self._pending_update = pending_update
|
||
|
||
# Setup CSS styling
|
||
_setup_css()
|
||
|
||
# Dialog configuration - Close button lives in the sidebar footer (see #323)
|
||
# Calculate dialog size
|
||
display = Gdk.Display.get_default()
|
||
if display:
|
||
monitor = display.get_primary_monitor()
|
||
if not monitor and display.get_n_monitors() > 0:
|
||
monitor = display.get_monitor(0)
|
||
if monitor:
|
||
geometry = monitor.get_geometry()
|
||
screen_height = geometry.height
|
||
screen_width = geometry.width
|
||
else:
|
||
screen_height = 1080 # Default fallback
|
||
screen_width = 1920
|
||
else:
|
||
screen_height = 1080 # Default fallback
|
||
screen_width = 1920
|
||
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")
|
||
|
||
# Topic-based pages (GNOME HIG: group by topic, navigate via sidebar)
|
||
# Icons stick to the stock Adwaita symbolic set so they resolve on
|
||
# every distro (some system-monitor icons only ship with Yaru).
|
||
self._pages = [
|
||
SettingsPage("dictation", "Dictation", "input-keyboard-symbolic"),
|
||
SettingsPage("dictionary", "Custom Dictionary", "accessories-dictionary-symbolic"),
|
||
SettingsPage("model", "Speech Model", "audio-input-microphone-symbolic"),
|
||
SettingsPage("audio", "Audio", "audio-speakers-symbolic"),
|
||
SettingsPage("performance", "Performance", "power-profile-performance-symbolic"),
|
||
SettingsPage("application", "Application", "preferences-system-symbolic"),
|
||
SettingsPage("advanced", "Advanced", "applications-engineering-symbolic"),
|
||
SettingsPage("post-processing", "Post-Processing", "utilities-terminal-symbolic"),
|
||
SettingsPage("proxy", "Proxy", "preferences-system-network-proxy-symbolic"),
|
||
SettingsPage("about", "About", "help-about-symbolic"),
|
||
]
|
||
pages_by_name = {page.name: page for page in self._pages}
|
||
|
||
# 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.dictionary_tab = pages_by_name["dictionary"].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
|
||
self.post_processing_tab = pages_by_name["post-processing"].box
|
||
self.proxy_tab = pages_by_name["proxy"].box
|
||
self.about_tab = pages_by_name["about"].box
|
||
|
||
# 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)
|
||
scroller.set_shadow_type(Gtk.ShadowType.NONE)
|
||
scroller.add(tab)
|
||
return scroller
|
||
|
||
# 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:
|
||
# The dictionary page owns two independently scrollable management
|
||
# panes so its pane chooser stays visible above long term lists.
|
||
content = page.box if page.name == "dictionary" else _scrollable(page.box)
|
||
self.settings_stack.add_titled(content, 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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
# Same treatment as the pages: without a ScrolledWindow the category
|
||
# list's natural height pins the dialog's minimum height, so the
|
||
# window can never shrink past the list and new categories clip.
|
||
self.sidebar_scroller = Gtk.ScrolledWindow()
|
||
self.sidebar_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
self.sidebar_scroller.set_shadow_type(Gtk.ShadowType.NONE)
|
||
self.sidebar_scroller.add(self.sidebar_listbox)
|
||
sidebar_scroller = self.sidebar_scroller
|
||
sidebar_box.pack_start(sidebar_scroller, True, True, 0)
|
||
|
||
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)
|
||
|
||
# Search state: baseline visibility snapshot while a query is active.
|
||
self._search_baseline = None
|
||
self._search_previous_page = None
|
||
self._search_previous_dictionary_pane = None
|
||
# Marks dictionary pane switches made by the search filter itself so a
|
||
# user-initiated switch can drop the saved pre-search pane.
|
||
self._search_pane_programmatic = False
|
||
# Custom-terms list stays capped for GTK row count until the user
|
||
# asks to render the whole file.
|
||
self._show_all_terms = False
|
||
# Bumps on every refresh so idle-sliced row builders left over from
|
||
# an older pass stop instead of re-adding cleared rows.
|
||
self._terms_build_token = 0
|
||
# Terms added in this dialog that land past the display cap; they are
|
||
# pinned to the top so a successful save is never invisible.
|
||
self._pinned_terms: list[str] = []
|
||
# A term saved under "show all" scrolls into view once the idle-sliced
|
||
# rebuild finishes: (row index, displayed term) so a stale target left
|
||
# by a superseded refresh can never jump to the wrong row.
|
||
self._scroll_terms_to_row: Optional[tuple[int, str]] = None
|
||
|
||
# Set content_box to speech_engine_tab for backward compatibility
|
||
self.content_box = self.speech_engine_tab
|
||
|
||
# Build UI sections into their topic pages
|
||
self._build_shortcuts_section()
|
||
self._build_language_shortcuts_section()
|
||
self._build_recognition_section()
|
||
self._build_simple_model_section()
|
||
self._build_dictionary_section()
|
||
self._build_engine_section()
|
||
self._build_remote_server_section()
|
||
self._build_gateway_embed_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_post_processing_section()
|
||
self._build_proxy_section()
|
||
self._build_about_section()
|
||
self._build_sidebar_footer(sidebar_box)
|
||
|
||
# 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()
|
||
# Release notes stay hidden until a successful update check.
|
||
self.release_notes_group.hide()
|
||
# Re-hide the About "New" badge if show_all revealed it without a pending update.
|
||
self._set_about_update_badge(self._pending_update is not None)
|
||
# Seed About from a tray background check (opens with notes already filled).
|
||
self._seed_pending_update_ui()
|
||
if self._initial_page:
|
||
self.navigate_to_page(self._initial_page)
|
||
else:
|
||
self.sidebar_listbox.select_row(self.sidebar_listbox.get_row_at_index(0))
|
||
self._scroll_sidebar_selection_into_view()
|
||
|
||
# Restore the saved mode and point the simple questions at the live model
|
||
# before the first visibility pass, so nothing flashes the wrong group.
|
||
self._sync_simple_from_advanced()
|
||
cast(Any, self.advanced_expander).set_expanded(
|
||
bool(self.config_manager.get("speech_recognition", "show_advanced", False))
|
||
)
|
||
|
||
# Then update visibility of engine-specific elements
|
||
self._update_engine_specific_ui()
|
||
|
||
# Initialize recognition progress UI
|
||
self.update_recognition_progress("Idle")
|
||
|
||
# Connect to recognition manager for progress updates
|
||
self.connect_to_recognition_manager()
|
||
|
||
# Initialization complete - enable auto-apply
|
||
self._initializing = False
|
||
|
||
# ------------------------------------------------------------------
|
||
# Navigation: sidebar, stack, and settings search
|
||
# ------------------------------------------------------------------
|
||
|
||
def _scroll_sidebar_selection_into_view(self) -> None:
|
||
"""Bring the selected sidebar row into the scroller's viewport."""
|
||
row = self.sidebar_listbox.get_selected_row()
|
||
if row is None:
|
||
return
|
||
|
||
def scroll_to_row() -> bool:
|
||
allocation = row.get_allocation()
|
||
if allocation.height <= 0:
|
||
return True # not laid out yet — try again
|
||
adjustment = self.sidebar_scroller.get_vadjustment()
|
||
page_size = adjustment.get_page_size()
|
||
value = adjustment.get_value()
|
||
if allocation.y < value:
|
||
adjustment.set_value(max(adjustment.get_lower(), allocation.y))
|
||
elif allocation.y + allocation.height > value + page_size:
|
||
adjustment.set_value(
|
||
min(
|
||
adjustment.get_upper() - page_size,
|
||
allocation.y + allocation.height - page_size,
|
||
)
|
||
)
|
||
return False
|
||
|
||
# Allocations exist only once the row is mapped, which may be after
|
||
# one idle turn; retry briefly instead of leaving the row hidden.
|
||
# A new selection aborts the retry for the previously chosen row.
|
||
retries_left = 20
|
||
|
||
def scroll_once() -> bool:
|
||
nonlocal retries_left
|
||
retries_left -= 1
|
||
if self.sidebar_listbox.get_selected_row() is not row:
|
||
return False
|
||
return scroll_to_row() and retries_left > 0
|
||
|
||
GLib.idle_add(scroll_once)
|
||
|
||
def navigate_to_page(self, page_name: str) -> bool:
|
||
"""Select a settings page by its internal name (e.g. ``about``)."""
|
||
for page in self._pages:
|
||
if page.name == page_name and page.sidebar_row is not None:
|
||
self.sidebar_listbox.select_row(page.sidebar_row)
|
||
self._scroll_sidebar_selection_into_view()
|
||
return True
|
||
return False
|
||
|
||
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)
|
||
|
||
update_badge = Gtk.Label(label="New")
|
||
update_badge.get_style_context().add_class("sidebar-update-badge")
|
||
update_badge.set_no_show_all(True)
|
||
update_badge.hide()
|
||
hbox.pack_end(update_badge, False, False, 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
|
||
page.update_badge_label = update_badge
|
||
if page.name == "about" and self._pending_update is not None:
|
||
update_badge.show()
|
||
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: Gtk.Stack, pspec: Any) -> None:
|
||
"""Persist deferred edits when the visible settings page changes."""
|
||
visible = stack.get_visible_child_name()
|
||
if visible != "advanced":
|
||
self._flush_advanced_prompt_if_dirty()
|
||
if visible == "about" and not self._update_auto_checked:
|
||
self._update_auto_checked = True
|
||
if self.config_manager.get_bool(
|
||
"updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY
|
||
):
|
||
self._start_update_check()
|
||
else:
|
||
self.update_status_row.set_subtitle(
|
||
"Automatic checks are off. Press Check to look now."
|
||
)
|
||
|
||
def _open_web_url(self, url: str) -> None:
|
||
"""Open a trusted project URL in the user's default browser."""
|
||
if not _can_open_url(url):
|
||
logger.warning("Refusing to open untrusted URL: %s", url)
|
||
return
|
||
try:
|
||
if hasattr(Gtk, "show_uri_on_window"):
|
||
Gtk.show_uri_on_window(self, url, Gdk.CURRENT_TIME)
|
||
else:
|
||
Gtk.show_uri(None, url, Gdk.CURRENT_TIME)
|
||
except Exception as exc:
|
||
logger.warning("Failed to open URL %s: %s", url, exc)
|
||
|
||
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"
|
||
self._search_previous_dictionary_pane = (
|
||
self.dictionary_management_stack.get_visible_child_name()
|
||
)
|
||
|
||
baseline = {"rows": {}, "groups": {}, "extras": {}, "islands": {}}
|
||
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()
|
||
# Nested unused_models_group is the searchable unit; the expander card
|
||
# that wraps it is not a PreferencesGroup, so record it separately.
|
||
if self.unused_island is not None:
|
||
baseline["islands"][self.unused_island] = self.unused_island.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()
|
||
|
||
# Badge visibility follows _pending_update (cleared on failed About checks
|
||
# so a stale New badge cannot reappear when search is cleared).
|
||
self._set_about_update_badge(self._pending_update is not None)
|
||
|
||
# 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._scroll_sidebar_selection_into_view()
|
||
self.settings_stack.set_visible_child_name(page.name)
|
||
self._search_previous_page = None
|
||
|
||
if self._search_previous_dictionary_pane is not None:
|
||
# A search may have auto-switched the dictionary pane to reveal a
|
||
# match; clearing the query returns to the pane the user selected.
|
||
self.dictionary_management_stack.set_visible_child_name(
|
||
self._search_previous_dictionary_pane
|
||
)
|
||
self._search_previous_dictionary_pane = None
|
||
|
||
def _on_dictionary_pane_changed(self, *_args: Any) -> None:
|
||
"""Keep a manual dictionary pane switch made during an active search.
|
||
|
||
The filter auto-switches panes to reveal a match and restores the
|
||
pre-search pane when the query clears. If the user clicks the pane
|
||
switcher themselves mid-search, that newer choice wins and clearing
|
||
the search must not roll it back.
|
||
"""
|
||
if self._search_pane_programmatic:
|
||
return
|
||
if self._search_baseline is not None:
|
||
self._search_previous_dictionary_pane = 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.name == "dictionary":
|
||
# A search can hide the selected management pane while the
|
||
# other pane contains the match. Keep matching controls visible.
|
||
current = self.dictionary_management_stack.get_visible_child_name()
|
||
terms_visible = self.dictionary_terms_group.get_visible()
|
||
corrections_visible = self.dictionary_corrections_group.get_visible()
|
||
self._search_pane_programmatic = True
|
||
try:
|
||
if current == "terms" and not terms_visible and corrections_visible:
|
||
self.dictionary_management_stack.set_visible_child_name("corrections")
|
||
elif current == "corrections" and not corrections_visible and terms_visible:
|
||
self.dictionary_management_stack.set_visible_child_name("terms")
|
||
finally:
|
||
self._search_pane_programmatic = False
|
||
|
||
if page_matches > 0:
|
||
if page.update_badge_label is not None:
|
||
page.update_badge_label.hide()
|
||
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:
|
||
if page.update_badge_label is not None:
|
||
page.update_badge_label.hide()
|
||
page.match_count_label.hide()
|
||
page.sidebar_row.set_sensitive(False)
|
||
|
||
# collect_children finds unused_models_group inside unused_island, so the
|
||
# expander card is not an extra. Hide the header when the nested group
|
||
# has no search hits; restore puts the island back from the snapshot.
|
||
if self.unused_island is not None and hasattr(self, "unused_models_group"):
|
||
self.unused_island.set_visible(self.unused_models_group.get_visible())
|
||
|
||
if first_match_page is not None:
|
||
self.sidebar_listbox.select_row(first_match_page.sidebar_row)
|
||
self._scroll_sidebar_selection_into_view()
|
||
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")
|
||
|
||
# Device selection row
|
||
self.audio_device_combo = Gtk.ComboBoxText()
|
||
_combo_chrome(
|
||
self.audio_device_combo,
|
||
tooltip=_AUDIO_DEVICE_TOOLTIP,
|
||
)
|
||
|
||
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("suffix-button")
|
||
refresh_btn.set_size_request(_ICON_BUTTON_WIDTH, -1)
|
||
refresh_btn.connect("clicked", self._on_refresh_audio_devices)
|
||
|
||
device_row = PreferenceRow(
|
||
title="Input Device",
|
||
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)
|
||
|
||
# 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(
|
||
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)
|
||
|
||
self.audio_tab.pack_start(group, False, False, 0)
|
||
|
||
# Sound Effects section
|
||
sound_group = PreferencesGroup(
|
||
title="Sound Effects",
|
||
description=(
|
||
"Cues play when recording starts, stops, or hits an error. "
|
||
"Turn the switch off to mute all of them."
|
||
),
|
||
)
|
||
self.sound_effects_switch = _add_switch_row(
|
||
sound_group,
|
||
title="Enable Sound Effects",
|
||
subtitle="Play audio feedback for recording events",
|
||
keywords=("tone", "chime", "cue", "feedback"),
|
||
tooltip="Play sounds when recording starts, stops, or encounters errors",
|
||
)
|
||
|
||
self.sound_tone_combo = Gtk.ComboBoxText()
|
||
_combo_chrome(self.sound_tone_combo, tooltip="Start and stop sound used while dictating")
|
||
for tone_id, label in SOUND_EFFECT_TONES:
|
||
self.sound_tone_combo.append(tone_id, label)
|
||
|
||
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=_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(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)
|
||
self.sound_tone_combo.connect("changed", self._on_sound_tone_changed)
|
||
self.preview_tone_btn.connect("clicked", self._on_preview_tone_clicked)
|
||
|
||
# Other audio: opt-in duck of the default sink for the length of a dictation.
|
||
other_audio = PreferencesGroup(
|
||
title="Other audio",
|
||
keywords=("duck", "volume", "speakers", "music"),
|
||
)
|
||
self.duck_playback_switch = _add_switch_row(
|
||
other_audio,
|
||
title="Lower other audio while dictating",
|
||
subtitle=(
|
||
"Turn speakers and headphones down while the microphone is open, "
|
||
"then put the volume back."
|
||
),
|
||
keywords=("duck", "volume", "speakers", "music", "headphones"),
|
||
tooltip=(
|
||
"Turn speakers and headphones down while the microphone is open, "
|
||
"then put the volume back"
|
||
),
|
||
)
|
||
|
||
self.duck_level_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1)
|
||
self.duck_level_scale.set_digits(0)
|
||
self.duck_level_scale.set_round_digits(0)
|
||
self.duck_level_scale.set_value_pos(Gtk.PositionType.RIGHT)
|
||
self.duck_level_scale.set_value(20)
|
||
self.duck_level_scale.set_draw_value(True)
|
||
self.duck_level_scale.set_size_request(180, -1)
|
||
self.duck_level_scale.set_hexpand(False)
|
||
self.duck_level_scale.set_halign(Gtk.Align.END)
|
||
self.duck_level_scale.set_sensitive(False)
|
||
self.duck_level_scale.set_tooltip_text("Percent of the current volume. 0 is silent.")
|
||
_prevent_scroll_on_hover(self.duck_level_scale)
|
||
duck_level_row = PreferenceRow(
|
||
title="Level while dictating",
|
||
subtitle="Percent of the current volume. 0 is silent.",
|
||
widget=self.duck_level_scale,
|
||
keywords=("duck", "volume", "speakers", "music"),
|
||
)
|
||
other_audio.add_row(duck_level_row)
|
||
self.audio_tab.pack_start(other_audio, False, False, 0)
|
||
self.duck_playback_switch.connect("state-set", self._on_duck_playback_toggled)
|
||
self.duck_level_scale.connect("value-changed", self._on_duck_level_changed)
|
||
|
||
# Populate devices
|
||
self._populate_audio_devices()
|
||
self.audio_device_combo.connect("changed", self._on_audio_device_changed)
|
||
|
||
def _build_general_section(self):
|
||
"""Build the Application page: general behavior."""
|
||
group = PreferencesGroup(title="General")
|
||
|
||
self.autostart_switch = _add_switch_row(
|
||
group,
|
||
title="Start on Login",
|
||
subtitle="Automatically start Voculinux when you log in",
|
||
keywords=("autostart", "boot", "startup"),
|
||
)
|
||
|
||
self.start_minimized_switch = _add_switch_row(
|
||
group,
|
||
title="Start Minimized",
|
||
subtitle="Start minimized to system tray instead of showing window",
|
||
keywords=("tray",),
|
||
)
|
||
|
||
self.missing_tray_warning_switch = Gtk.Switch()
|
||
missing_tray_warning_row = PreferenceRow(
|
||
title="Warn if tray support is not detected",
|
||
subtitle="Show a warning when Voculinux cannot detect AppIndicator support",
|
||
widget=self.missing_tray_warning_switch,
|
||
keywords=("tray", "appindicator", "warning"),
|
||
)
|
||
group.add_row(missing_tray_warning_row)
|
||
|
||
self.show_overlay_switch = _add_switch_row(
|
||
group,
|
||
title="Show Dictation Overlay",
|
||
subtitle="Floating on-screen glow when listening or processing",
|
||
tooltip=("Show a floating glowing indicator on screen while the microphone is active"),
|
||
)
|
||
|
||
self.general_tab.pack_start(group, False, False, 0)
|
||
|
||
# Transcription history group
|
||
history_group = PreferencesGroup(
|
||
title="Transcription History",
|
||
description="Recent dictation snippets are kept in memory and shown "
|
||
"in the tray menu. History clears on quit unless Keep on Disk is on, "
|
||
"which saves them to history.jsonl under the Voculinux data directory.",
|
||
)
|
||
|
||
self.history_enabled_switch = Gtk.Switch()
|
||
self.history_enabled_switch.set_tooltip_text(
|
||
"Keep recent dictation snippets in the tray menu for quick re-copying"
|
||
)
|
||
history_enabled_row = PreferenceRow(
|
||
title="Keep History",
|
||
subtitle="Show recent snippets in the tray menu",
|
||
widget=self.history_enabled_switch,
|
||
)
|
||
history_group.add_row(history_enabled_row)
|
||
|
||
self.history_max_items_spin = Gtk.SpinButton.new_with_range(1, 50, 1)
|
||
self.history_max_items_spin.set_tooltip_text("How many recent snippets to keep")
|
||
_prevent_scroll_on_hover(self.history_max_items_spin)
|
||
history_max_items_row = PreferenceRow(
|
||
title="Snippets to Keep",
|
||
subtitle="Number of recent snippets retained",
|
||
widget=self.history_max_items_spin,
|
||
)
|
||
history_group.add_row(history_max_items_row)
|
||
|
||
self.history_persist_switch = Gtk.Switch()
|
||
self.history_persist_switch.set_tooltip_text(
|
||
"Save snippets to disk so they survive restarts (off by default for privacy)"
|
||
)
|
||
history_persist_row = PreferenceRow(
|
||
title="Keep on Disk",
|
||
subtitle="Save snippets to history.jsonl in the data directory across restarts",
|
||
widget=self.history_persist_switch,
|
||
keywords=("persist", "save", "disk", "transcript"),
|
||
)
|
||
history_group.add_row(history_persist_row)
|
||
|
||
self.history_clear_button = Gtk.Button(label="Clear")
|
||
self.history_clear_button.set_tooltip_text(
|
||
"Delete every saved snippet, including any written to disk"
|
||
)
|
||
_style_action_button(self.history_clear_button)
|
||
self.history_clear_button.connect("clicked", self._on_history_clear_clicked)
|
||
history_clear_row = PreferenceRow(
|
||
title="Clear History",
|
||
subtitle="Remove every saved snippet now",
|
||
widget=self.history_clear_button,
|
||
keywords=("delete", "erase", "wipe", "transcript"),
|
||
)
|
||
history_group.add_row(history_clear_row)
|
||
|
||
self.general_tab.pack_start(history_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.missing_tray_warning_switch.connect("state-set", self._on_missing_tray_warning_toggled)
|
||
self.show_overlay_switch.connect("state-set", self._on_show_overlay_toggled)
|
||
self.history_enabled_switch.connect("state-set", self._on_history_enabled_toggled)
|
||
self.history_max_items_spin.connect("value-changed", self._on_history_max_items_changed)
|
||
self.history_persist_switch.connect("state-set", self._on_history_persist_toggled)
|
||
|
||
def _build_dictionary_section(self) -> None:
|
||
"""Build the Custom Dictionary page for terms and transcript corrections."""
|
||
self.dictionary_management_stack = Gtk.Stack()
|
||
self.dictionary_management_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
||
self.dictionary_management_stack.set_transition_duration(120)
|
||
self.dictionary_management_stack.set_homogeneous(False)
|
||
self.dictionary_management_stack.set_hexpand(True)
|
||
self.dictionary_management_stack.set_vexpand(True)
|
||
self.dictionary_management_stack.connect(
|
||
"notify::visible-child-name", self._on_dictionary_pane_changed
|
||
)
|
||
|
||
self.dictionary_management_switcher = Gtk.StackSwitcher()
|
||
self.dictionary_management_switcher.set_stack(self.dictionary_management_stack)
|
||
self.dictionary_management_switcher.set_halign(Gtk.Align.CENTER)
|
||
self.dictionary_management_switcher.get_accessible().set_name("Custom dictionary section")
|
||
self.dictionary_tab.pack_start(self.dictionary_management_switcher, False, False, 0)
|
||
|
||
self.dictionary_feedback_label = Gtk.Label(xalign=0)
|
||
self.dictionary_feedback_label.set_line_wrap(True)
|
||
self.dictionary_feedback_label.get_accessible().set_name("Custom dictionary status")
|
||
self.dictionary_feedback_label.get_style_context().add_class("tip-label")
|
||
self.dictionary_tab.pack_start(self.dictionary_feedback_label, False, False, 0)
|
||
|
||
self.dictionary_tab.pack_start(self.dictionary_management_stack, True, True, 0)
|
||
|
||
terms_group = PreferencesGroup(
|
||
title="Custom terms",
|
||
description=(
|
||
"Terms bias Whisper, whisper.cpp, and Faster Whisper recognition. The UTF-8 "
|
||
"terms file is live-reloaded and has one term per line, so accessibility tools "
|
||
"can edit it."
|
||
),
|
||
keywords=("dictionary", "terms", "vocabulary", "whisper", "scanner"),
|
||
)
|
||
self.dictionary_terms_group = terms_group
|
||
self.dictionary_terms_enabled_switch = Gtk.Switch()
|
||
terms_group.add_row(
|
||
PreferenceRow(
|
||
title="Use custom terms",
|
||
subtitle=(
|
||
"Vocabulary bias works with Whisper, whisper.cpp, and Faster Whisper; "
|
||
"corrections work with every engine."
|
||
),
|
||
widget=self.dictionary_terms_enabled_switch,
|
||
)
|
||
)
|
||
terms_path_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||
self.dictionary_terms_path_entry = Gtk.Entry()
|
||
self.dictionary_terms_path_entry.set_hexpand(True)
|
||
self.dictionary_terms_path_entry.set_placeholder_text("~/.config/voculinux/dictionary.txt")
|
||
self.dictionary_terms_path_entry.set_tooltip_text(
|
||
"UTF-8 line file used for custom terms and the accessibility scanner"
|
||
)
|
||
self.dictionary_terms_path_entry.get_accessible().set_name("Custom terms file")
|
||
terms_path_box.pack_start(self.dictionary_terms_path_entry, True, True, 0)
|
||
self.dictionary_terms_file_button = Gtk.FileChooserButton(title="Choose Terms File")
|
||
terms_path_box.pack_start(self.dictionary_terms_file_button, False, False, 0)
|
||
terms_group.add_row(
|
||
PreferenceRow(
|
||
title="Terms file",
|
||
subtitle="UTF-8, one term per line; # starts a comment",
|
||
widget=terms_path_box,
|
||
)
|
||
)
|
||
self.dictionary_terms_status_label = Gtk.Label(xalign=0)
|
||
self.dictionary_terms_status_label.set_line_wrap(True)
|
||
self.dictionary_terms_status_label.get_style_context().add_class("tip-label")
|
||
terms_group.add_row(
|
||
PreferenceRow(
|
||
title="Live status",
|
||
subtitle="The terms file is re-read before every transcription.",
|
||
widget=self.dictionary_terms_status_label,
|
||
)
|
||
)
|
||
|
||
terms_add_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
terms_add_box.set_margin_top(8)
|
||
terms_add_box.set_margin_bottom(4)
|
||
terms_add_box.set_margin_start(16)
|
||
terms_add_box.set_margin_end(16)
|
||
self.dictionary_term_entry = Gtk.Entry()
|
||
self.dictionary_term_entry.set_placeholder_text("Add a term")
|
||
self.dictionary_term_entry.set_tooltip_text(
|
||
"Term to add to the scanner-friendly terms file"
|
||
)
|
||
self.dictionary_term_entry.get_accessible().set_name("Custom term")
|
||
self.dictionary_term_entry.set_hexpand(True)
|
||
terms_add_box.pack_start(self.dictionary_term_entry, True, True, 0)
|
||
self.dictionary_add_term_button = Gtk.Button(label="Add term")
|
||
self.dictionary_add_term_button.connect("clicked", self._on_dictionary_add_term)
|
||
self.dictionary_term_entry.connect("activate", self._on_dictionary_add_term)
|
||
terms_add_box.pack_start(self.dictionary_add_term_button, False, False, 0)
|
||
terms_add_row = Gtk.ListBoxRow()
|
||
terms_add_row.set_activatable(False)
|
||
terms_add_row.add(terms_add_box)
|
||
terms_group.add_row(terms_add_row)
|
||
|
||
self.dictionary_terms_listbox = Gtk.ListBox()
|
||
self.dictionary_terms_listbox.get_style_context().add_class("dictionary-entry-list")
|
||
self.dictionary_terms_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||
self.dictionary_terms_listbox.set_placeholder(
|
||
Gtk.Label(label="No custom terms yet.", xalign=0.5)
|
||
)
|
||
terms_list_row = Gtk.ListBoxRow()
|
||
terms_list_row.set_activatable(False)
|
||
terms_list_row.add(self.dictionary_terms_listbox)
|
||
terms_group.add_row(terms_list_row)
|
||
|
||
self.dictionary_terms_scroller = Gtk.ScrolledWindow()
|
||
self.dictionary_terms_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
self.dictionary_terms_scroller.set_shadow_type(Gtk.ShadowType.NONE)
|
||
self.dictionary_terms_scroller.add(terms_group)
|
||
self.dictionary_management_stack.add_titled(
|
||
self.dictionary_terms_scroller, "terms", "Custom terms"
|
||
)
|
||
|
||
corrections_group = PreferencesGroup(
|
||
title="Transcript corrections",
|
||
description=(
|
||
"Corrections replace a misheard whole word or phrase in every completed "
|
||
"transcript. They run before voice commands, so avoid replacement text that "
|
||
"is itself a voice command."
|
||
),
|
||
keywords=("dictionary", "correction", "replacement", "misheard", "transcript"),
|
||
)
|
||
self.dictionary_corrections_group = corrections_group
|
||
corrections_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
corrections_box.set_margin_top(8)
|
||
corrections_box.set_margin_bottom(4)
|
||
corrections_box.set_margin_start(16)
|
||
corrections_box.set_margin_end(16)
|
||
self.dictionary_heard_entry = Gtk.Entry()
|
||
self.dictionary_heard_entry.set_placeholder_text("Heard as, e.g. super base")
|
||
self.dictionary_heard_entry.set_tooltip_text("Misheard phrase, matched case-insensitively")
|
||
self.dictionary_heard_entry.get_accessible().set_name("Heard phrase")
|
||
self.dictionary_heard_entry.set_hexpand(True)
|
||
corrections_box.pack_start(self.dictionary_heard_entry, True, True, 0)
|
||
self.dictionary_replacement_entry = Gtk.Entry()
|
||
self.dictionary_replacement_entry.set_placeholder_text("Replace with, e.g. Supabase")
|
||
self.dictionary_replacement_entry.set_tooltip_text(
|
||
"Replacement inserted with exactly this spelling"
|
||
)
|
||
self.dictionary_replacement_entry.get_accessible().set_name("Replacement text")
|
||
self.dictionary_replacement_entry.set_hexpand(True)
|
||
corrections_box.pack_start(self.dictionary_replacement_entry, True, True, 0)
|
||
self.dictionary_add_correction_button = Gtk.Button(label="Add correction")
|
||
self.dictionary_add_correction_button.connect("clicked", self._on_dictionary_add_correction)
|
||
self.dictionary_heard_entry.connect("activate", self._on_dictionary_add_correction)
|
||
self.dictionary_replacement_entry.connect("activate", self._on_dictionary_add_correction)
|
||
corrections_box.pack_start(self.dictionary_add_correction_button, False, False, 0)
|
||
corrections_add_row = Gtk.ListBoxRow()
|
||
corrections_add_row.set_activatable(False)
|
||
corrections_add_row.add(corrections_box)
|
||
corrections_group.add_row(corrections_add_row)
|
||
|
||
self.dictionary_corrections_listbox = Gtk.ListBox()
|
||
self.dictionary_corrections_listbox.get_style_context().add_class("dictionary-entry-list")
|
||
self.dictionary_corrections_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||
self.dictionary_corrections_listbox.set_placeholder(
|
||
Gtk.Label(label="No transcript corrections yet.", xalign=0.5)
|
||
)
|
||
corrections_list_row = Gtk.ListBoxRow()
|
||
corrections_list_row.set_activatable(False)
|
||
corrections_list_row.add(self.dictionary_corrections_listbox)
|
||
corrections_group.add_row(corrections_list_row)
|
||
|
||
corrections_scroller = Gtk.ScrolledWindow()
|
||
corrections_scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
corrections_scroller.set_shadow_type(Gtk.ShadowType.NONE)
|
||
corrections_scroller.add(corrections_group)
|
||
self.dictionary_management_stack.add_titled(
|
||
corrections_scroller, "corrections", "Corrections"
|
||
)
|
||
|
||
self.dictionary_terms_enabled_switch.connect("state-set", self._on_dictionary_terms_enabled)
|
||
self.dictionary_terms_path_entry.connect("activate", self._on_dictionary_terms_path_changed)
|
||
self.dictionary_terms_path_entry.connect(
|
||
"focus-out-event", self._on_dictionary_terms_path_changed
|
||
)
|
||
self.dictionary_terms_file_button.connect("file-set", self._on_dictionary_terms_file_chosen)
|
||
|
||
def _on_dictionary_terms_enabled(self, widget: Any, state: bool) -> bool:
|
||
"""Persist terms enablement and restore the control when persistence fails."""
|
||
if self._initializing or self._applying_settings or self.dictionary_manager is None:
|
||
return False
|
||
if not self.dictionary_manager.set_terms_enabled(bool(state)):
|
||
self.dictionary_feedback_label.set_text("Could not save custom terms setting.")
|
||
self._refresh_dictionary_ui()
|
||
# Returning True suppresses GTK's own state-set so the switch
|
||
# shows the value that actually persisted, not the requested one.
|
||
return True
|
||
self._refresh_dictionary_ui()
|
||
return False
|
||
|
||
def _on_dictionary_terms_path_changed(self, widget: Any, *args: Any) -> bool:
|
||
"""Persist a validated custom terms path and restore it on failure."""
|
||
if self._initializing or self._applying_settings or self.dictionary_manager is None:
|
||
return False
|
||
if self.dictionary_manager.set_terms_path(self.dictionary_terms_path_entry.get_text()):
|
||
self.dictionary_feedback_label.set_text("Custom terms path saved.")
|
||
else:
|
||
self.dictionary_feedback_label.set_text(
|
||
"Could not save that custom terms path; keeping the previous path."
|
||
)
|
||
self._refresh_dictionary_ui()
|
||
return False
|
||
|
||
def _on_dictionary_terms_file_chosen(self, widget: Any) -> None:
|
||
"""Apply a terms path selected through the GTK file chooser."""
|
||
path = widget.get_filename()
|
||
if path:
|
||
self.dictionary_terms_path_entry.set_text(path)
|
||
self._on_dictionary_terms_path_changed(self.dictionary_terms_path_entry)
|
||
|
||
def _on_dictionary_add_term(self, widget: Any) -> None:
|
||
"""Add a term to the fixed line file, reporting an observable result."""
|
||
if self._initializing or self._applying_settings or self.dictionary_manager is None:
|
||
return
|
||
term = self.dictionary_term_entry.get_text().strip()
|
||
if not term:
|
||
self.dictionary_feedback_label.set_text("Enter a term before adding it.")
|
||
return
|
||
terms = self.dictionary_manager.get_terms()
|
||
if any(existing.casefold() == term.casefold() for existing in terms):
|
||
self.dictionary_feedback_label.set_text("That term is already in the terms file.")
|
||
return
|
||
if not self.dictionary_manager.add_term(term):
|
||
self.dictionary_feedback_label.set_text(
|
||
"Could not save the terms file; no term was added."
|
||
)
|
||
return
|
||
self.dictionary_term_entry.set_text("")
|
||
terms_now = self.dictionary_manager.get_terms()
|
||
if any(existing.casefold() == term.casefold() for existing in terms_now):
|
||
self.dictionary_feedback_label.set_text("Term saved to the live terms file.")
|
||
if self._show_all_terms:
|
||
# Oversized or yield-capped files prepend the term, other
|
||
# files append it — scroll to wherever it landed once the
|
||
# idle-sliced rebuild finishes. Under "show all" the rebuilt
|
||
# rows match terms_now one to one.
|
||
new_index = next(
|
||
index
|
||
for index, existing in enumerate(terms_now)
|
||
if existing.casefold() == term.casefold()
|
||
)
|
||
self._scroll_terms_to_row = (new_index, terms_now[new_index])
|
||
else:
|
||
# A term that lands past the display cap is pinned to the top
|
||
# of the capped list — a saved term is never invisible. The
|
||
# next refresh drops the pin if the term is already visible.
|
||
self._pinned_terms.append(term)
|
||
else:
|
||
self.dictionary_feedback_label.set_text("That term is not valid for the terms file.")
|
||
self._refresh_dictionary_ui()
|
||
|
||
def _on_terms_show_all(self, widget: Any) -> None:
|
||
"""Render every term, including those past the display cap."""
|
||
self._show_all_terms = True
|
||
self._refresh_dictionary_ui()
|
||
|
||
def _on_terms_show_fewer(self, widget: Any) -> None:
|
||
"""Collapse the terms list back to the display cap."""
|
||
self._show_all_terms = False
|
||
self._refresh_dictionary_ui()
|
||
|
||
def _on_dictionary_remove_term(self, widget: Any, term: str) -> None:
|
||
"""Remove one term from the fixed line file."""
|
||
if self._initializing or self._applying_settings or self.dictionary_manager is None:
|
||
return
|
||
if self.dictionary_manager.remove_term(term):
|
||
self.dictionary_feedback_label.set_text("Term removed from the live terms file.")
|
||
else:
|
||
self.dictionary_feedback_label.set_text(
|
||
"Could not save the terms file; the term was not removed."
|
||
)
|
||
self._refresh_dictionary_ui()
|
||
|
||
def _on_dictionary_add_correction(self, widget: Any) -> None:
|
||
"""Add or update a phrase correction in the structured corrections file."""
|
||
if self._initializing or self._applying_settings or self.dictionary_manager is None:
|
||
return
|
||
heard = self.dictionary_heard_entry.get_text().strip()
|
||
replacement = self.dictionary_replacement_entry.get_text().strip()
|
||
if not heard or not replacement:
|
||
self.dictionary_feedback_label.set_text(
|
||
"Enter both the heard phrase and its replacement."
|
||
)
|
||
return
|
||
entries = self.dictionary_manager.get_corrections_for_edit()
|
||
if entries is None:
|
||
self.dictionary_feedback_label.set_text(
|
||
"Could not edit corrections: fix or replace the malformed corrections file first."
|
||
)
|
||
return
|
||
candidate = {"heard": heard, "replacement": replacement}
|
||
normalized_entries = normalize_corrections([candidate])
|
||
if not normalized_entries:
|
||
self.dictionary_feedback_label.set_text("That correction is not valid for the file.")
|
||
return
|
||
candidate = normalized_entries[0]
|
||
heard_key = candidate["heard"].casefold()
|
||
existing = [entry for entry in entries if entry["heard"].casefold() == heard_key]
|
||
entries = [entry for entry in entries if entry["heard"].casefold() != heard_key]
|
||
entries.append(candidate)
|
||
if not self.dictionary_manager.save_corrections(entries):
|
||
self.dictionary_feedback_label.set_text(
|
||
"Could not save corrections; no correction was changed."
|
||
)
|
||
return
|
||
self.dictionary_heard_entry.set_text("")
|
||
self.dictionary_replacement_entry.set_text("")
|
||
if any(
|
||
entry["heard"].casefold() == heard_key
|
||
for entry in self.dictionary_manager.get_corrections()
|
||
):
|
||
self.dictionary_feedback_label.set_text(
|
||
"Correction updated."
|
||
if existing
|
||
else "Correction saved to the live corrections file."
|
||
)
|
||
else:
|
||
self.dictionary_feedback_label.set_text("That correction is not valid for the file.")
|
||
self._refresh_dictionary_ui()
|
||
|
||
def _on_dictionary_remove_correction(self, widget: Any, heard: str) -> None:
|
||
"""Remove one correction from the structured corrections file."""
|
||
if self._initializing or self._applying_settings or self.dictionary_manager is None:
|
||
return
|
||
editable_entries = self.dictionary_manager.get_corrections_for_edit()
|
||
if editable_entries is None:
|
||
self.dictionary_feedback_label.set_text(
|
||
"Could not edit corrections: fix or replace the malformed corrections file first."
|
||
)
|
||
return
|
||
entries = [
|
||
entry for entry in editable_entries if entry["heard"].casefold() != heard.casefold()
|
||
]
|
||
if self.dictionary_manager.save_corrections(entries):
|
||
self.dictionary_feedback_label.set_text("Correction removed.")
|
||
else:
|
||
self.dictionary_feedback_label.set_text(
|
||
"Could not save corrections; nothing was removed."
|
||
)
|
||
self._refresh_dictionary_ui()
|
||
|
||
def _make_term_row(self, term: str, transient: bool) -> Gtk.ListBoxRow:
|
||
"""Build one removable term row for the custom terms list."""
|
||
row = Gtk.ListBoxRow()
|
||
row.set_activatable(False)
|
||
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
row_box.set_margin_top(6)
|
||
row_box.set_margin_bottom(6)
|
||
row_box.set_margin_start(16)
|
||
row_box.set_margin_end(16)
|
||
label = Gtk.Label(label=term, xalign=0)
|
||
label.set_hexpand(True)
|
||
row_box.pack_start(label, True, True, 0)
|
||
remove_button = Gtk.Button(label="Remove")
|
||
remove_button.set_tooltip_text(f"Remove term {term}")
|
||
remove_button.get_accessible().set_name(f"Remove term {term}")
|
||
remove_button.set_sensitive(not transient)
|
||
remove_button.connect("clicked", self._on_dictionary_remove_term, term)
|
||
row_box.pack_start(remove_button, False, False, 0)
|
||
row.add(row_box)
|
||
return row
|
||
|
||
def _build_term_rows(
|
||
self, terms: list[str], total_terms: int, transient: bool, token: int
|
||
) -> None:
|
||
"""Populate the terms listbox in idle slices of bounded size.
|
||
|
||
Building thousands of GTK rows synchronously stalls the whole
|
||
dialog, so each slice appends at most ``_TERMS_ROWS_PER_IDLE`` rows
|
||
and reschedules itself. Every refresh bumps the build token, which
|
||
retires pending slices from an older pass instead of letting them
|
||
re-add rows the refresh already cleared.
|
||
"""
|
||
pending = iter(terms)
|
||
|
||
def build_chunk() -> bool:
|
||
if token != self._terms_build_token:
|
||
return False
|
||
batch = list(itertools.islice(pending, _TERMS_ROWS_PER_IDLE))
|
||
for term in batch:
|
||
self.dictionary_terms_listbox.add(self._make_term_row(term, transient))
|
||
self.dictionary_terms_listbox.show_all()
|
||
if len(batch) == _TERMS_ROWS_PER_IDLE:
|
||
return True
|
||
self._finish_term_rows(total_terms)
|
||
return False
|
||
|
||
# The first slice runs inline so small lists appear immediately;
|
||
# a slice never exceeds _TERMS_ROWS_PER_IDLE rows of work.
|
||
if build_chunk():
|
||
GLib.idle_add(build_chunk)
|
||
|
||
def _finish_term_rows(self, total_terms: int) -> None:
|
||
"""Append the overflow summary row after the last term slice."""
|
||
if total_terms > _MAX_TERMS_DISPLAYED:
|
||
overflow_row = Gtk.ListBoxRow()
|
||
overflow_row.set_activatable(False)
|
||
overflow_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
overflow_box.set_margin_top(6)
|
||
overflow_box.set_margin_bottom(6)
|
||
overflow_box.set_margin_start(16)
|
||
overflow_box.set_margin_end(16)
|
||
if self._show_all_terms:
|
||
overflow_text = f"Showing all {total_terms} terms."
|
||
toggle_button = Gtk.Button(label=f"Show first {_MAX_TERMS_DISPLAYED}")
|
||
toggle_button.connect("clicked", self._on_terms_show_fewer)
|
||
else:
|
||
overflow_text = f"Showing the first {_MAX_TERMS_DISPLAYED} of {total_terms} terms."
|
||
toggle_button = Gtk.Button(label=f"Show all {total_terms}")
|
||
toggle_button.connect("clicked", self._on_terms_show_all)
|
||
overflow_label = Gtk.Label(label=overflow_text, xalign=0)
|
||
overflow_label.set_line_wrap(True)
|
||
overflow_label.set_hexpand(True)
|
||
overflow_label.get_style_context().add_class("tip-label")
|
||
overflow_box.pack_start(overflow_label, True, True, 0)
|
||
overflow_box.pack_start(toggle_button, False, False, 0)
|
||
overflow_row.add(overflow_box)
|
||
self.dictionary_terms_listbox.add(overflow_row)
|
||
self.dictionary_terms_listbox.show_all()
|
||
if self._scroll_terms_to_row is not None:
|
||
index, expected_term = self._scroll_terms_to_row
|
||
self._scroll_terms_to_row = None
|
||
row = self.dictionary_terms_listbox.get_row_at_index(index)
|
||
row_text = self._term_row_text(row) if row is not None else None
|
||
if row_text is not None and row_text.casefold() == expected_term.casefold():
|
||
self._scroll_terms_row_into_view(row)
|
||
|
||
@staticmethod
|
||
def _term_row_text(row: Gtk.ListBoxRow) -> Optional[str]:
|
||
"""Return the term displayed by a row built by ``_make_term_row``."""
|
||
row_box = row.get_child()
|
||
if not isinstance(row_box, Gtk.Box) or not row_box.get_children():
|
||
return None
|
||
label = row_box.get_children()[0]
|
||
return label.get_label() if isinstance(label, Gtk.Label) else None
|
||
|
||
def _scroll_terms_row_into_view(self, row: Gtk.ListBoxRow) -> None:
|
||
"""Bring a term row inside the terms scroller's viewport once laid out."""
|
||
retries_left = 20
|
||
|
||
def scroll_once() -> bool:
|
||
nonlocal retries_left
|
||
retries_left -= 1
|
||
if row.get_parent() is not self.dictionary_terms_listbox:
|
||
return False
|
||
allocation = row.get_allocation()
|
||
if allocation.height <= 0:
|
||
# Not laid out yet — retry briefly instead of never scrolling.
|
||
return retries_left > 0
|
||
# The row's y is relative to the terms listbox, but the scroller's
|
||
# adjustment addresses the whole card (header and controls above
|
||
# the list) — translate into that space or the scroll stops short.
|
||
viewport = self.dictionary_terms_scroller.get_child()
|
||
content = viewport.get_child() if isinstance(viewport, Gtk.Viewport) else None
|
||
position = row.translate_coordinates(content, 0, 0) if content else None
|
||
if position is None:
|
||
return False
|
||
_, row_y = position
|
||
adjustment = self.dictionary_terms_scroller.get_vadjustment()
|
||
page_size = adjustment.get_page_size()
|
||
value = adjustment.get_value()
|
||
if row_y < value:
|
||
adjustment.set_value(max(adjustment.get_lower(), row_y))
|
||
elif row_y + allocation.height > value + page_size:
|
||
adjustment.set_value(
|
||
min(
|
||
adjustment.get_upper() - page_size,
|
||
row_y + allocation.height - page_size,
|
||
)
|
||
)
|
||
return False
|
||
|
||
GLib.idle_add(scroll_once)
|
||
|
||
def _refresh_dictionary_ui(self) -> None:
|
||
"""Rebuild custom dictionary controls from the live, file-backed state."""
|
||
if not hasattr(self, "dictionary_terms_enabled_switch"):
|
||
return
|
||
if self.dictionary_manager is None:
|
||
self.dictionary_terms_enabled_switch.set_sensitive(False)
|
||
self.dictionary_terms_status_label.set_text("Custom dictionary support is unavailable.")
|
||
return
|
||
transient = self.dictionary_manager.is_transient_terms
|
||
enabled = self.dictionary_manager.terms_enabled()
|
||
self.dictionary_terms_enabled_switch.set_active(enabled)
|
||
self.dictionary_terms_enabled_switch.set_sensitive(not transient)
|
||
self.dictionary_terms_path_entry.set_text(self.dictionary_manager.terms_path_text())
|
||
self.dictionary_terms_path_entry.set_sensitive(not transient)
|
||
self.dictionary_terms_file_button.set_sensitive(not transient)
|
||
self.dictionary_terms_status_label.set_text(
|
||
("Session-only --dictionary-file override is active. " if transient else "")
|
||
+ self.dictionary_manager.terms_status()
|
||
)
|
||
self.dictionary_term_entry.set_sensitive(not transient)
|
||
self.dictionary_add_term_button.set_sensitive(not transient)
|
||
|
||
for child in list(self.dictionary_terms_listbox.get_children()):
|
||
self.dictionary_terms_listbox.remove(child)
|
||
terms = self.dictionary_manager.get_terms()
|
||
visible_terms = terms if self._show_all_terms else terms[:_MAX_TERMS_DISPLAYED]
|
||
# Recently added terms that would fall outside the visible window
|
||
# stay pinned to the top while they remain unseen; a pin expires as
|
||
# soon as the term disappears or becomes visible on its own.
|
||
visible_keys = {term.casefold() for term in visible_terms}
|
||
term_keys = {term.casefold() for term in terms}
|
||
self._pinned_terms = [
|
||
pinned
|
||
for pinned in self._pinned_terms
|
||
if pinned.casefold() in term_keys and pinned.casefold() not in visible_keys
|
||
]
|
||
self._terms_build_token += 1
|
||
self._build_term_rows(
|
||
[*self._pinned_terms, *visible_terms], len(terms), transient, self._terms_build_token
|
||
)
|
||
|
||
for child in list(self.dictionary_corrections_listbox.get_children()):
|
||
self.dictionary_corrections_listbox.remove(child)
|
||
|
||
for entry in self.dictionary_manager.get_corrections():
|
||
row = Gtk.ListBoxRow()
|
||
row.set_activatable(False)
|
||
row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
row_box.set_margin_top(6)
|
||
row_box.set_margin_bottom(6)
|
||
row_box.set_margin_start(16)
|
||
row_box.set_margin_end(16)
|
||
label = Gtk.Label(label=f"{entry['heard']} → {entry['replacement']}", xalign=0)
|
||
label.set_hexpand(True)
|
||
row_box.pack_start(label, True, True, 0)
|
||
remove_button = Gtk.Button(label="Remove")
|
||
remove_button.set_tooltip_text(f"Remove correction for {entry['heard']}")
|
||
remove_button.get_accessible().set_name(f"Remove correction for {entry['heard']}")
|
||
remove_button.connect("clicked", self._on_dictionary_remove_correction, entry["heard"])
|
||
row_box.pack_start(remove_button, False, False, 0)
|
||
row.add(row_box)
|
||
self.dictionary_corrections_listbox.add(row)
|
||
self.dictionary_terms_listbox.show_all()
|
||
self.dictionary_corrections_listbox.show_all()
|
||
|
||
def _build_auto_pause_section(self):
|
||
"""Build Auto-Pause settings: enable toggle + process name list."""
|
||
group = PreferencesGroup(
|
||
title="Auto-Pause for Games & Apps",
|
||
description=(
|
||
"Pauses dictation and unloads the speech model while any listed app "
|
||
"is running. Dictation comes back when the app closes."
|
||
),
|
||
keywords=("process", "program", "game"),
|
||
)
|
||
|
||
self.auto_pause_switch = _add_switch_row(
|
||
group,
|
||
title="Pause for listed apps",
|
||
subtitle="Unload the model while these apps are open",
|
||
tooltip=("Pause dictation and unload the speech model while any listed app is running"),
|
||
)
|
||
|
||
# Add process name: entry + Add button
|
||
add_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
add_box.set_margin_top(8)
|
||
add_box.set_margin_bottom(4)
|
||
add_box.set_margin_start(16)
|
||
add_box.set_margin_end(16)
|
||
|
||
self.auto_pause_entry = Gtk.Entry()
|
||
self.auto_pause_entry.set_placeholder_text("Process name (e.g. overwatch, steam)")
|
||
self.auto_pause_entry.set_hexpand(True)
|
||
self.auto_pause_entry.set_tooltip_text(
|
||
"Process name, matched case-insensitively. "
|
||
"For Windows games under Wine/Proton, the .exe suffix is optional."
|
||
)
|
||
add_box.pack_start(self.auto_pause_entry, True, True, 0)
|
||
|
||
self.auto_pause_add_btn = Gtk.Button(label="Add")
|
||
self.auto_pause_add_btn.set_tooltip_text("Add this process name to the list")
|
||
self.auto_pause_add_btn.connect("clicked", self._on_auto_pause_add_clicked)
|
||
self.auto_pause_entry.connect("activate", self._on_auto_pause_add_clicked)
|
||
add_box.pack_start(self.auto_pause_add_btn, False, False, 0)
|
||
|
||
self.auto_pause_pick_btn = Gtk.Button(label="Choose Running App…")
|
||
self.auto_pause_pick_btn.set_tooltip_text("Pick from apps that are running right now")
|
||
self.auto_pause_pick_btn.connect("clicked", self._on_auto_pause_pick_running)
|
||
add_box.pack_start(self.auto_pause_pick_btn, False, False, 0)
|
||
|
||
# Custom row for the add controls (not a PreferenceRow)
|
||
add_row = Gtk.ListBoxRow()
|
||
add_row.set_activatable(False)
|
||
add_row.add(add_box)
|
||
group.add_row(add_row)
|
||
|
||
# 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(48)
|
||
list_scrolled.set_max_content_height(160)
|
||
list_scrolled.set_margin_start(8)
|
||
list_scrolled.set_margin_end(8)
|
||
list_scrolled.set_margin_bottom(8)
|
||
list_scrolled.add(self.auto_pause_listbox)
|
||
|
||
list_row = Gtk.ListBoxRow()
|
||
list_row.set_activatable(False)
|
||
list_row.add(list_scrolled)
|
||
group.add_row(list_row)
|
||
|
||
self.power_tab.pack_start(group, False, False, 0)
|
||
|
||
self.auto_pause_switch.connect("state-set", self._on_auto_pause_enabled_toggled)
|
||
|
||
def _update_auto_pause_sensitivity(self, enabled: bool) -> None:
|
||
"""Gray out the app-list controls while auto-pause is disabled."""
|
||
for widget in (
|
||
self.auto_pause_entry,
|
||
self.auto_pause_add_btn,
|
||
self.auto_pause_pick_btn,
|
||
self.auto_pause_listbox,
|
||
):
|
||
widget.set_sensitive(enabled)
|
||
|
||
def _get_auto_pause_apps(self) -> list:
|
||
"""Return a clean list of configured auto-pause process names."""
|
||
apps = self.config_manager.get("auto_pause", "apps", []) or []
|
||
if not isinstance(apps, list):
|
||
return []
|
||
return [str(a).strip() for a in apps if a and str(a).strip()]
|
||
|
||
def _save_auto_pause_apps(self, apps: list) -> None:
|
||
# Deduplicate case-insensitively while preserving first-seen casing
|
||
seen = set()
|
||
cleaned = []
|
||
for name in apps:
|
||
key = name.strip().lower()
|
||
if not key or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
cleaned.append(name.strip())
|
||
self.config_manager.set("auto_pause", "apps", cleaned)
|
||
self.config_manager.save_settings()
|
||
self._refresh_auto_pause_list()
|
||
|
||
def _refresh_auto_pause_list(self) -> None:
|
||
"""Rebuild the auto-pause app list UI from config."""
|
||
if not hasattr(self, "auto_pause_listbox"):
|
||
return
|
||
|
||
for child in list(self.auto_pause_listbox.get_children()):
|
||
self.auto_pause_listbox.remove(child)
|
||
|
||
apps = self._get_auto_pause_apps()
|
||
for name in apps:
|
||
row = Gtk.ListBoxRow()
|
||
row.set_activatable(False)
|
||
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
hbox.set_margin_top(6)
|
||
hbox.set_margin_bottom(6)
|
||
hbox.set_margin_start(16)
|
||
hbox.set_margin_end(16)
|
||
|
||
label = Gtk.Label(label=name, xalign=0)
|
||
label.set_hexpand(True)
|
||
hbox.pack_start(label, True, True, 0)
|
||
|
||
remove_btn = Gtk.Button(label="Remove")
|
||
remove_btn.set_tooltip_text(f"Remove {name} from auto-pause list")
|
||
remove_btn.connect("clicked", self._on_auto_pause_remove_clicked, name)
|
||
hbox.pack_start(remove_btn, False, False, 0)
|
||
|
||
row.add(hbox)
|
||
self.auto_pause_listbox.add(row)
|
||
|
||
self.auto_pause_listbox.show_all()
|
||
|
||
def _on_auto_pause_enabled_toggled(self, widget, state):
|
||
enabled = bool(state)
|
||
self._update_auto_pause_sensitivity(enabled)
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
logger.info("Auto-pause enabled toggled: %s", enabled)
|
||
self.config_manager.set("auto_pause", "enabled", enabled)
|
||
self.config_manager.save_settings()
|
||
return False
|
||
|
||
def _on_auto_pause_add_clicked(self, widget):
|
||
if _handlers_suppressed(self):
|
||
return
|
||
name = self.auto_pause_entry.get_text().strip()
|
||
if not name:
|
||
return
|
||
# Strip path if user pasted a full path
|
||
name = os.path.basename(name)
|
||
if name.lower().endswith(".exe"):
|
||
name = name[:-4]
|
||
apps = self._get_auto_pause_apps()
|
||
if name.lower() in {a.lower() for a in apps}:
|
||
self.auto_pause_entry.set_text("")
|
||
return
|
||
apps.append(name)
|
||
self._save_auto_pause_apps(apps)
|
||
self.auto_pause_entry.set_text("")
|
||
logger.info("Added auto-pause app: %s", name)
|
||
|
||
def _on_auto_pause_remove_clicked(self, widget, name: str):
|
||
if _handlers_suppressed(self):
|
||
return
|
||
apps = [a for a in self._get_auto_pause_apps() if a.lower() != name.lower()]
|
||
self._save_auto_pause_apps(apps)
|
||
logger.info("Removed auto-pause app: %s", name)
|
||
|
||
def _on_auto_pause_pick_running(self, widget):
|
||
"""Show a simple dialog listing running process names to add."""
|
||
if _handlers_suppressed(self):
|
||
return
|
||
|
||
try:
|
||
import psutil
|
||
except ImportError:
|
||
logger.warning("psutil not available for process picker")
|
||
return
|
||
|
||
names: set[str] = set()
|
||
for proc in psutil.process_iter(["name"]):
|
||
try:
|
||
n = proc.info.get("name")
|
||
if n:
|
||
base = os.path.basename(n)
|
||
if base.lower().endswith(".exe"):
|
||
base = base[:-4]
|
||
names.add(base)
|
||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||
continue
|
||
|
||
if not names:
|
||
return
|
||
|
||
dialog = Gtk.Dialog(title="Choose a Running App", transient_for=self, flags=0)
|
||
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||
dialog.add_button("Add", Gtk.ResponseType.OK)
|
||
dialog.set_default_size(360, 400)
|
||
|
||
scrolled = Gtk.ScrolledWindow()
|
||
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||
scrolled.set_margin_top(8)
|
||
scrolled.set_margin_bottom(8)
|
||
scrolled.set_margin_start(8)
|
||
scrolled.set_margin_end(8)
|
||
|
||
listbox = Gtk.ListBox()
|
||
listbox.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
|
||
already = {a.lower() for a in self._get_auto_pause_apps()}
|
||
for name in sorted(names, key=str.lower):
|
||
if name.lower() in already:
|
||
continue
|
||
row = Gtk.ListBoxRow()
|
||
label = Gtk.Label(label=name, xalign=0)
|
||
label.set_margin_start(8)
|
||
label.set_margin_end(8)
|
||
label.set_margin_top(4)
|
||
label.set_margin_bottom(4)
|
||
row.add(label)
|
||
listbox.add(row)
|
||
scrolled.add(listbox)
|
||
dialog.get_content_area().pack_start(scrolled, True, True, 0)
|
||
dialog.show_all()
|
||
|
||
response = dialog.run()
|
||
if response == Gtk.ResponseType.OK:
|
||
selected = listbox.get_selected_rows()
|
||
apps = self._get_auto_pause_apps()
|
||
existing = {a.lower() for a in apps}
|
||
for row in selected:
|
||
label = row.get_child()
|
||
if isinstance(label, Gtk.Label):
|
||
name = label.get_text().strip()
|
||
if name and name.lower() not in existing:
|
||
apps.append(name)
|
||
existing.add(name.lower())
|
||
self._save_auto_pause_apps(apps)
|
||
dialog.destroy()
|
||
|
||
def _build_model_keepalive_section(self):
|
||
"""Build idle unload settings: enable toggle + idle timeout."""
|
||
group = PreferencesGroup(
|
||
title="Unload When Idle",
|
||
description=(
|
||
"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."
|
||
),
|
||
)
|
||
|
||
self.model_keepalive_switch = _add_switch_row(
|
||
group,
|
||
title="Unload model when idle",
|
||
subtitle="Free RAM and GPU after this much inactivity",
|
||
tooltip=("Unload the speech model after the idle timeout to save memory and battery"),
|
||
)
|
||
|
||
self.model_keepalive_timeout_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.model_keepalive_timeout_combo)
|
||
_combo_chrome(
|
||
self.model_keepalive_timeout_combo,
|
||
tooltip="How long to wait after the last dictation before unloading the model",
|
||
)
|
||
# id = seconds as string
|
||
for seconds, label in (
|
||
(60, "1 minute"),
|
||
(300, "5 minutes"),
|
||
(600, "10 minutes"),
|
||
(900, "15 minutes"),
|
||
(1800, "30 minutes"),
|
||
):
|
||
self.model_keepalive_timeout_combo.append(str(seconds), label)
|
||
timeout_row = PreferenceRow(
|
||
title="Idle Timeout",
|
||
subtitle="How long to wait after the last dictation",
|
||
widget=self.model_keepalive_timeout_combo,
|
||
)
|
||
group.add_row(timeout_row)
|
||
|
||
self.model_reload_buffer_switch = Gtk.Switch()
|
||
group.add_row(
|
||
PreferenceRow(
|
||
title="Record while model reloads",
|
||
subtitle="Start speaking immediately. Audio is kept in memory until the model is ready.",
|
||
widget=self.model_reload_buffer_switch,
|
||
)
|
||
)
|
||
self.model_reload_buffer_switch.connect("state-set", self._on_model_reload_buffer_toggled)
|
||
|
||
self.power_tab.pack_start(group, False, False, 0)
|
||
|
||
self.model_keepalive_switch.connect("state-set", self._on_model_keepalive_enabled_toggled)
|
||
self.model_keepalive_timeout_combo.connect(
|
||
"changed", self._on_model_keepalive_timeout_changed
|
||
)
|
||
|
||
def _update_model_keepalive_sensitivity(self, enabled: bool) -> None:
|
||
"""Gray out the idle timeout selector while idle unload is disabled."""
|
||
self.model_keepalive_timeout_combo.set_sensitive(enabled)
|
||
self.model_reload_buffer_switch.set_sensitive(enabled)
|
||
|
||
def _on_model_reload_buffer_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Apply and save recording during idle model reloads."""
|
||
if self._initializing or self._applying_settings:
|
||
return False
|
||
enabled = bool(state)
|
||
self.speech_engine.buffer_during_reload = enabled
|
||
self.config_manager.set("model_keepalive", "buffer_during_reload", enabled)
|
||
self.config_manager.save_settings()
|
||
return False
|
||
|
||
def _on_model_keepalive_enabled_toggled(self, widget, state):
|
||
enabled = bool(state)
|
||
self._update_model_keepalive_sensitivity(enabled)
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
logger.info("Model keep-alive enabled toggled: %s", enabled)
|
||
self.config_manager.set("model_keepalive", "enabled", enabled)
|
||
self.config_manager.save_settings()
|
||
return False
|
||
|
||
def _on_model_keepalive_timeout_changed(self, widget):
|
||
if _handlers_suppressed(self):
|
||
return
|
||
active_id = self.model_keepalive_timeout_combo.get_active_id()
|
||
if not active_id:
|
||
return
|
||
try:
|
||
seconds = int(active_id)
|
||
except (TypeError, ValueError):
|
||
return
|
||
logger.info("Model keep-alive timeout set to %s seconds", seconds)
|
||
self.config_manager.set("model_keepalive", "idle_timeout_seconds", seconds)
|
||
self.config_manager.save_settings()
|
||
|
||
def _on_history_enabled_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Handle toggle of the keep-history switch."""
|
||
if self._initializing or self._applying_settings:
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Transcription history toggled: {enabled}")
|
||
self.config_manager.set("history", "enabled", enabled)
|
||
self.config_manager.save_settings()
|
||
if self.history_update_callback:
|
||
self.history_update_callback()
|
||
return False
|
||
|
||
def _on_history_max_items_changed(self, widget: Gtk.SpinButton) -> None:
|
||
"""Handle change of the snippets-to-keep spin button."""
|
||
if self._initializing or self._applying_settings:
|
||
return
|
||
|
||
max_items = widget.get_value_as_int()
|
||
logger.info(f"Transcription history max items: {max_items}")
|
||
self.config_manager.set("history", "max_items", max_items)
|
||
self.config_manager.save_settings()
|
||
if self.history_update_callback:
|
||
self.history_update_callback()
|
||
|
||
def _on_history_persist_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Handle toggle of the keep-on-disk switch."""
|
||
if self._initializing or self._applying_settings:
|
||
return False
|
||
|
||
persist = bool(state)
|
||
logger.info(f"Transcription history persistence toggled: {persist}")
|
||
self.config_manager.set("history", "persist", persist)
|
||
self.config_manager.save_settings()
|
||
if self.history_update_callback:
|
||
self.history_update_callback()
|
||
return False
|
||
|
||
def _on_history_clear_clicked(self, widget: Gtk.Button) -> None:
|
||
"""Handle click of the clear-history button."""
|
||
if self._initializing or self._applying_settings:
|
||
return
|
||
|
||
logger.info("Clear history requested from settings")
|
||
if self.history_clear_callback:
|
||
self.history_clear_callback()
|
||
|
||
def _on_autostart_toggled(self, widget, state):
|
||
"""Handle toggle of the autostart switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Autostart toggled: {enabled}")
|
||
|
||
from . import autostart_manager
|
||
|
||
if autostart_manager.set_autostart(enabled):
|
||
self.config_manager.set("general", "autostart", enabled)
|
||
self.config_manager.save_settings()
|
||
logger.info(f"Autostart {'enabled' if enabled else 'disabled'}")
|
||
return False
|
||
|
||
return True
|
||
|
||
def _on_start_minimized_toggled(self, widget, state):
|
||
"""Handle toggle of the start minimized switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Start minimized toggled: {enabled}")
|
||
self.config_manager.set("ui", "start_minimized", enabled)
|
||
self.config_manager.save_settings()
|
||
logger.info(f"Start minimized {'enabled' if enabled else 'disabled'}")
|
||
return False
|
||
|
||
def _on_missing_tray_warning_toggled(self, widget, state):
|
||
"""Handle toggle of the missing tray support warning switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Missing tray warning toggled: {enabled}")
|
||
self.config_manager.set("ui", "show_missing_tray_warning", enabled)
|
||
self.config_manager.save_settings()
|
||
return False
|
||
|
||
def _on_show_overlay_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Handle toggle of the floating dictation overlay switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
self.config_manager.set_overlay_enabled(enabled)
|
||
self.config_manager.save_settings()
|
||
if self.overlay_enabled_callback is not None:
|
||
try:
|
||
self.overlay_enabled_callback(enabled)
|
||
except Exception as e:
|
||
logger.warning(f"Overlay enabled callback failed: {e}")
|
||
logger.info(f"Dictation overlay {'enabled' if enabled else 'disabled'}")
|
||
return False
|
||
|
||
def _on_copy_to_clipboard_toggled(self, widget, state):
|
||
"""Handle toggle of the copy to clipboard switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Copy to clipboard toggled: {enabled}")
|
||
self.config_manager.set("text_injection", "copy_to_clipboard", enabled)
|
||
self.config_manager.save_settings()
|
||
logger.info(f"Copy to clipboard {'enabled' if enabled else 'disabled'}")
|
||
return False
|
||
|
||
def _on_dictation_pad_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Handle toggle of the in-app dictation pad switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Dictate to pad toggled: {enabled}")
|
||
self.config_manager.set("text_injection", "dictate_to_pad", enabled)
|
||
self.config_manager.save_settings()
|
||
logger.info(f"Dictate to pad {'enabled' if enabled else 'disabled'}")
|
||
return False
|
||
|
||
def _add_dictation_pad_open_row(self, group: PreferencesGroup) -> None:
|
||
"""Add the optional Dictation Pad launcher row."""
|
||
self.dictation_pad_open_button: Optional[Gtk.Button] = None
|
||
if self.dictation_pad_open_callback is None:
|
||
return
|
||
button = Gtk.Button(label="Open")
|
||
button.connect("clicked", self._on_dictation_pad_open_clicked)
|
||
group.add_row(
|
||
PreferenceRow(
|
||
title="Open Dictation Pad",
|
||
subtitle="Show the Dictation Pad window",
|
||
widget=button,
|
||
keywords=("pad", "window", "open"),
|
||
)
|
||
)
|
||
self.dictation_pad_open_button = button
|
||
|
||
def _on_dictation_pad_open_clicked(self, _button: Gtk.Button) -> None:
|
||
"""Open the Dictation Pad when its optional launcher is available."""
|
||
callback = self.dictation_pad_open_callback
|
||
if callback is not None:
|
||
callback()
|
||
|
||
def _on_auto_capitalize_toggled(self, widget, state):
|
||
"""Handle toggle of the auto-capitalize switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Auto-capitalize toggled: {enabled}")
|
||
self.config_manager.set("text_injection", "auto_capitalize", enabled)
|
||
self.config_manager.save_settings()
|
||
logger.info(f"Auto-capitalize {'enabled' if enabled else 'disabled'}")
|
||
return False
|
||
|
||
def _on_append_trailing_space_toggled(self, widget, state):
|
||
"""Handle toggle of the trailing space after dictation switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Append trailing space toggled: {enabled}")
|
||
self.config_manager.set("text_injection", "append_trailing_space", enabled)
|
||
self.config_manager.save_settings()
|
||
logger.info(f"Append trailing space {'enabled' if enabled else 'disabled'}")
|
||
|
||
def _on_paste_shortcut_changed(self, widget):
|
||
"""Handle clipboard paste-shortcut combo changes."""
|
||
if _handlers_suppressed(self):
|
||
return
|
||
|
||
shortcut_id = self.paste_shortcut_combo.get_active_id() or DEFAULT_PASTE_SHORTCUT
|
||
self.config_manager.set_paste_shortcut(shortcut_id)
|
||
self.config_manager.save_settings()
|
||
logger.info(f"Paste shortcut set to {self.config_manager.get_paste_shortcut()}")
|
||
return False
|
||
|
||
def _on_paste_long_text_toggled(self, _widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Handle the long-dictation clipboard-paste switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
self.config_manager.set_paste_long_text(enabled)
|
||
self.config_manager.save_settings()
|
||
logger.info("Paste long dictations toggled: %s", enabled)
|
||
return False
|
||
|
||
def _update_sound_effects_sensitivity(self, enabled: bool) -> None:
|
||
"""Gray out the tone picker while sound effects are disabled."""
|
||
self.tone_row.set_sensitive(enabled)
|
||
|
||
def _on_sound_effects_toggled(self, widget, state):
|
||
enabled = bool(state)
|
||
self._update_sound_effects_sensitivity(enabled)
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
logger.info(f"Sound effects toggled: {enabled}")
|
||
self.config_manager.set_sound_effects_enabled(enabled)
|
||
self.config_manager.save_settings()
|
||
logger.info(f"Sound effects {'enabled' if enabled else 'disabled'}")
|
||
return False
|
||
|
||
def _on_duck_playback_toggled(self, _widget: Gtk.Widget, state: bool) -> bool:
|
||
enabled = bool(state)
|
||
self.duck_level_scale.set_sensitive(enabled)
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
logger.info("Lower other audio while dictating toggled: %s", enabled)
|
||
self.config_manager.set_playback_duck_enabled(enabled)
|
||
self.config_manager.save_settings()
|
||
return False
|
||
|
||
def _on_duck_level_changed(self, scale: Gtk.Scale) -> None:
|
||
if _handlers_suppressed(self):
|
||
return
|
||
percent = int(round(scale.get_value()))
|
||
logger.info("Playback duck level set to %s%%", percent)
|
||
self.config_manager.set_playback_duck_percent(percent)
|
||
self.config_manager.save_settings()
|
||
|
||
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._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_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._tone_preview_kind = "start"
|
||
self._sync_tone_preview_button(tone_id)
|
||
if _handlers_suppressed(self):
|
||
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) -> None:
|
||
from .audio_feedback import preview_tone_cue
|
||
|
||
tone_id = self.sound_tone_combo.get_active_id() or DEFAULT_SOUND_EFFECT_TONE
|
||
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_simple_model_section(self):
|
||
"""Build the simple questions and the Advanced reveal (#779)."""
|
||
self.simple_group = PreferencesGroup(title="Language")
|
||
|
||
# Searchable, like the advanced row: over thirty languages is too many to
|
||
# scroll, and a list you cannot type into is a step backwards.
|
||
self.simple_language_combo = SearchablePicker()
|
||
_style_combo(self.simple_language_combo)
|
||
_combo_chrome(self.simple_language_combo)
|
||
for language_id, info in SUPPORTED_LANGUAGES.items():
|
||
# Auto-detect is the switch below, not a language you speak.
|
||
if language_id != "auto":
|
||
self.simple_language_combo.append(language_id, info["name"])
|
||
_attach_language_combo_search(self.simple_language_combo)
|
||
_connect_picker_entry(
|
||
self.simple_language_combo,
|
||
self._on_simple_language_entry_activate,
|
||
self._on_simple_language_entry_focus_out,
|
||
)
|
||
self.simple_language_row = PreferenceRow(
|
||
title="Main language",
|
||
subtitle="Search or pick from the list",
|
||
widget=self.simple_language_combo,
|
||
keywords=("language", "speak"),
|
||
)
|
||
self.simple_group.add_row(self.simple_language_row)
|
||
|
||
# The engine takes one language or none, so this is the only other option
|
||
# that exists. A second language field would promise something it cannot do.
|
||
self.simple_multi_switch = _add_switch_row(
|
||
self.simple_group,
|
||
title="Other languages",
|
||
subtitle="Guesses the language each time. Short clips can be wrong.",
|
||
keywords=("multilingual", "auto", "detect"),
|
||
)
|
||
|
||
self.simple_second_language_combo = SearchablePicker()
|
||
_style_combo(self.simple_second_language_combo)
|
||
_combo_chrome(self.simple_second_language_combo)
|
||
# First entry is the multilingual answer: any language, detected per
|
||
# utterance. Naming one specific second language means the same thing
|
||
# to the engine, but lets the user say which one they had in mind.
|
||
self.simple_second_language_combo.append("auto", "Any language")
|
||
for language_id, info in SUPPORTED_LANGUAGES.items():
|
||
if language_id != "auto":
|
||
self.simple_second_language_combo.append(language_id, info["name"])
|
||
_attach_language_combo_search(self.simple_second_language_combo)
|
||
# SearchablePicker already emits "changed" on Enter/row pick; do not also
|
||
# hook activate or a single choice would apply twice.
|
||
self.simple_second_language_row = PreferenceRow(
|
||
title="Other language",
|
||
# Honest about what the engine does: whisper takes one language or
|
||
# none, so any second language means detection per utterance.
|
||
subtitle="Or pick Any language to auto-detect",
|
||
widget=self.simple_second_language_combo,
|
||
keywords=("second", "language", "other"),
|
||
)
|
||
self.simple_second_language_row.set_no_show_all(True)
|
||
self.simple_group.add_row(self.simple_second_language_row)
|
||
|
||
self.simple_priority_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.simple_priority_combo)
|
||
_combo_chrome(self.simple_priority_combo)
|
||
for priority in PRIORITIES:
|
||
self.simple_priority_combo.append(priority, PRIORITY_LABELS[priority])
|
||
self.simple_priority_row = PreferenceRow(
|
||
title="Speed vs accuracy",
|
||
subtitle="Balanced is the default for this computer",
|
||
widget=self.simple_priority_combo,
|
||
keywords=("speed", "accuracy", "priority"),
|
||
)
|
||
self.simple_group.add_row(self.simple_priority_row)
|
||
|
||
# The simple card and, under it, the info card the engine section adds.
|
||
self.simple_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||
self.simple_page.pack_start(self.simple_group, False, False, 0)
|
||
self.content_box.pack_start(self.simple_page, False, False, 0)
|
||
|
||
# Advanced is its own island under the simple card: collapsed to a
|
||
# header by default, expanding in place. Not a second window — that went
|
||
# wrong twice on KWin/Wayland: transient for the dialog it crashed the
|
||
# compositor (findModal recursion, tag kwin-crash-repro); standing alone
|
||
# it could not be raised above the dialog at all. Both cards visible at
|
||
# once also makes the expanded rows a readout of what the simple answers
|
||
# resolved to.
|
||
(
|
||
self.advanced_island,
|
||
self.advanced_expander,
|
||
self.advanced_box,
|
||
_,
|
||
) = _make_expander_card(
|
||
"Advanced",
|
||
"Engine, model, remote server, and local VocaGateway",
|
||
)
|
||
self.advanced_box.set_spacing(12)
|
||
self.advanced_box.set_margin_top(8)
|
||
self.content_box.pack_start(self.advanced_island, False, False, 0)
|
||
|
||
self.advanced_expander.connect("notify::expanded", self._on_advanced_expanded)
|
||
self.simple_language_combo.connect("changed", self._on_simple_choice_changed)
|
||
self.simple_second_language_combo.connect("changed", self._on_simple_choice_changed)
|
||
self.simple_multi_switch.connect("notify::active", self._on_simple_choice_changed)
|
||
self.simple_priority_combo.connect("changed", self._on_simple_choice_changed)
|
||
|
||
def _build_engine_section(self):
|
||
"""Build the Speech Engine section."""
|
||
group = PreferencesGroup(title="Speech Engine")
|
||
self.engine_group = group
|
||
|
||
# Engine selection
|
||
self.engine_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.engine_combo)
|
||
_combo_chrome(self.engine_combo)
|
||
engine_row = PreferenceRow(
|
||
title="Engine",
|
||
subtitle="Speech recognition backend",
|
||
widget=self.engine_combo,
|
||
)
|
||
group.add_row(engine_row)
|
||
|
||
# Model size selection
|
||
self.model_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.model_combo)
|
||
self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP)
|
||
_combo_chrome(self.model_combo)
|
||
self.model_row = PreferenceRow(
|
||
title="Model size",
|
||
subtitle="Larger models are more accurate but slower",
|
||
widget=self.model_combo,
|
||
)
|
||
self.model_row.set_tooltip_text(MODEL_SIZE_TOOLTIP)
|
||
group.add_row(self.model_row)
|
||
|
||
# whisper.cpp specialization selection
|
||
self.model_variant_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.model_variant_combo)
|
||
_combo_chrome(self.model_variant_combo, MODEL_SPECIALIZATION_TOOLTIP)
|
||
self.model_variant_row = PreferenceRow(
|
||
title="Specialization",
|
||
subtitle="Variant for language, speed, or memory use",
|
||
widget=self.model_variant_combo,
|
||
)
|
||
self.model_variant_row.set_tooltip_text(MODEL_SPECIALIZATION_TOOLTIP)
|
||
group.add_row(self.model_variant_row)
|
||
|
||
# Language selection (searchable: type to filter the 30+ language list)
|
||
self.language_combo = SearchablePicker()
|
||
_style_combo(self.language_combo)
|
||
_combo_chrome(self.language_combo, LANGUAGE_TOOLTIP)
|
||
_attach_language_combo_search(self.language_combo)
|
||
_connect_picker_entry(
|
||
self.language_combo,
|
||
self._on_language_entry_activate,
|
||
self._on_language_entry_focus_out,
|
||
)
|
||
self.language_row = PreferenceRow(
|
||
title="Language",
|
||
subtitle="Search or pick from the list",
|
||
widget=self.language_combo,
|
||
)
|
||
self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP)
|
||
group.add_row(self.language_row)
|
||
|
||
# Follow-the-layout is a mode, not a language, so it is a switch over the
|
||
# picker rather than another entry inside it (#821). On, the picker is
|
||
# disabled and shows what the layout currently resolves to; off, whatever
|
||
# it shows becomes the pinned language again.
|
||
self.follow_layout_switch = Gtk.Switch()
|
||
self.follow_layout_switch.set_tooltip_text(FOLLOW_LAYOUT_TOOLTIP)
|
||
self.follow_layout_row = PreferenceRow(
|
||
title="Follow keyboard layout",
|
||
subtitle="Dictate in the language of whichever layout you are typing in",
|
||
widget=self.follow_layout_switch,
|
||
keywords=("keyboard", "layout", "follow", "bilingual", "language", "switch"),
|
||
)
|
||
self.follow_layout_row.set_tooltip_text(FOLLOW_LAYOUT_TOOLTIP)
|
||
group.add_row(self.follow_layout_row)
|
||
|
||
# Lives inside the revealer built above, so the Advanced switch slides it out.
|
||
self.advanced_box.pack_start(group, False, False, 0)
|
||
|
||
# Model info card (shown below the group)
|
||
self.model_info_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||
self.model_info_card.get_style_context().add_class("model-info-card")
|
||
self.model_info_card.set_margin_start(4)
|
||
self.model_info_card.set_margin_end(4)
|
||
|
||
self.model_info_title = Gtk.Label(xalign=0)
|
||
self.model_info_title.get_style_context().add_class("model-info-title")
|
||
self.model_info_card.pack_start(self.model_info_title, False, False, 0)
|
||
|
||
self.model_info_subtitle = Gtk.Label(xalign=0, wrap=True)
|
||
self.model_info_subtitle.get_style_context().add_class("model-info-subtitle")
|
||
self.model_info_card.pack_start(self.model_info_subtitle, False, False, 0)
|
||
|
||
# Picking a spec only stages it; this button is the explicit
|
||
# confirmation that turns the staged spec into a download (#894).
|
||
self.model_download_button = Gtk.Button(label="Download")
|
||
self.model_download_button.get_style_context().add_class("suggested-action")
|
||
self.model_download_button.set_halign(Gtk.Align.START)
|
||
self.model_download_button.set_no_show_all(True)
|
||
self.model_download_button.connect("clicked", self._on_download_model_clicked)
|
||
self.model_info_card.pack_start(self.model_download_button, False, False, 0)
|
||
|
||
model_selection_link = Gtk.LinkButton.new_with_label(
|
||
MODEL_SELECTION_DOC_URL, "How to choose a model"
|
||
)
|
||
model_selection_link.set_halign(Gtk.Align.START)
|
||
self.simple_page.pack_start(model_selection_link, False, False, 0)
|
||
|
||
self.model_recommendation_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
self.model_recommendation_box.set_no_show_all(True)
|
||
|
||
self.model_recommendation = Gtk.Label(xalign=0, wrap=True)
|
||
self.model_recommendation.get_style_context().add_class("model-info-subtitle")
|
||
self.model_recommendation_box.pack_start(self.model_recommendation, True, True, 0)
|
||
|
||
self.model_info_card.pack_start(self.model_recommendation_box, False, False, 0)
|
||
|
||
# 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.language_warning.set_no_show_all(True)
|
||
self.model_info_card.pack_start(self.language_warning, False, False, 0)
|
||
|
||
# On the page, under the simple questions: it is the only feedback that a
|
||
# priority or language change did anything, and what it will cost.
|
||
self.simple_page.pack_start(self.model_info_card, False, False, 0)
|
||
|
||
(
|
||
self.unused_island,
|
||
self.unused_expander,
|
||
unused_body,
|
||
self.unused_expander_subtitle,
|
||
) = _make_expander_card(
|
||
"Unused downloads",
|
||
"Downloaded, but not the one in use",
|
||
)
|
||
self.unused_models_group = PreferencesGroup(
|
||
keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"),
|
||
)
|
||
# Title lives on the expander card. Copy it onto the nested group so
|
||
# search matches "Unused downloads" without drawing a second header.
|
||
self.unused_models_group.title = "Unused downloads"
|
||
self.unused_models_group.description = "Downloaded, but not the one in use"
|
||
# The island is the card; this group only holds rows for search/delete.
|
||
self.unused_models_group.get_style_context().remove_class("preferences-group")
|
||
|
||
self.unused_models_scroll = Gtk.ScrolledWindow()
|
||
self.unused_models_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
self.unused_models_scroll.set_shadow_type(Gtk.ShadowType.NONE)
|
||
self.unused_models_scroll.set_propagate_natural_height(True)
|
||
self.unused_models_scroll.set_max_content_height(_UNUSED_DOWNLOADS_MAX_HEIGHT)
|
||
# Keep the scrollbar in the layout: an overlay one stays invisible until
|
||
# hovered, so a list clipped by the cap looks like it has no more rows.
|
||
self.unused_models_scroll.set_overlay_scrolling(False)
|
||
|
||
# ListBox is GtkScrollable, so wrapping it in a Box forces a Viewport
|
||
# and lets the expander child report a real height.
|
||
list_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||
self.unused_models_group.remove(self.unused_models_group.listbox)
|
||
list_holder.pack_start(self.unused_models_group.listbox, False, False, 0)
|
||
self.unused_models_scroll.add(list_holder)
|
||
self.unused_models_group.pack_start(self.unused_models_scroll, False, False, 0)
|
||
unused_body.pack_start(self.unused_models_group, False, False, 0)
|
||
self.unused_expander.set_expanded(False)
|
||
# Refresh may run while collapsed or before map. Remeasure on expand
|
||
# and on map so a short first measurement cannot leave rows clipped.
|
||
self.unused_expander.connect(
|
||
"notify::expanded", lambda *_args: self._fit_unused_downloads_height()
|
||
)
|
||
self.unused_models_scroll.connect("map", lambda *_args: self._fit_unused_downloads_height())
|
||
self.content_box.pack_start(self.unused_island, False, False, 0)
|
||
|
||
# Connect signals
|
||
self.engine_combo.connect("changed", self._on_engine_changed)
|
||
self.model_combo.connect("changed", self._on_model_changed)
|
||
self.model_variant_combo.connect("changed", self._on_model_variant_changed)
|
||
self.language_combo.connect("changed", self._on_language_changed)
|
||
self.follow_layout_switch.connect("notify::active", self._on_follow_layout_toggled)
|
||
|
||
def _on_remote_api_settings_changed(self, widget):
|
||
"""Handle remote API URL/Key/endpoint changes."""
|
||
if _handlers_suppressed(self):
|
||
return
|
||
|
||
url = self.remote_api_url_entry.get_text().strip()
|
||
key = self.remote_api_key_entry.get_text().strip()
|
||
endpoint = self.remote_api_endpoint_combo.get_active_id() or "/inference"
|
||
model = self.remote_api_model_entry.get_text().strip() or "whisper-1"
|
||
|
||
self.config_manager.set("speech_recognition", "remote_api_url", url)
|
||
self.config_manager.set("speech_recognition", "remote_api_key", key)
|
||
self.config_manager.set("speech_recognition", "remote_api_endpoint", endpoint)
|
||
self.config_manager.set("speech_recognition", "remote_api_model", model)
|
||
self.config_manager.save_settings()
|
||
|
||
# Auto apply only if remote engine is currently active
|
||
engine_text = self.engine_combo.get_active_text()
|
||
engine = _engine_from_display(engine_text) if engine_text else "vosk"
|
||
if engine == "remote_api":
|
||
self._auto_apply_settings()
|
||
|
||
def _on_test_remote_connection(self, widget):
|
||
"""Test remote server connection."""
|
||
url = self.remote_api_url_entry.get_text().strip()
|
||
if not url:
|
||
self.remote_status_label.set_markup(
|
||
"<span foreground='#c01c28'>✗ Please enter a server URL</span>"
|
||
)
|
||
return
|
||
|
||
self.remote_test_btn.set_sensitive(False)
|
||
self.remote_test_btn.set_label("Testing...")
|
||
self.remote_status_label.set_markup("<i>Connecting...</i>")
|
||
|
||
key = self.remote_api_key_entry.get_text().strip()
|
||
endpoint = self.remote_api_endpoint_combo.get_active_id() or "/inference"
|
||
|
||
def test_connection(url=url, key=key, endpoint=endpoint):
|
||
try:
|
||
import requests
|
||
|
||
headers = {}
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
|
||
clean_url = url.rstrip("/")
|
||
|
||
session = requests.Session()
|
||
try:
|
||
response = session.get(clean_url, headers=headers, timeout=5)
|
||
|
||
server_info = ""
|
||
|
||
try:
|
||
if endpoint == "/v1/audio/transcriptions":
|
||
openai_resp = session.get(
|
||
f"{clean_url}/v1/models", headers=headers, timeout=5
|
||
)
|
||
if openai_resp.status_code == 200:
|
||
server_info = " (OpenAI compatible)"
|
||
elif endpoint == "/inference":
|
||
whispercpp_resp = session.get(
|
||
f"{clean_url}/inference", headers=headers, timeout=5
|
||
)
|
||
if whispercpp_resp.status_code != 404:
|
||
server_info = " (whisper.cpp server)"
|
||
except Exception:
|
||
pass
|
||
|
||
GLib.idle_add(
|
||
self.remote_status_label.set_markup,
|
||
f"<span foreground='#26a269'>✓ Connected! "
|
||
f"(status={response.status_code}){server_info}</span>",
|
||
)
|
||
finally:
|
||
session.close()
|
||
|
||
except Exception as e:
|
||
error_msg = str(e)[:80]
|
||
GLib.idle_add(
|
||
self.remote_status_label.set_markup,
|
||
f"<span foreground='#c01c28'>✗ Connection failed: {error_msg}</span>",
|
||
)
|
||
|
||
GLib.idle_add(self.remote_test_btn.set_sensitive, True)
|
||
GLib.idle_add(self.remote_test_btn.set_label, "Test Connection")
|
||
|
||
threading.Thread(target=test_connection, daemon=True).start()
|
||
|
||
def _build_recognition_section(self):
|
||
"""Build the Listening and Output groups on the Dictation page."""
|
||
group = PreferencesGroup(title="Listening")
|
||
|
||
# VAD Sensitivity
|
||
self.vad_spin = _style_spin(
|
||
_new_spin(1, 5, 1, 0, tooltip="Higher = more sensitive to quiet speech")
|
||
)
|
||
silero_active = is_silero_available()
|
||
vad_subtitle = (
|
||
"Sensitivity to quiet speech (1-5) — Silero neural VAD"
|
||
if silero_active
|
||
else "Sensitivity to quiet speech (1-5) — amplitude backend"
|
||
)
|
||
self.vad_row = PreferenceRow(
|
||
title="Microphone Sensitivity",
|
||
subtitle=vad_subtitle,
|
||
widget=self.vad_spin,
|
||
keywords=("vad", "voice activity detection"),
|
||
)
|
||
group.add_row(self.vad_row)
|
||
|
||
# Silence Timeout
|
||
self.silence_spin = _style_spin(
|
||
_new_spin(0.5, 5.0, 0.1, 1, tooltip="Wait time after silence before processing speech")
|
||
)
|
||
silence_row = PreferenceRow(
|
||
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)
|
||
|
||
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 = _add_switch_row(
|
||
output_group,
|
||
title="Voice Commands",
|
||
subtitle="Say 'new line', 'period', 'undo' (localized punctuation too)",
|
||
keywords=("punctuation", "editing"),
|
||
tooltip=(
|
||
"Enable voice commands like 'new line', 'period', 'undo', etc.\n"
|
||
"Punctuation phrases also match the recognition language "
|
||
"(e.g. Italian 'virgola', French 'virgule').\n"
|
||
"Useful for VOSK engine. Whisper engines handle punctuation automatically."
|
||
),
|
||
)
|
||
|
||
self.auto_capitalize_switch = _add_switch_row(
|
||
output_group,
|
||
title="Auto-Capitalize Sentences",
|
||
subtitle="Capitalize first letter after punctuation (Vosk only)",
|
||
keywords=("capitalization", "casing", "sentence"),
|
||
tooltip=(
|
||
"Automatically capitalize the first letter of each sentence. "
|
||
"Works after sentence-ending punctuation (period, exclamation, question mark). "
|
||
"Only applies to Vosk engine - Whisper models output proper capitalization "
|
||
"automatically."
|
||
),
|
||
)
|
||
|
||
self.copy_to_clipboard_switch = _add_switch_row(
|
||
output_group,
|
||
title="Copy to Clipboard",
|
||
subtitle="Always copy recognized text to clipboard for easy pasting",
|
||
keywords=("paste",),
|
||
tooltip=(
|
||
"Copy recognized text to clipboard after each transcription. "
|
||
"Useful if injection fails or you want to paste elsewhere."
|
||
),
|
||
)
|
||
|
||
self.dictation_pad_switch = _add_switch_row(
|
||
output_group,
|
||
title="Dictate into Dictation Pad",
|
||
subtitle="Send dictation to the Dictation Pad window instead of other apps",
|
||
keywords=("wayland", "pad", "fallback", "text box"),
|
||
tooltip=(
|
||
"Send dictation to the Dictation Pad instead of injecting it into "
|
||
"other apps. Copy text out by hand. Useful on Wayland, where injecting "
|
||
"keystrokes into other windows is restricted."
|
||
),
|
||
)
|
||
self._add_dictation_pad_open_row(output_group)
|
||
|
||
self.append_trailing_space_switch = _add_switch_row(
|
||
output_group,
|
||
title="Trailing Space After Dictation",
|
||
subtitle="Insert a space after each completed transcription segment",
|
||
keywords=("space", "spacing", "punctuation", "push-to-talk"),
|
||
tooltip=(
|
||
"Append a space after each completed transcription so the next "
|
||
"dictation session continues without gluing onto the previous text "
|
||
'(e.g. "Hello. This" instead of "Hello.This").'
|
||
),
|
||
)
|
||
|
||
self.paste_shortcut_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.paste_shortcut_combo)
|
||
_combo_chrome(
|
||
self.paste_shortcut_combo,
|
||
tooltip=(
|
||
"Clipboard injection uses Ctrl+V in ordinary fields and Ctrl+Shift+V "
|
||
"in terminal windows. Override this when a nested terminal panel "
|
||
"(for example in an IDE) is not detected."
|
||
),
|
||
)
|
||
for shortcut_id, display_name in PASTE_SHORTCUTS:
|
||
self.paste_shortcut_combo.append(shortcut_id, display_name)
|
||
paste_shortcut_row = PreferenceRow(
|
||
title="Clipboard Paste Shortcut",
|
||
subtitle="Auto-detect terminals, or force Ctrl+V / Ctrl+Shift+V",
|
||
widget=self.paste_shortcut_combo,
|
||
keywords=("paste", "terminal", "ctrl", "clipboard"),
|
||
)
|
||
output_group.add_row(paste_shortcut_row)
|
||
|
||
self.paste_long_text_switch = Gtk.Switch()
|
||
paste_long_text_row = PreferenceRow(
|
||
title="Paste long dictations",
|
||
subtitle=(
|
||
"X11: paste text of 30+ characters through the clipboard instead of typing "
|
||
"it key by key. Much faster; restores the clipboard unless Copy to Clipboard "
|
||
"is enabled."
|
||
),
|
||
widget=self.paste_long_text_switch,
|
||
keywords=("paste", "long text", "X11", "clipboard", "speed"),
|
||
)
|
||
output_group.add_row(paste_long_text_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)
|
||
self.dictation_pad_switch.connect("state-set", self._on_dictation_pad_toggled)
|
||
self.auto_capitalize_switch.connect("state-set", self._on_auto_capitalize_toggled)
|
||
self.append_trailing_space_switch.connect(
|
||
"state-set", self._on_append_trailing_space_toggled
|
||
)
|
||
self.paste_shortcut_combo.connect("changed", self._on_paste_shortcut_changed)
|
||
self.paste_long_text_switch.connect("state-set", self._on_paste_long_text_toggled)
|
||
|
||
if not silero_active:
|
||
vad_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
vad_info_box.get_style_context().add_class("info-box")
|
||
vad_info_box.set_margin_start(4)
|
||
vad_info_box.set_margin_end(4)
|
||
vad_info_box.set_margin_top(4)
|
||
|
||
info_icon = Gtk.Image.new_from_icon_name(
|
||
"dialog-information-symbolic", Gtk.IconSize.MENU
|
||
)
|
||
vad_info_box.pack_start(info_icon, False, False, 0)
|
||
|
||
vad_info_label = Gtk.Label(
|
||
label=(
|
||
"Neural VAD is more accurate at separating speech from background "
|
||
"noise. To enable it, install onnxruntime and restart Voculinux:\n"
|
||
' pip install "voculinux[vad]"'
|
||
),
|
||
xalign=0,
|
||
wrap=True,
|
||
selectable=True,
|
||
)
|
||
vad_info_label.get_style_context().add_class("tip-label")
|
||
vad_info_box.pack_start(vad_info_label, True, True, 0)
|
||
|
||
self.recognition_settings_tab.pack_start(vad_info_box, False, False, 0)
|
||
|
||
# Connect signals
|
||
self.vad_spin.connect("value-changed", self._on_vad_changed)
|
||
self.silence_spin.connect("value-changed", self._on_silence_changed)
|
||
self.voice_commands_switch.connect("state-set", self._on_voice_commands_toggled)
|
||
self.auto_capitalize_switch.connect("state-set", self._on_auto_capitalize_toggled)
|
||
|
||
def _build_shortcuts_section(self):
|
||
"""Build the Keyboard Shortcuts section."""
|
||
group = PreferencesGroup(
|
||
title="Keyboard Shortcuts",
|
||
description="Configure the shortcut to control voice recognition",
|
||
)
|
||
|
||
# External activation: drive start/stop from a desktop/compositor global
|
||
# shortcut (bound to `voculinux --toggle`) instead of the built-in key
|
||
# listener. Avoids reading /dev/input (no keylogging, no `input` group).
|
||
self.disable_internal_hotkey_switch = Gtk.Switch()
|
||
self.disable_internal_hotkey_switch.set_tooltip_text(
|
||
"Turn off the built-in key listener and trigger voice typing from a "
|
||
"desktop global shortcut bound to 'voculinux --toggle'. Avoids reading "
|
||
"/dev/input (no keylogging, no 'input' group needed)."
|
||
)
|
||
external_row = PreferenceRow(
|
||
title="External Activation (Desktop Shortcut)",
|
||
subtitle="Use a compositor global shortcut instead of the built-in key listener",
|
||
widget=self.disable_internal_hotkey_switch,
|
||
)
|
||
group.add_row(external_row)
|
||
|
||
# Mode selection (Toggle vs Push-to-Talk)
|
||
self.shortcut_mode_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.shortcut_mode_combo)
|
||
_combo_chrome(
|
||
self.shortcut_mode_combo,
|
||
tooltip="Choose between toggle (double-tap) or push-to-talk mode",
|
||
)
|
||
|
||
# Populate mode options
|
||
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)
|
||
if not self.shortcut_mode_combo.set_active_id(current_mode):
|
||
self.shortcut_mode_combo.set_active_id(DEFAULT_SHORTCUT_MODE)
|
||
|
||
self.mode_row = PreferenceRow(
|
||
title="Shortcut Mode",
|
||
subtitle="How the shortcut behaves",
|
||
widget=self.shortcut_mode_combo,
|
||
)
|
||
group.add_row(self.mode_row)
|
||
|
||
# Shortcut selection combo
|
||
self.shortcut_combo = Gtk.ComboBoxText()
|
||
_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)
|
||
|
||
# Populate shortcut options grouped by side, then a Custom sentinel.
|
||
# Preset and custom are mutually exclusive: only one is active at a time.
|
||
for group_label, shortcut_ids in SHORTCUT_GROUPS.items():
|
||
# Add group separator as a disabled label entry
|
||
separator_id = f"__separator_{group_label}__"
|
||
self.shortcut_combo.append(separator_id, f"── {group_label} ──")
|
||
for shortcut_id in shortcut_ids:
|
||
display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut_id, shortcut_id)
|
||
self.shortcut_combo.append(shortcut_id, display_name)
|
||
self.shortcut_combo.append("__custom__", "Custom Shortcut")
|
||
|
||
# Load current shortcut from config
|
||
current_shortcut = self.config_manager.get_str(
|
||
"shortcuts", "toggle_recognition", DEFAULT_SHORTCUT
|
||
)
|
||
|
||
self.shortcut_row = PreferenceRow(
|
||
title="Shortcut Key",
|
||
subtitle="Press this key to control voice typing",
|
||
widget=self.shortcut_combo,
|
||
)
|
||
group.add_row(self.shortcut_row)
|
||
|
||
# Optional non-suppressing shortcut detection (#950).
|
||
self.pass_shortcut_to_apps_switch = Gtk.Switch()
|
||
self.pass_shortcut_to_apps_switch.set_active(
|
||
self.config_manager.get_bool("shortcuts", "pass_shortcut_to_apps", False)
|
||
)
|
||
self.pass_shortcut_to_apps_switch.set_tooltip_text(
|
||
"Forward shortcut presses and releases to applications while "
|
||
"still triggering dictation. Apps may also react to the shortcut."
|
||
)
|
||
self.pass_shortcut_to_apps_row = PreferenceRow(
|
||
title="Pass Shortcut Keys to Applications",
|
||
subtitle="Preserve Ctrl+wheel zoom and other modifier actions",
|
||
widget=self.pass_shortcut_to_apps_switch,
|
||
keywords=("passthrough", "suppress", "modifier", "mouse wheel"),
|
||
)
|
||
group.add_row(self.pass_shortcut_to_apps_row)
|
||
|
||
# Custom shortcut: modifier+key combos (e.g. Alt+R) for users who want
|
||
# something the preset modifiers can't express (split keyboards, etc.).
|
||
custom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||
self.custom_shortcut_entry = Gtk.Entry()
|
||
self.custom_shortcut_entry.set_placeholder_text("e.g. alt+r or f10")
|
||
self.custom_shortcut_entry.set_width_chars(12)
|
||
self.custom_shortcut_entry.set_tooltip_text(
|
||
"A modifier plus a key (alt+r) or a function key (f10)"
|
||
)
|
||
self.custom_shortcut_entry.connect("activate", self._on_custom_shortcut_apply)
|
||
custom_box.pack_start(self.custom_shortcut_entry, False, False, 0)
|
||
|
||
self.record_shortcut_button = Gtk.Button(label="Record")
|
||
self.record_shortcut_button.set_tooltip_text("Click, then press your desired key combo")
|
||
self.record_shortcut_button.connect("clicked", self._on_record_shortcut_clicked)
|
||
custom_box.pack_start(self.record_shortcut_button, False, False, 0)
|
||
|
||
self.set_custom_shortcut_button = Gtk.Button(label="Set")
|
||
self.set_custom_shortcut_button.set_tooltip_text("Apply the typed shortcut")
|
||
self.set_custom_shortcut_button.connect("clicked", self._on_custom_shortcut_apply)
|
||
custom_box.pack_start(self.set_custom_shortcut_button, False, False, 0)
|
||
|
||
self.custom_shortcut_row = PreferenceRow(
|
||
title="Custom Shortcut",
|
||
subtitle="Modifier + key, or a function key",
|
||
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. ``_recording_shortcut_target``
|
||
# names (entry, apply_fn, button, hint_label) so one recorder serves the
|
||
# main shortcut and every per-language row (#805).
|
||
self._recording_shortcut = False
|
||
self._recording_shortcut_target: Optional[tuple[Any, Callable[[str], None], Any, Any]] = (
|
||
None
|
||
)
|
||
self._evdev_shortcut_recorder = None
|
||
self.connect("key-press-event", self._on_shortcut_key_press)
|
||
self.connect("destroy", self._on_shortcut_recorder_destroy)
|
||
|
||
self.shortcuts_tab.pack_start(group, False, False, 0)
|
||
|
||
# Info box about the shortcut
|
||
info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
info_box.get_style_context().add_class("info-box")
|
||
info_box.set_margin_start(4)
|
||
info_box.set_margin_end(4)
|
||
info_box.set_margin_top(4)
|
||
|
||
info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
|
||
info_box.pack_start(info_icon, False, False, 0)
|
||
|
||
self.shortcut_info_label = Gtk.Label(
|
||
label="Changes take effect immediately.",
|
||
xalign=0,
|
||
wrap=True,
|
||
)
|
||
self.shortcut_info_label.get_style_context().add_class("tip-label")
|
||
info_box.pack_start(self.shortcut_info_label, True, True, 0)
|
||
|
||
self.shortcuts_tab.pack_start(info_box, False, False, 0)
|
||
|
||
# Reflect active shortcut in combo + custom entry (preset vs custom).
|
||
self._sync_shortcut_selection_ui(current_shortcut)
|
||
|
||
# Connect signals
|
||
self.shortcut_combo.connect("changed", self._on_shortcut_changed)
|
||
self.shortcut_mode_combo.connect("changed", self._on_shortcut_mode_changed)
|
||
self.pass_shortcut_to_apps_switch.connect(
|
||
"state-set", self._on_pass_shortcut_to_apps_toggled
|
||
)
|
||
self.disable_internal_hotkey_switch.connect(
|
||
"state-set", self._on_disable_internal_hotkey_toggled
|
||
)
|
||
|
||
# Update UI based on initial mode
|
||
self._update_shortcut_ui_for_mode(current_mode)
|
||
|
||
def _update_internal_hotkey_sensitivity(self, disabled: bool) -> None:
|
||
"""Grey out the built-in shortcut controls when external activation is on."""
|
||
for row in (
|
||
self.mode_row,
|
||
self.shortcut_row,
|
||
self.custom_shortcut_row,
|
||
self.pass_shortcut_to_apps_row,
|
||
):
|
||
row.set_sensitive(not disabled)
|
||
|
||
if disabled:
|
||
self.shortcut_info_label.set_text(
|
||
"External activation is on: the built-in key listener is off. "
|
||
"Bind a desktop global shortcut to 'voculinux --toggle' to start/stop."
|
||
)
|
||
else:
|
||
# Restore the mode-appropriate hint.
|
||
self._update_shortcut_ui_for_mode(
|
||
self.config_manager.get_str("shortcuts", "mode", "toggle")
|
||
)
|
||
|
||
def _on_pass_shortcut_to_apps_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Persist and live-apply shortcut pass-through."""
|
||
if self._initializing or self._applying_settings:
|
||
return False
|
||
enabled = bool(state)
|
||
self.config_manager.set("shortcuts", "pass_shortcut_to_apps", enabled)
|
||
self.config_manager.save_settings()
|
||
if self.hotkey_passthrough_update_callback:
|
||
self.hotkey_passthrough_update_callback(enabled)
|
||
return False
|
||
|
||
def _on_disable_internal_hotkey_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Handle toggle of the external-activation switch."""
|
||
if self._initializing or self._applying_settings:
|
||
return False
|
||
|
||
disabled = bool(state)
|
||
logger.info(f"External activation (internal hotkey disabled) toggled: {disabled}")
|
||
self.config_manager.set("shortcuts", "disable_internal_hotkey", disabled)
|
||
self.config_manager.save_settings()
|
||
|
||
self._update_internal_hotkey_sensitivity(disabled)
|
||
|
||
# Live-apply: start or stop the built-in listener without a restart.
|
||
if self.hotkey_listener_update_callback:
|
||
self.hotkey_listener_update_callback()
|
||
|
||
return False
|
||
|
||
def _build_language_shortcuts_section(self) -> None:
|
||
"""Build the Language Shortcuts section (#805).
|
||
|
||
One row per shortcut → language binding, an Add row at the bottom, and
|
||
an info label the shared key-recorder writes its hints into.
|
||
"""
|
||
group = PreferencesGroup(
|
||
title="Language Shortcuts",
|
||
description=(
|
||
"Start dictation in a specific language with its own key. The "
|
||
"binding lasts for that one dictation; your main language is "
|
||
"restored after. Needs a multilingual model."
|
||
),
|
||
keywords=("language", "shortcut", "multilingual"),
|
||
)
|
||
self.language_shortcuts_group = group
|
||
|
||
for entry in self.config_manager.get_language_shortcuts():
|
||
self._add_language_shortcut_row(entry["language"], entry["shortcut"])
|
||
|
||
add_button = Gtk.Button(label="Add Language Shortcut")
|
||
add_button.set_tooltip_text("Bind another language to its own key")
|
||
add_button.connect("clicked", self._on_add_language_shortcut_clicked)
|
||
self._language_shortcut_add_row = PreferenceRow(
|
||
title="Add a binding",
|
||
subtitle="Pick a language, then record a key for it",
|
||
widget=add_button,
|
||
keywords=("add", "language", "shortcut"),
|
||
)
|
||
group.add_row(self._language_shortcut_add_row)
|
||
|
||
self.shortcuts_tab.pack_start(group, False, False, 0)
|
||
|
||
info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
info_box.get_style_context().add_class("info-box")
|
||
info_box.set_margin_start(4)
|
||
info_box.set_margin_end(4)
|
||
info_box.set_margin_top(4)
|
||
|
||
info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
|
||
info_box.pack_start(info_icon, False, False, 0)
|
||
|
||
self.language_shortcuts_info_label = Gtk.Label(
|
||
label="Language shortcuts take effect immediately and follow the "
|
||
"shortcut mode above.",
|
||
xalign=0,
|
||
wrap=True,
|
||
)
|
||
self.language_shortcuts_info_label.get_style_context().add_class("tip-label")
|
||
info_box.pack_start(self.language_shortcuts_info_label, True, True, 0)
|
||
|
||
self.shortcuts_tab.pack_start(info_box, False, False, 0)
|
||
|
||
def _add_language_shortcut_row(self, language: str, shortcut: str) -> None:
|
||
"""Append one language → shortcut binding row (#805)."""
|
||
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
|
||
language_picker = SearchablePicker()
|
||
_style_combo(language_picker, _CONTROL_WIDTH - 40)
|
||
_prevent_scroll_on_hover(language_picker)
|
||
for lang_code, lang_info in SUPPORTED_LANGUAGES.items():
|
||
language_picker.append(lang_code, str(cast(dict, lang_info)["name"]))
|
||
hbox.pack_start(language_picker, False, False, 0)
|
||
|
||
shortcut_entry = Gtk.Entry()
|
||
shortcut_entry.set_placeholder_text("e.g. ctrl+alt+d")
|
||
shortcut_entry.set_width_chars(14)
|
||
shortcut_entry.set_tooltip_text(
|
||
"A modifier plus a key (ctrl+alt+d) or a function key (f10)"
|
||
)
|
||
shortcut_entry.set_text(shortcut)
|
||
shortcut_entry.connect("changed", self._on_language_shortcut_row_changed)
|
||
hbox.pack_start(shortcut_entry, True, True, 0)
|
||
|
||
record_button = Gtk.Button(label="Record")
|
||
record_button.set_tooltip_text("Click, then press your desired key combo")
|
||
hbox.pack_start(record_button, False, False, 0)
|
||
|
||
remove_button = Gtk.Button.new_from_icon_name("list-remove-symbolic", Gtk.IconSize.BUTTON)
|
||
remove_button.set_tooltip_text("Remove this binding")
|
||
hbox.pack_start(remove_button, False, False, 0)
|
||
|
||
# ``refs["row"]`` fills in below: handlers are wired before the row
|
||
# exists so the picker's initial set_active_id cannot emit stale titles.
|
||
refs = {
|
||
"row": None,
|
||
"language_picker": language_picker,
|
||
"shortcut_entry": shortcut_entry,
|
||
"record_button": record_button,
|
||
# The last binding this row actually persisted; a mid-edit entry
|
||
# (empty or half-typed) falls back to it rather than erasing the
|
||
# binding.
|
||
"last_valid_shortcut": shortcut.strip().lower(),
|
||
}
|
||
language_picker.connect("changed", partial(self._on_language_shortcut_picker_changed, refs))
|
||
record_button.connect("clicked", partial(self._on_record_language_shortcut_clicked, refs))
|
||
remove_button.connect("clicked", partial(self._on_remove_language_shortcut_clicked, refs))
|
||
if not language_picker.set_active_id(language):
|
||
language_picker.set_active_id("auto")
|
||
|
||
lang_info = cast(Optional[dict], SUPPORTED_LANGUAGES.get(language))
|
||
lang_name = str(lang_info["name"]) if lang_info else language
|
||
row = PreferenceRow(
|
||
title=lang_name,
|
||
subtitle="",
|
||
widget=hbox,
|
||
keywords=("language", "shortcut", lang_name),
|
||
)
|
||
refs["row"] = row
|
||
|
||
# Newest binding above the Add row so the group reads top-down.
|
||
if self._language_shortcut_add_row is not None:
|
||
index = self.language_shortcuts_group.rows.index(self._language_shortcut_add_row)
|
||
self.language_shortcuts_group.listbox.insert(row, index)
|
||
self.language_shortcuts_group.rows.insert(index, row)
|
||
else:
|
||
self.language_shortcuts_group.add_row(row)
|
||
self._language_shortcut_rows.append(refs)
|
||
|
||
def _on_language_shortcut_picker_changed(
|
||
self, refs: dict[str, Any], picker: SearchablePicker
|
||
) -> None:
|
||
"""Apply a row's language change: retitle it and persist (#805)."""
|
||
row = refs.get("row")
|
||
if row is not None:
|
||
lang_id = picker.get_active_id() or ""
|
||
lang_info = cast(Optional[dict], SUPPORTED_LANGUAGES.get(lang_id))
|
||
lang_name = str(lang_info["name"]) if lang_info else lang_id
|
||
row.set_title(lang_name)
|
||
self._on_language_shortcut_row_changed(picker)
|
||
|
||
def _on_language_shortcut_row_changed(self, *args: object) -> None:
|
||
"""Persist the bindings whenever a row's language or key changes (#805)."""
|
||
self._persist_language_shortcuts()
|
||
|
||
def _on_record_language_shortcut_clicked(
|
||
self, refs: dict[str, Any], button: Gtk.Button
|
||
) -> None:
|
||
"""Arm key-capture aimed at a language row's entry (#805)."""
|
||
|
||
def _apply(shortcut: str) -> None:
|
||
# _commit_recorded_shortcut already wrote the captured key to the
|
||
# entry, whose changed signal persisted it; setting the same text
|
||
# again would write config a second time and rebuild the
|
||
# listeners for one recorded key.
|
||
if refs["shortcut_entry"].get_text() != shortcut:
|
||
refs["shortcut_entry"].set_text(shortcut)
|
||
|
||
self._begin_shortcut_recording(
|
||
refs["shortcut_entry"],
|
||
_apply,
|
||
refs["record_button"],
|
||
self.language_shortcuts_info_label,
|
||
)
|
||
|
||
def _on_remove_language_shortcut_clicked(
|
||
self, refs: dict[str, Any], button: Gtk.Button
|
||
) -> None:
|
||
"""Remove a language binding row and persist (#805)."""
|
||
# An armed Record capture aimed at this row's entry must be cancelled
|
||
# first: the commit would otherwise write into a destroyed widget.
|
||
target = getattr(self, "_recording_shortcut_target", None)
|
||
if (
|
||
getattr(self, "_recording_shortcut", False)
|
||
and target is not None
|
||
and target[0] is refs["shortcut_entry"]
|
||
):
|
||
self._stop_recording_shortcut()
|
||
row = refs["row"]
|
||
self.language_shortcuts_group.listbox.remove(row)
|
||
if row in self.language_shortcuts_group.rows:
|
||
self.language_shortcuts_group.rows.remove(row)
|
||
self._language_shortcut_rows.remove(refs)
|
||
row.destroy()
|
||
self._persist_language_shortcuts()
|
||
|
||
def _on_add_language_shortcut_clicked(self, button: Gtk.Button) -> None:
|
||
"""Append a fresh binding row (defaults to auto-detect) (#805)."""
|
||
self._add_language_shortcut_row("auto", "")
|
||
self.language_shortcuts_group.show_all()
|
||
self._persist_language_shortcuts()
|
||
|
||
def _collect_language_shortcuts(self) -> list[dict[str, str]]:
|
||
"""Read the current rows as [{shortcut, language}] for config (#805)."""
|
||
entries = []
|
||
for refs in self._language_shortcut_rows:
|
||
entries.append(
|
||
{
|
||
"shortcut": refs["shortcut_entry"].get_text(),
|
||
"language": refs["language_picker"].get_active_id() or "auto",
|
||
}
|
||
)
|
||
return entries
|
||
|
||
def _persist_language_shortcuts(self) -> None:
|
||
"""Write the rows to config and refresh the live listeners (#805).
|
||
|
||
A row mid-edit — empty, half-typed, or claiming a key another row
|
||
already holds — would be dropped by config normalization, erasing its
|
||
binding when the dialog closes. Such rows keep their last persisted
|
||
binding instead, and a rejected duplicate is called out on the info
|
||
label.
|
||
"""
|
||
if self._initializing:
|
||
return
|
||
# Each row's remembered binding belongs to that row first, so a
|
||
# shortcut typed into one row can never displace a binding another
|
||
# row already holds — the stealing edit is rejected instead.
|
||
owners: dict[str, int] = {}
|
||
for index, refs in enumerate(self._language_shortcut_rows):
|
||
owned = (refs.get("last_valid_shortcut") or "").strip().lower()
|
||
if owned and owned not in owners:
|
||
owners[owned] = index
|
||
entries = []
|
||
rejected = []
|
||
claimed = set()
|
||
for index, refs in enumerate(self._language_shortcut_rows):
|
||
language = refs["language_picker"].get_active_id() or "auto"
|
||
shortcut = refs["shortcut_entry"].get_text().strip().lower()
|
||
last_valid = refs.get("last_valid_shortcut", "")
|
||
owner = owners.get(shortcut)
|
||
if (
|
||
shortcut
|
||
and is_valid_shortcut(shortcut)
|
||
and shortcut not in claimed
|
||
and (owner is None or owner == index)
|
||
):
|
||
refs["last_valid_shortcut"] = shortcut
|
||
claimed.add(shortcut)
|
||
elif (
|
||
last_valid and last_valid not in claimed and owners.get(last_valid, index) == index
|
||
):
|
||
if shortcut and is_valid_shortcut(shortcut):
|
||
rejected.append(shortcut)
|
||
shortcut = last_valid
|
||
claimed.add(shortcut)
|
||
else:
|
||
if shortcut and is_valid_shortcut(shortcut):
|
||
rejected.append(shortcut)
|
||
continue
|
||
entries.append({"shortcut": shortcut, "language": language})
|
||
if normalize_language_shortcuts(entries) == self.config_manager.get_language_shortcuts():
|
||
# Nothing effective changed — a mid-edit keystroke, a rejected
|
||
# duplicate, or a reverted field — so the live listeners do not
|
||
# need another rebuild (and dictation never pauses for one).
|
||
self._report_language_shortcut_rejections(rejected)
|
||
return
|
||
self.config_manager.set_language_shortcuts(entries)
|
||
self.config_manager.save_settings()
|
||
if self.language_shortcuts_update_callback:
|
||
self.language_shortcuts_update_callback()
|
||
self._report_language_shortcut_rejections(rejected)
|
||
|
||
def _report_language_shortcut_rejections(self, rejected: list[str]) -> None:
|
||
"""Surface dropped duplicate keys on the section's info label (#805)."""
|
||
label = getattr(self, "language_shortcuts_info_label", None)
|
||
if label is None:
|
||
return
|
||
if rejected:
|
||
keys = ", ".join(sorted(set(rejected)))
|
||
label.set_markup(
|
||
f"<span foreground='#e01b24'>Shortcut "
|
||
f"<b>{GLib.markup_escape_text(keys)}</b> is already bound on "
|
||
"another row.</span>"
|
||
)
|
||
elif not getattr(self, "_recording_shortcut", False):
|
||
label.set_text(
|
||
"Language shortcuts take effect immediately and follow the " "shortcut mode above."
|
||
)
|
||
|
||
def _is_preset_shortcut(self, shortcut: str) -> bool:
|
||
"""Return True if shortcut is one of the built-in double-tap presets."""
|
||
return shortcut in SUPPORTED_SHORTCUTS
|
||
|
||
def _set_custom_shortcut_row_visible(self, visible: bool) -> None:
|
||
"""Show or hide the custom shortcut entry / Record / Set controls.
|
||
|
||
The row uses ``no_show_all`` so a dialog-level ``show_all()`` does not
|
||
reveal it while a preset is selected. The shared dance —
|
||
``set_no_show_all(False)`` then ``show_all()`` to show, ``hide()``
|
||
then re-arm to hide — lives in ``_set_no_show_all_visible``.
|
||
"""
|
||
_set_no_show_all_visible(self.custom_shortcut_row, visible)
|
||
|
||
def _set_shortcut_combo_active_id(self, active_id: str) -> None:
|
||
"""Select a combo item without firing the changed handler."""
|
||
# Handler may not be connected yet (during section build).
|
||
try:
|
||
self.shortcut_combo.handler_block_by_func(self._on_shortcut_changed)
|
||
blocked = True
|
||
except TypeError:
|
||
blocked = False
|
||
try:
|
||
if not self.shortcut_combo.set_active_id(active_id):
|
||
self.shortcut_combo.set_active_id(DEFAULT_SHORTCUT)
|
||
finally:
|
||
if blocked:
|
||
self.shortcut_combo.handler_unblock_by_func(self._on_shortcut_changed)
|
||
|
||
def _sync_shortcut_selection_ui(self, shortcut: str) -> None:
|
||
"""Keep preset combo and custom entry mutually exclusive.
|
||
|
||
- Preset active: combo shows that preset; custom entry is cleared.
|
||
- Custom active: combo shows "Custom Shortcut"; entry holds the combo string.
|
||
"""
|
||
if self._is_preset_shortcut(shortcut):
|
||
self._set_shortcut_combo_active_id(shortcut)
|
||
self.custom_shortcut_entry.set_text("")
|
||
self._set_custom_shortcut_row_visible(False)
|
||
else:
|
||
self._set_shortcut_combo_active_id("__custom__")
|
||
self.custom_shortcut_entry.set_text(shortcut)
|
||
self._set_custom_shortcut_row_visible(True)
|
||
|
||
def _report_shortcut_apply_result(self, display_name: str, applied: bool) -> None:
|
||
"""Show success/restart feedback after a shortcut change."""
|
||
if applied:
|
||
self.shortcut_info_label.set_markup(
|
||
f"<span foreground='#26a269'>Shortcut updated to <b>{display_name}</b>. "
|
||
"Active now!</span>"
|
||
)
|
||
else:
|
||
self.shortcut_info_label.set_markup(
|
||
f"<i>Shortcut updated to <b>{display_name}</b>. "
|
||
"Restart the app for the change to take full effect.</i>"
|
||
)
|
||
|
||
def _apply_custom_shortcut(self, shortcut: str) -> None:
|
||
"""Validate, persist, and live-apply a custom shortcut string."""
|
||
shortcut = shortcut.strip().lower()
|
||
if not is_valid_shortcut(shortcut):
|
||
self.shortcut_info_label.set_markup(
|
||
f"<span foreground='#e01b24'>Invalid shortcut: "
|
||
f"<b>{GLib.markup_escape_text(shortcut or '(empty)')}</b>. "
|
||
"Try a modifier + key (alt+r) or a function key (f10).</span>"
|
||
)
|
||
return
|
||
|
||
# If the user typed/recorded a preset id, treat it as selecting that preset.
|
||
if self._is_preset_shortcut(shortcut):
|
||
self.config_manager.set("shortcuts", "toggle_recognition", shortcut)
|
||
self.config_manager.save_settings()
|
||
self._sync_shortcut_selection_ui(shortcut)
|
||
mode_id = self.shortcut_mode_combo.get_active_id()
|
||
display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)
|
||
logger.info(f"Keyboard shortcut changed to preset via custom entry: {display_name}")
|
||
applied = False
|
||
if self.shortcut_update_callback:
|
||
applied = bool(self.shortcut_update_callback(shortcut, mode_id))
|
||
self._report_shortcut_apply_result(display_name, applied)
|
||
return
|
||
|
||
self.config_manager.set("shortcuts", "toggle_recognition", shortcut)
|
||
self.config_manager.save_settings()
|
||
# Dropdown should show Custom, not a leftover preset.
|
||
self._sync_shortcut_selection_ui(shortcut)
|
||
|
||
mode_id = self.shortcut_mode_combo.get_active_id()
|
||
display_name = get_shortcut_display_name(shortcut, mode_id)
|
||
logger.info(f"Keyboard shortcut changed to custom: {display_name}")
|
||
|
||
applied = False
|
||
if self.shortcut_update_callback:
|
||
applied = bool(self.shortcut_update_callback(shortcut, mode_id))
|
||
self._report_shortcut_apply_result(display_name, applied)
|
||
|
||
def _on_custom_shortcut_apply(self, widget):
|
||
"""Handle Set button / Entry activation for a typed custom shortcut."""
|
||
if self._initializing:
|
||
return
|
||
self._apply_custom_shortcut(self.custom_shortcut_entry.get_text())
|
||
|
||
def _on_record_shortcut_clicked(self, widget):
|
||
"""Begin capturing the next key combo pressed in the dialog."""
|
||
self._begin_shortcut_recording(
|
||
self.custom_shortcut_entry,
|
||
self._apply_custom_shortcut,
|
||
self.record_shortcut_button,
|
||
self.shortcut_info_label,
|
||
)
|
||
|
||
def _begin_shortcut_recording(
|
||
self,
|
||
entry: Gtk.Entry,
|
||
apply_fn: Callable[[str], None],
|
||
button: Gtk.Button,
|
||
hint_label: Gtk.Label,
|
||
) -> None:
|
||
"""Arm key-capture for one shortcut target.
|
||
|
||
``apply_fn`` receives the captured shortcut string; ``hint_label`` gets
|
||
the press-keys hint and any error/cancel messages for this capture.
|
||
"""
|
||
if self._recording_shortcut:
|
||
# Only one capture can be armed: reset the previously armed
|
||
# button instead of leaving two targets captioned "Press keys…".
|
||
self._stop_recording_shortcut()
|
||
self._recording_shortcut = True
|
||
self._recording_shortcut_target = (entry, apply_fn, button, hint_label)
|
||
button.set_label("Press keys…")
|
||
hint_label.set_markup(
|
||
"<i>Press a modifier + key (e.g. Alt+R), or an F-key. Press Esc to cancel.</i>"
|
||
)
|
||
self._start_evdev_shortcut_recorder()
|
||
|
||
def _stop_recording_shortcut(self) -> None:
|
||
"""Exit key-capture mode and restore the armed Record button."""
|
||
self._recording_shortcut = False
|
||
self._stop_evdev_shortcut_recorder()
|
||
target = getattr(self, "_recording_shortcut_target", None)
|
||
self._recording_shortcut_target = None
|
||
if target is not None:
|
||
try:
|
||
target[2].set_label("Record")
|
||
except Exception:
|
||
pass
|
||
|
||
def _start_evdev_shortcut_recorder(self) -> None:
|
||
"""Listen on keyboard evdev nodes for F13–F24 while recording."""
|
||
self._stop_evdev_shortcut_recorder()
|
||
recorder = _EvdevShortcutRecorder(self._on_evdev_recorded_shortcut)
|
||
recorder.start()
|
||
self._evdev_shortcut_recorder = recorder
|
||
|
||
def _stop_evdev_shortcut_recorder(self) -> None:
|
||
recorder = getattr(self, "_evdev_shortcut_recorder", None)
|
||
if recorder is not None:
|
||
recorder.stop()
|
||
self._evdev_shortcut_recorder = None
|
||
|
||
def _on_shortcut_recorder_destroy(self, widget) -> None:
|
||
"""Drop evdev watches if the dialog closes while recording."""
|
||
self._recording_shortcut = False
|
||
self._stop_evdev_shortcut_recorder()
|
||
|
||
def _commit_recorded_shortcut(self, shortcut: Optional[str]) -> bool:
|
||
"""Apply a captured shortcut if still recording. Returns True if consumed."""
|
||
if not getattr(self, "_recording_shortcut", False):
|
||
return False
|
||
if not shortcut or not is_valid_shortcut(shortcut):
|
||
return False
|
||
target = getattr(self, "_recording_shortcut_target", None)
|
||
if target is None:
|
||
return False
|
||
entry, apply_fn, _button, _hint_label = target
|
||
entry.set_text(shortcut)
|
||
self._stop_recording_shortcut()
|
||
apply_fn(shortcut)
|
||
return True
|
||
|
||
def _on_evdev_recorded_shortcut(self, shortcut: str) -> None:
|
||
"""Apply an F13–F24 capture from the evdev watcher (main loop)."""
|
||
self._commit_recorded_shortcut(shortcut)
|
||
|
||
def _gdk_event_to_shortcut(self, event) -> Optional[str]:
|
||
"""Build a canonical shortcut string from a GDK key-press event."""
|
||
modifiers = []
|
||
state = event.state
|
||
if state & Gdk.ModifierType.CONTROL_MASK:
|
||
modifiers.append("ctrl")
|
||
if state & Gdk.ModifierType.MOD1_MASK:
|
||
modifiers.append("alt")
|
||
if state & Gdk.ModifierType.SHIFT_MASK:
|
||
modifiers.append("shift")
|
||
if state & Gdk.ModifierType.SUPER_MASK:
|
||
modifiers.append("super")
|
||
hardware = getattr(event, "hardware_keycode", 0) or 0
|
||
return _gdk_capture_to_shortcut(modifiers, Gdk.keyval_name(event.keyval), hardware)
|
||
|
||
def _on_shortcut_key_press(self, widget, event):
|
||
"""Capture a pressed combo while recording; otherwise pass through."""
|
||
if not getattr(self, "_recording_shortcut", False):
|
||
return False # not recording: let normal event handling proceed
|
||
|
||
keyname = Gdk.keyval_name(event.keyval) or ""
|
||
if keyname in _GDK_MODIFIER_KEYNAMES:
|
||
return True # wait for the non-modifier key
|
||
|
||
shortcut = self._gdk_event_to_shortcut(event)
|
||
if self._commit_recorded_shortcut(shortcut):
|
||
return True
|
||
if not getattr(self, "_recording_shortcut", False):
|
||
return True
|
||
target = getattr(self, "_recording_shortcut_target", None)
|
||
hint_label = target[3] if target is not None else self.shortcut_info_label
|
||
if keyname == "Escape" and not shortcut:
|
||
self._stop_recording_shortcut()
|
||
hint_label.set_text("Recording cancelled.")
|
||
return True
|
||
|
||
hint_label.set_markup(
|
||
"<span foreground='#e01b24'>Need a modifier + key, or an F1–F24 "
|
||
"function key alone. Try again or press Esc to cancel.</span>"
|
||
)
|
||
return True
|
||
|
||
def _update_shortcut_ui_for_mode(self, mode: str):
|
||
"""Update the shortcut UI text to match both the mode and the shortcut.
|
||
|
||
A modifier+key combo (e.g. "alt+r") toggles on a single press, not a
|
||
double-tap, so the wording must reflect the active shortcut instead of
|
||
assuming a bare-modifier gesture. Reads the saved shortcut from config
|
||
(the single source of truth for both preset and custom shortcuts).
|
||
"""
|
||
shortcut = self.config_manager.get_str("shortcuts", "toggle_recognition", DEFAULT_SHORTCUT)
|
||
is_combo = is_valid_shortcut(shortcut) and parse_shortcut_spec(shortcut).is_combo
|
||
# e.g. "Press Alt+R" (combo toggle), "Double-tap Ctrl", "Hold Alt+R".
|
||
action = get_shortcut_display_name(shortcut, mode)
|
||
|
||
if mode == "toggle":
|
||
if is_combo:
|
||
self.shortcut_row.set_subtitle(f"{action} to start/stop voice typing")
|
||
self.shortcut_info_label.set_text(
|
||
f"In Toggle mode: {action} to start voice typing, {action.lower()} "
|
||
"again to stop."
|
||
)
|
||
else:
|
||
self.shortcut_row.set_subtitle("Double-tap this key to start/stop voice typing")
|
||
self.shortcut_info_label.set_text(
|
||
"In Toggle mode: Double-tap the key to start voice typing, "
|
||
"double-tap again to stop."
|
||
)
|
||
elif mode == "push_to_talk":
|
||
self.shortcut_row.set_subtitle("Hold this shortcut to speak, release to stop")
|
||
self.shortcut_info_label.set_text(
|
||
"In Push-to-Talk mode: Hold the shortcut down to speak, release to stop recording."
|
||
)
|
||
|
||
def _on_shortcut_mode_changed(self, widget):
|
||
"""Handle shortcut mode selection change."""
|
||
if self._initializing:
|
||
return
|
||
|
||
mode_id = self.shortcut_mode_combo.get_active_id()
|
||
if not mode_id:
|
||
return
|
||
|
||
# Save to config
|
||
self.config_manager.set("shortcuts", "mode", mode_id)
|
||
self.config_manager.save_settings()
|
||
|
||
mode_name = SHORTCUT_MODES.get(mode_id, mode_id)
|
||
logger.info(f"Keyboard shortcut mode changed to: {mode_name}")
|
||
|
||
# Update UI to reflect new mode
|
||
self._update_shortcut_ui_for_mode(mode_id)
|
||
|
||
# Apply from saved config (source of truth for both preset and custom).
|
||
if self.shortcut_update_callback:
|
||
shortcut_id = self.config_manager.get_str(
|
||
"shortcuts", "toggle_recognition", DEFAULT_SHORTCUT
|
||
)
|
||
success = self.shortcut_update_callback(shortcut_id, mode_id)
|
||
if success:
|
||
self.shortcut_info_label.set_markup(
|
||
f"<span foreground='#26a269'>Mode updated to <b>{mode_name}</b>. "
|
||
f"Active now!</span>"
|
||
)
|
||
else:
|
||
self.shortcut_info_label.set_markup(
|
||
f"<i>Mode updated to <b>{mode_name}</b>. "
|
||
f"Restart the app for the change to take full effect.</i>"
|
||
)
|
||
else:
|
||
self.shortcut_info_label.set_markup(
|
||
f"<i>Mode updated to <b>{mode_name}</b>. "
|
||
f"Restart the app for the change to take full effect.</i>"
|
||
)
|
||
|
||
def _revert_shortcut_combo_to_saved(self) -> None:
|
||
"""Restore combo, custom-row visibility, and info text to the saved shortcut.
|
||
|
||
Used when the user clicks a group separator in the shortcut combo.
|
||
Must sync the custom row too: selecting Custom Shortcut reveals
|
||
Record/Set, and a separator click must not leave those controls
|
||
visible while a preset is still the active binding. Also clear the
|
||
temporary Record/Set hint so the mode description matches the UI.
|
||
"""
|
||
current = self.config_manager.get_str("shortcuts", "toggle_recognition", DEFAULT_SHORTCUT)
|
||
self._sync_shortcut_selection_ui(current)
|
||
mode_id = self.shortcut_mode_combo.get_active_id() or DEFAULT_SHORTCUT_MODE
|
||
self._update_shortcut_ui_for_mode(mode_id)
|
||
|
||
def _on_shortcut_changed(self, widget):
|
||
"""Handle shortcut selection change."""
|
||
if self._initializing:
|
||
return
|
||
|
||
shortcut_id = self.shortcut_combo.get_active_id()
|
||
if not shortcut_id:
|
||
return
|
||
|
||
# Ignore separator entries (used for grouped display)
|
||
if shortcut_id.startswith("__separator_"):
|
||
self._revert_shortcut_combo_to_saved()
|
||
return
|
||
|
||
# Selecting "Custom Shortcut" does not change the active binding until
|
||
# the user records/sets one; just focus the custom entry for convenience.
|
||
if shortcut_id == "__custom__":
|
||
current = self.config_manager.get_str(
|
||
"shortcuts", "toggle_recognition", DEFAULT_SHORTCUT
|
||
)
|
||
if not self._is_preset_shortcut(current):
|
||
self.custom_shortcut_entry.set_text(current)
|
||
self._set_custom_shortcut_row_visible(True)
|
||
self.custom_shortcut_entry.grab_focus()
|
||
self.shortcut_info_label.set_markup(
|
||
"<i>Record or type a custom shortcut (e.g. alt+r or f10), then click Set.</i>"
|
||
)
|
||
return
|
||
|
||
# Preset selected: clear any leftover custom entry so UI matches config.
|
||
self.custom_shortcut_entry.set_text("")
|
||
self._set_custom_shortcut_row_visible(False)
|
||
self.config_manager.set("shortcuts", "toggle_recognition", shortcut_id)
|
||
self.config_manager.save_settings()
|
||
|
||
display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut_id, shortcut_id)
|
||
logger.info(f"Keyboard shortcut changed to: {display_name}")
|
||
|
||
applied = False
|
||
if self.shortcut_update_callback:
|
||
mode_id = self.shortcut_mode_combo.get_active_id()
|
||
applied = bool(self.shortcut_update_callback(shortcut_id, mode_id))
|
||
self._report_shortcut_apply_result(display_name, applied)
|
||
|
||
def _build_sidebar_footer(self, sidebar_box: Gtk.Box):
|
||
"""Build the sidebar footer: test output, status, test action, and Close.
|
||
|
||
Always visible regardless of the selected page: recognition state,
|
||
live microphone level, a dictation test button, and the dialog's
|
||
in-window Close button. Test output leads the footer so the revealed
|
||
box grows into the empty sidebar space above the controls instead of
|
||
pushing them down (#677).
|
||
"""
|
||
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||
separator.set_margin_start(8)
|
||
separator.set_margin_end(8)
|
||
sidebar_box.pack_start(separator, False, False, 0)
|
||
|
||
footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||
footer.get_style_context().add_class("sidebar-footer")
|
||
|
||
# Test transcription output, revealed while testing
|
||
self.test_output_revealer = Gtk.Revealer()
|
||
self.test_output_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
|
||
|
||
scrolled_window = Gtk.ScrolledWindow()
|
||
scrolled_window.set_min_content_height(60)
|
||
scrolled_window.set_max_content_height(100)
|
||
scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||
# GTK3 default IN shadow draws a left etched stripe on GNOME/Wayland (#847).
|
||
scrolled_window.set_shadow_type(Gtk.ShadowType.NONE)
|
||
scrolled_window.get_style_context().add_class("test-area")
|
||
|
||
self.test_textview = Gtk.TextView()
|
||
self.test_textview.set_editable(False)
|
||
self.test_textview.set_cursor_visible(False)
|
||
self.test_textview.set_wrap_mode(Gtk.WrapMode.WORD)
|
||
self.test_textview.get_style_context().add_class("test-textview")
|
||
self.test_buffer = self.test_textview.get_buffer()
|
||
scrolled_window.add(self.test_textview)
|
||
self.test_output_revealer.add(scrolled_window)
|
||
|
||
footer.pack_start(self.test_output_revealer, False, False, 0)
|
||
|
||
status_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
|
||
self.recognition_indicator = Gtk.Image.new_from_icon_name(
|
||
"media-record-symbolic", Gtk.IconSize.MENU
|
||
)
|
||
self.recognition_indicator.set_opacity(0.3)
|
||
status_row.pack_start(self.recognition_indicator, False, False, 0)
|
||
|
||
self.recognition_status_label = Gtk.Label(label="Idle", xalign=0)
|
||
self.recognition_status_label.get_style_context().add_class("status-strip-state")
|
||
status_row.pack_start(self.recognition_status_label, False, False, 0)
|
||
footer.pack_start(status_row, False, False, 0)
|
||
|
||
# One shared level bar: recognition progress and the microphone test
|
||
# both report into it (recognition_audio_level is a legacy alias).
|
||
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
|
||
footer.pack_start(self.audio_level_bar, False, False, 0)
|
||
|
||
test_button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
|
||
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)
|
||
test_button_row.pack_start(self.test_button, False, False, 0)
|
||
|
||
self.test_cancel_button = Gtk.Button(label="Cancel")
|
||
self.test_cancel_button.set_tooltip_text("Stop the test and discard its result")
|
||
self.test_cancel_button.connect("clicked", self._on_test_cancel_clicked)
|
||
self.test_cancel_button.set_no_show_all(True)
|
||
self.test_cancel_button.hide()
|
||
test_button_row.pack_start(self.test_cancel_button, False, False, 0)
|
||
footer.pack_start(test_button_row, 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
|
||
footer.pack_start(self.progress_info_label, False, False, 0)
|
||
|
||
# Separate Close from the dictation-test controls so it reads as
|
||
# dialog chrome, not as part of Test Dictation (#651).
|
||
close_separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||
close_separator.set_margin_start(8)
|
||
close_separator.set_margin_end(8)
|
||
close_separator.set_margin_top(2)
|
||
close_separator.set_margin_bottom(2)
|
||
footer.pack_start(close_separator, False, False, 0)
|
||
|
||
# In-window Close so the dialog can always be dismissed, even on WMs
|
||
# that hide the title-bar close button for Gtk.Dialog windows (#323).
|
||
close_button = Gtk.Button(label="Close")
|
||
close_button.set_tooltip_text("Close settings (Ctrl+W)")
|
||
close_button.connect("clicked", self._on_close_clicked)
|
||
footer.pack_start(close_button, False, False, 0)
|
||
|
||
sidebar_box.pack_start(footer, False, False, 0)
|
||
|
||
def _on_close_clicked(self, button):
|
||
"""Close the dialog through the normal response path (same as title-bar X)."""
|
||
self.response(Gtk.ResponseType.CLOSE)
|
||
|
||
def _build_advanced_section(self):
|
||
"""Build the Advanced section with whisper.cpp parameters."""
|
||
|
||
# Opt-in toggle at the top of the tab
|
||
opt_in_group = PreferencesGroup(title="Advanced Access")
|
||
self.power_user_switch = _add_switch_row(
|
||
opt_in_group,
|
||
title="Unlock Advanced Settings",
|
||
subtitle="I know what I'm doing — show me the whisper.cpp tuning knobs",
|
||
tooltip="Reveal advanced whisper.cpp tuning parameters",
|
||
)
|
||
self.advanced_tab.pack_start(opt_in_group, False, False, 0)
|
||
|
||
# Revealer that hides/shows the actual advanced controls.
|
||
# No inner ScrolledWindow: the notebook tab already scrolls.
|
||
self.advanced_revealer = Gtk.Revealer()
|
||
self.advanced_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
|
||
|
||
controls_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||
|
||
group = PreferencesGroup(title="Whisper.cpp Decoding")
|
||
|
||
self.advanced_no_timestamps_switch = _add_switch_row(
|
||
group,
|
||
title="No Timestamps",
|
||
subtitle="Disable timestamp tokens (reduces hallucinations)",
|
||
tooltip="Disable timestamp generation to reduce hallucinations",
|
||
)
|
||
|
||
self.advanced_no_context_switch = _add_switch_row(
|
||
group,
|
||
title="No Context",
|
||
subtitle="Prevent error loops from past text",
|
||
tooltip="Do not condition on previously transcribed text",
|
||
)
|
||
|
||
self.advanced_temperature_spin = _new_spin(
|
||
0.0, 1.0, 0.1, 1, tooltip="0.0 = greedy decoding, higher = more random"
|
||
)
|
||
temperature_row = PreferenceRow(
|
||
title="Temperature",
|
||
subtitle="Decoding randomness (0.0 = deterministic)",
|
||
widget=self.advanced_temperature_spin,
|
||
)
|
||
group.add_row(temperature_row)
|
||
|
||
self.advanced_temperature_inc_spin = _new_spin(
|
||
-1.0, 1.0, 0.1, 1, tooltip="-1.0 disables temperature fallback entirely"
|
||
)
|
||
temperature_inc_row = PreferenceRow(
|
||
title="Temperature Increment",
|
||
subtitle="Fallback step (-1.0 = disabled)",
|
||
widget=self.advanced_temperature_inc_spin,
|
||
)
|
||
group.add_row(temperature_inc_row)
|
||
|
||
self.advanced_entropy_thold_spin = _new_spin(
|
||
0.0, 5.0, 0.1, 1, tooltip="Higher values catch more repetition loops"
|
||
)
|
||
entropy_row = PreferenceRow(
|
||
title="Entropy Threshold",
|
||
subtitle="Repetition loop detection",
|
||
widget=self.advanced_entropy_thold_spin,
|
||
)
|
||
group.add_row(entropy_row)
|
||
|
||
self.advanced_logprob_thold_spin = _new_spin(
|
||
-5.0, 0.0, 0.1, 1, tooltip="Average log-probability threshold for fallback"
|
||
)
|
||
logprob_row = PreferenceRow(
|
||
title="Logprob Threshold",
|
||
subtitle="Fallback trigger for low confidence",
|
||
widget=self.advanced_logprob_thold_spin,
|
||
)
|
||
group.add_row(logprob_row)
|
||
|
||
self.advanced_no_speech_thold_spin = _new_spin(
|
||
0.0, 1.0, 0.05, 2, tooltip="Probability threshold for treating audio as silence"
|
||
)
|
||
no_speech_row = PreferenceRow(
|
||
title="No-Speech Threshold",
|
||
subtitle="Silence detection confidence",
|
||
widget=self.advanced_no_speech_thold_spin,
|
||
)
|
||
group.add_row(no_speech_row)
|
||
|
||
candidates_help = (
|
||
"Optional comma-separated Whisper language codes for auto-detect, "
|
||
"such as en,es. Leave blank to consider all languages."
|
||
)
|
||
self.advanced_language_candidates_entry = Gtk.Entry()
|
||
self.advanced_language_candidates_entry.set_placeholder_text("en,es")
|
||
self.advanced_language_candidates_entry.set_tooltip_text(candidates_help)
|
||
language_candidates_row = PreferenceRow(
|
||
title="Language Candidates",
|
||
subtitle="Restrict auto-detect to selected language codes",
|
||
widget=self.advanced_language_candidates_entry,
|
||
)
|
||
language_candidates_row.set_tooltip_text(candidates_help)
|
||
group.add_row(language_candidates_row)
|
||
|
||
# Initial Prompt -- moved to the end and made multiline
|
||
initial_prompt_help = (
|
||
"Optional. Add names, jargon, punctuation style, or other context to bias "
|
||
"whisper.cpp transcription. Leave blank for normal dictation."
|
||
)
|
||
self.advanced_initial_prompt_textview = Gtk.TextView()
|
||
self.advanced_initial_prompt_textview.set_wrap_mode(Gtk.WrapMode.WORD)
|
||
self.advanced_initial_prompt_textview.set_tooltip_text(initial_prompt_help)
|
||
self.advanced_initial_prompt_textview.set_size_request(250, 80)
|
||
|
||
prompt_scrolled = Gtk.ScrolledWindow()
|
||
prompt_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
prompt_scrolled.set_min_content_height(80)
|
||
prompt_scrolled.set_tooltip_text(initial_prompt_help)
|
||
prompt_scrolled.add(self.advanced_initial_prompt_textview)
|
||
|
||
initial_prompt_row = PreferenceRow(
|
||
title="Initial Prompt",
|
||
subtitle="Context to steer transcription style",
|
||
widget=prompt_scrolled,
|
||
)
|
||
# A ListBoxRow grabs focus on click, which steals it back from the
|
||
# TextView before typing can start. The row is never activatable,
|
||
# so it does not need keyboard focus of its own.
|
||
initial_prompt_row.set_can_focus(False)
|
||
initial_prompt_row.set_tooltip_text(initial_prompt_help)
|
||
group.add_row(initial_prompt_row)
|
||
|
||
info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
info_box.get_style_context().add_class("info-box")
|
||
info_box.set_margin_start(4)
|
||
info_box.set_margin_end(4)
|
||
info_box.set_margin_bottom(4)
|
||
|
||
info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
|
||
info_box.pack_start(info_icon, False, False, 0)
|
||
|
||
self.advanced_info_label = Gtk.Label(
|
||
label="These settings only apply when the whisper.cpp engine is selected.",
|
||
xalign=0,
|
||
wrap=True,
|
||
)
|
||
self.advanced_info_label.get_style_context().add_class("tip-label")
|
||
info_box.pack_start(self.advanced_info_label, True, True, 0)
|
||
|
||
controls_box.pack_start(info_box, False, False, 0)
|
||
controls_box.pack_start(group, False, False, 0)
|
||
|
||
# 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"
|
||
)
|
||
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)
|
||
|
||
self.advanced_no_timestamps_switch.connect("state-set", self._on_advanced_param_changed)
|
||
self.advanced_no_context_switch.connect("state-set", self._on_advanced_param_changed)
|
||
self.advanced_temperature_spin.connect("value-changed", self._on_advanced_param_changed)
|
||
self.advanced_temperature_inc_spin.connect("value-changed", self._on_advanced_param_changed)
|
||
self.advanced_entropy_thold_spin.connect("value-changed", self._on_advanced_param_changed)
|
||
self.advanced_logprob_thold_spin.connect("value-changed", self._on_advanced_param_changed)
|
||
self.advanced_no_speech_thold_spin.connect("value-changed", self._on_advanced_param_changed)
|
||
self.advanced_language_candidates_entry.connect(
|
||
"changed", self._on_language_candidates_changed
|
||
)
|
||
|
||
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.power_user_switch.connect("state-set", self._on_power_user_toggled)
|
||
|
||
def _build_proxy_section(self) -> None:
|
||
"""Build the Proxy page: Off / System / Manual modes (#655).
|
||
|
||
The mode applies to model downloads and update checks. The manual
|
||
rows stay visible but insensitive unless the mode is Manual.
|
||
"""
|
||
self.proxy_group = PreferencesGroup(
|
||
title="Proxy",
|
||
description=(
|
||
"Route model downloads and update checks through a proxy. "
|
||
"Useful in regions where the model hosts are unreachable."
|
||
),
|
||
keywords=("proxy", "network", "socks5", "https"),
|
||
)
|
||
|
||
self.proxy_mode_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.proxy_mode_combo)
|
||
_combo_chrome(
|
||
self.proxy_mode_combo,
|
||
tooltip="How Voculinux connects to model hosts and update checks",
|
||
)
|
||
self.proxy_mode_combo.append("off", "Off")
|
||
self.proxy_mode_combo.append("system", "System")
|
||
self.proxy_mode_combo.append("manual", "Manual")
|
||
mode_row = PreferenceRow(
|
||
title="Mode",
|
||
subtitle=(
|
||
"System uses the *_proxy environment variables and the GNOME "
|
||
"proxy settings; Manual uses the server configured below"
|
||
),
|
||
widget=self.proxy_mode_combo,
|
||
keywords=("mode",),
|
||
)
|
||
self.proxy_group.add_row(mode_row)
|
||
self.proxy_tab.pack_start(self.proxy_group, False, False, 0)
|
||
|
||
self.proxy_manual_group = PreferencesGroup(
|
||
title="Manual Proxy",
|
||
description="Applied when the mode above is set to Manual.",
|
||
)
|
||
|
||
self.proxy_protocol_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.proxy_protocol_combo)
|
||
_combo_chrome(
|
||
self.proxy_protocol_combo,
|
||
tooltip="SOCKS5 or an HTTPS (HTTP CONNECT) proxy",
|
||
)
|
||
self.proxy_protocol_combo.append("socks5", "SOCKS5")
|
||
self.proxy_protocol_combo.append("https", "HTTPS")
|
||
protocol_row = PreferenceRow(
|
||
title="Protocol",
|
||
subtitle="SOCKS5 or an HTTPS (HTTP CONNECT) proxy",
|
||
widget=self.proxy_protocol_combo,
|
||
keywords=("protocol", "socks5", "https"),
|
||
)
|
||
self.proxy_manual_group.add_row(protocol_row)
|
||
|
||
self.proxy_host_entry = Gtk.Entry()
|
||
self.proxy_host_entry.set_placeholder_text("127.0.0.1 or proxy.example.com")
|
||
self.proxy_host_entry.set_tooltip_text("Proxy server hostname or IP address")
|
||
self.proxy_host_entry.set_size_request(_CONTROL_WIDTH, -1)
|
||
host_row = PreferenceRow(
|
||
title="Host",
|
||
subtitle="Proxy server hostname or IP address",
|
||
widget=self.proxy_host_entry,
|
||
keywords=("host", "server"),
|
||
)
|
||
self.proxy_manual_group.add_row(host_row)
|
||
|
||
self.proxy_port_spin = _new_spin(
|
||
1,
|
||
65535,
|
||
1,
|
||
0,
|
||
tooltip="Proxy server port (SOCKS5 default 1080, HTTPS often 8080)",
|
||
)
|
||
port_row = PreferenceRow(
|
||
title="Port",
|
||
subtitle="Proxy server port",
|
||
widget=self.proxy_port_spin,
|
||
keywords=("port",),
|
||
)
|
||
self.proxy_manual_group.add_row(port_row)
|
||
|
||
self.proxy_username_entry = Gtk.Entry()
|
||
self.proxy_username_entry.set_placeholder_text("(optional)")
|
||
self.proxy_username_entry.set_tooltip_text("Proxy username (optional)")
|
||
self.proxy_username_entry.set_size_request(_CONTROL_WIDTH, -1)
|
||
username_row = PreferenceRow(
|
||
title="Username",
|
||
subtitle="Proxy username (optional)",
|
||
widget=self.proxy_username_entry,
|
||
keywords=("username", "auth"),
|
||
)
|
||
self.proxy_manual_group.add_row(username_row)
|
||
|
||
self.proxy_password_entry = Gtk.Entry()
|
||
self.proxy_password_entry.set_placeholder_text("(optional)")
|
||
self.proxy_password_entry.set_visibility(False)
|
||
self.proxy_password_entry.set_tooltip_text(
|
||
"Proxy password (optional); stored in config.json in plaintext, "
|
||
"the same as the remote API key"
|
||
)
|
||
self.proxy_password_entry.set_size_request(_CONTROL_WIDTH, -1)
|
||
password_row = PreferenceRow(
|
||
title="Password",
|
||
subtitle="Proxy password (optional)",
|
||
widget=self.proxy_password_entry,
|
||
keywords=("password", "auth"),
|
||
)
|
||
self.proxy_manual_group.add_row(password_row)
|
||
|
||
self.proxy_test_btn = Gtk.Button(label="Test Connection")
|
||
self.proxy_test_btn.set_tooltip_text(
|
||
"Check that the Hugging Face model host is reachable with these settings"
|
||
)
|
||
self.proxy_test_btn.connect("clicked", self._on_test_proxy_connection)
|
||
test_row = PreferenceRow(
|
||
title="Connection Test",
|
||
subtitle="Verify the Hugging Face model host is reachable",
|
||
widget=self.proxy_test_btn,
|
||
keywords=("test", "connection"),
|
||
)
|
||
self.proxy_manual_group.add_row(test_row)
|
||
self.proxy_tab.pack_start(self.proxy_manual_group, False, False, 0)
|
||
|
||
# Status line under the groups, matching the remote server section
|
||
self.proxy_status_label = Gtk.Label(label="", use_markup=True, xalign=0)
|
||
self.proxy_status_label.set_margin_start(16)
|
||
self.proxy_status_label.set_margin_top(4)
|
||
self.proxy_status_label.get_style_context().add_class("status-info")
|
||
self.proxy_tab.pack_start(self.proxy_status_label, False, False, 0)
|
||
|
||
# Load saved values into the widgets
|
||
proxy_settings = self.config_manager.get_settings().get("proxy", {})
|
||
saved_mode = normalize_proxy_mode(proxy_settings.get("mode"))
|
||
if not self.proxy_mode_combo.set_active_id(saved_mode):
|
||
self.proxy_mode_combo.set_active_id(DEFAULT_PROXY_MODE)
|
||
saved_protocol = normalize_proxy_protocol(proxy_settings.get("protocol"))
|
||
if not self.proxy_protocol_combo.set_active_id(saved_protocol):
|
||
self.proxy_protocol_combo.set_active_id(DEFAULT_PROXY_PROTOCOL)
|
||
saved_host = proxy_settings.get("host", "")
|
||
if saved_host:
|
||
self.proxy_host_entry.set_text(str(saved_host))
|
||
try:
|
||
saved_port = int(proxy_settings.get("port") or 0)
|
||
except (TypeError, ValueError):
|
||
saved_port = 0
|
||
if saved_port < 1 or saved_port > 65535:
|
||
saved_port = default_port_for_protocol(saved_protocol)
|
||
self.proxy_port_spin.set_value(saved_port)
|
||
saved_username = proxy_settings.get("username", "")
|
||
if saved_username:
|
||
self.proxy_username_entry.set_text(str(saved_username))
|
||
saved_password = proxy_settings.get("password", "")
|
||
if saved_password:
|
||
self.proxy_password_entry.set_text(str(saved_password))
|
||
|
||
self.proxy_mode_combo.connect("changed", self._on_proxy_mode_changed)
|
||
self.proxy_protocol_combo.connect("changed", self._on_proxy_settings_changed)
|
||
self.proxy_host_entry.connect("changed", self._on_proxy_settings_changed)
|
||
self.proxy_port_spin.connect("value-changed", self._on_proxy_settings_changed)
|
||
self.proxy_username_entry.connect("changed", self._on_proxy_settings_changed)
|
||
self.proxy_password_entry.connect("changed", self._on_proxy_settings_changed)
|
||
|
||
self._update_proxy_sensitivity()
|
||
|
||
def _on_proxy_mode_changed(self, widget: Gtk.Widget) -> None:
|
||
"""Persist the proxy mode and gate the manual rows on it."""
|
||
if _handlers_suppressed(self):
|
||
return
|
||
self._save_proxy_settings()
|
||
self._update_proxy_sensitivity()
|
||
|
||
def _on_proxy_settings_changed(self, widget: Gtk.Widget) -> None:
|
||
"""Persist the manual proxy fields as they are edited."""
|
||
if _handlers_suppressed(self):
|
||
return
|
||
self._save_proxy_settings()
|
||
|
||
def _save_proxy_settings(self) -> None:
|
||
"""Write the proxy widgets to the ``proxy`` config section."""
|
||
self.config_manager.set(
|
||
"proxy", "mode", self.proxy_mode_combo.get_active_id() or DEFAULT_PROXY_MODE
|
||
)
|
||
self.config_manager.set(
|
||
"proxy",
|
||
"protocol",
|
||
self.proxy_protocol_combo.get_active_id() or DEFAULT_PROXY_PROTOCOL,
|
||
)
|
||
self.config_manager.set("proxy", "host", self.proxy_host_entry.get_text().strip())
|
||
self.config_manager.set("proxy", "port", int(self.proxy_port_spin.get_value()))
|
||
self.config_manager.set("proxy", "username", self.proxy_username_entry.get_text().strip())
|
||
self.config_manager.set("proxy", "password", self.proxy_password_entry.get_text())
|
||
self.config_manager.save_config()
|
||
|
||
def _update_proxy_sensitivity(self) -> None:
|
||
"""Gray out the manual fields unless the mode is Manual."""
|
||
manual = (self.proxy_mode_combo.get_active_id() or DEFAULT_PROXY_MODE) == "manual"
|
||
self.proxy_manual_group.set_sensitive(manual)
|
||
|
||
def _on_test_proxy_connection(self, widget: Gtk.Widget) -> None:
|
||
"""Fetch the Hugging Face model repo through the configured proxy."""
|
||
self.proxy_test_btn.set_sensitive(False)
|
||
self.proxy_test_btn.set_label("Testing...")
|
||
self.proxy_status_label.set_markup("<i>Connecting...</i>")
|
||
|
||
config = {
|
||
"mode": self.proxy_mode_combo.get_active_id() or DEFAULT_PROXY_MODE,
|
||
"protocol": self.proxy_protocol_combo.get_active_id() or DEFAULT_PROXY_PROTOCOL,
|
||
"host": self.proxy_host_entry.get_text().strip(),
|
||
"port": int(self.proxy_port_spin.get_value()),
|
||
"username": self.proxy_username_entry.get_text().strip(),
|
||
"password": self.proxy_password_entry.get_text(),
|
||
}
|
||
|
||
def test_connection(config: dict = config) -> None:
|
||
import requests
|
||
|
||
try:
|
||
response = requests.get(
|
||
"https://huggingface.co/api/models/ggerganov/whisper.cpp",
|
||
headers={"User-Agent": user_agent(__version__)},
|
||
timeout=10,
|
||
proxies=requests_proxies(config),
|
||
)
|
||
GLib.idle_add(
|
||
self.proxy_status_label.set_markup,
|
||
"<span foreground='#26a269'>✓ huggingface.co reachable "
|
||
f"(status={response.status_code})</span>",
|
||
)
|
||
except requests.RequestException as exc:
|
||
logger.warning("Proxy connection test against huggingface.co failed: %s", exc)
|
||
error_msg = GLib.markup_escape_text(str(exc)[:80])
|
||
GLib.idle_add(
|
||
self.proxy_status_label.set_markup,
|
||
f"<span foreground='#c01c28'>✗ Connection failed: {error_msg}</span>",
|
||
)
|
||
|
||
GLib.idle_add(self.proxy_test_btn.set_sensitive, True)
|
||
GLib.idle_add(self.proxy_test_btn.set_label, "Test Connection")
|
||
|
||
threading.Thread(target=test_connection, daemon=True).start()
|
||
|
||
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)
|
||
if about_page is None or about_page.update_badge_label is None:
|
||
return
|
||
# Don't fight the search filter's match-count badges.
|
||
searching = bool(self.search_entry.get_text().strip())
|
||
if visible and not searching:
|
||
about_page.update_badge_label.show()
|
||
else:
|
||
about_page.update_badge_label.hide()
|
||
|
||
def _seed_pending_update_ui(self) -> None:
|
||
"""Apply a tray-discovered update to the About page without another fetch."""
|
||
if self._pending_update is None:
|
||
return
|
||
# Avoid an immediate re-check flicker; user can still press Check.
|
||
self._update_auto_checked = True
|
||
self._apply_update_check_result(
|
||
self._pending_update,
|
||
channel=self._current_update_channel(),
|
||
generation=None,
|
||
)
|
||
|
||
def _current_update_channel(self) -> str:
|
||
"""Return the selected update channel id."""
|
||
channel_id = self.update_channel_combo.get_active_id()
|
||
return normalize_channel(channel_id or DEFAULT_UPDATE_CHANNEL)
|
||
|
||
def _on_update_checks_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||
"""Persist automatic update-check preference and notify the tray."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
self.config_manager.set("updates", "check_automatically", enabled)
|
||
self.config_manager.save_settings()
|
||
if self.update_checks_callback is not None:
|
||
try:
|
||
self.update_checks_callback(enabled)
|
||
except Exception:
|
||
logger.error("Automatic update checks callback failed", exc_info=True)
|
||
return False
|
||
|
||
def _on_update_channel_changed(self, widget: Gtk.ComboBoxText) -> None:
|
||
"""Refresh the manual update lookup for the selected channel."""
|
||
if _handlers_suppressed(self):
|
||
return
|
||
self._start_update_check()
|
||
|
||
def _on_check_updates_clicked(self, widget):
|
||
"""Manual re-check from the About page."""
|
||
self._start_update_check()
|
||
|
||
def _on_open_release_clicked(self, widget):
|
||
"""Open the latest or pending release page in the browser."""
|
||
self._open_web_url(self._about_release_url)
|
||
|
||
def _start_update_check(self):
|
||
"""Kick off a background release lookup, or explain packaged updates."""
|
||
channel = self._current_update_channel()
|
||
if is_packaged_install():
|
||
# Packaged installs never query a release server (ticket #27).
|
||
self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT)
|
||
return
|
||
self._update_check_generation += 1
|
||
generation = self._update_check_generation
|
||
self.check_updates_btn.set_sensitive(False)
|
||
self.check_updates_btn.set_label("…")
|
||
self.update_status_row.set_subtitle(f"Checking Voculinux releases ({channel})…")
|
||
|
||
# Invalidate any in-flight result; that worker will restart on completion.
|
||
if self._update_check_in_progress:
|
||
return
|
||
|
||
self._update_check_in_progress = True
|
||
threading.Thread(
|
||
target=self._update_check_worker,
|
||
args=(channel, generation),
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _update_check_worker(self, channel: str, generation: int):
|
||
"""Worker thread: fetch latest release for ``channel`` and compare versions."""
|
||
release = fetch_latest_release(channel=channel)
|
||
GLib.idle_add(self._apply_update_check_result, release, channel, generation)
|
||
|
||
def _dialog_is_alive(self) -> bool:
|
||
"""Return False when the settings dialog has been destroyed."""
|
||
try:
|
||
# During __init__ (before map) widgets exist but are not realized yet.
|
||
if self._initializing:
|
||
return True
|
||
return bool(self.get_realized())
|
||
except Exception:
|
||
return False
|
||
|
||
def _apply_update_check_result(
|
||
self,
|
||
release: Optional[ReleaseInfo],
|
||
channel: Optional[str] = None,
|
||
generation: Optional[int] = None,
|
||
) -> bool:
|
||
"""Update About-page UI from a release lookup result."""
|
||
if not self._dialog_is_alive():
|
||
return False
|
||
|
||
channel = normalize_channel(channel or self._current_update_channel())
|
||
current_channel = self._current_update_channel()
|
||
# Drop stale results after a channel switch (or a newer check request).
|
||
if generation is not None and generation != self._update_check_generation:
|
||
self._update_check_in_progress = False
|
||
self._start_update_check()
|
||
return False
|
||
if channel != current_channel:
|
||
self._update_check_in_progress = False
|
||
self._start_update_check()
|
||
return False
|
||
|
||
self._update_check_in_progress = False
|
||
self.check_updates_btn.set_sensitive(True)
|
||
self.check_updates_btn.set_label("Check")
|
||
|
||
if release is None:
|
||
self._about_release_url = __url__
|
||
missing = "nightly build" if channel == "nightly" else "stable release"
|
||
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 Voculinux project page")
|
||
self.open_release_btn.get_style_context().remove_class("suggested-action")
|
||
self.release_notes_group.hide()
|
||
# Clear dialog pending state so search restore cannot revive the New
|
||
# badge while About still shows the failed-lookup message. Do not
|
||
# call update_status_callback — tray state stays as-is (same as
|
||
# UpdateMonitor on a failed lookup).
|
||
self._pending_update = None
|
||
self._set_about_update_badge(False)
|
||
return False
|
||
|
||
self._about_release_url = (
|
||
release.html_url if is_trusted_release_url(release.html_url) else __url__
|
||
)
|
||
update_available = is_update_available(__version__, release, channel)
|
||
release_label = release.tag_name
|
||
if release.published_at:
|
||
release_label = f"{release.tag_name} · {release.published_at[:10]}"
|
||
if channel == "nightly" and release.prerelease:
|
||
release_label = f"{release_label} (nightly)"
|
||
|
||
if update_available:
|
||
self._pending_update = release
|
||
self.update_status_row.set_subtitle(
|
||
f"Update available on {channel} (running {__version__})"
|
||
)
|
||
self.open_release_btn.get_style_context().add_class("suggested-action")
|
||
self._set_about_update_badge(True)
|
||
else:
|
||
self._pending_update = None
|
||
self.update_status_row.set_subtitle(f"Up to date on {channel}")
|
||
self.open_release_btn.get_style_context().remove_class("suggested-action")
|
||
self._set_about_update_badge(False)
|
||
|
||
# Keep the tray menu in sync with an About-page check (no extra notification).
|
||
if (
|
||
channel == build_channel()
|
||
and self.config_manager.get_bool(
|
||
"updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY
|
||
)
|
||
and self.update_status_callback is not None
|
||
):
|
||
try:
|
||
self.update_status_callback(update_available, release if update_available else None)
|
||
except Exception:
|
||
logger.error("Update status callback failed", exc_info=True)
|
||
|
||
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"
|
||
)
|
||
|
||
notes = format_release_notes(release.body)
|
||
# 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
|
||
|
||
def _gpu_acceleration_subtitle(self) -> str:
|
||
"""Describe whether the bundled pywhispercpp can use the GPU picker."""
|
||
if running_in_snap():
|
||
return SNAP_GPU_NOTE
|
||
try:
|
||
from ..speech_recognition.recognition_manager import detect_pywhispercpp_gpu_backend
|
||
|
||
bundled = detect_pywhispercpp_gpu_backend()
|
||
except (ImportError, OSError, AttributeError):
|
||
return "GPU used by whisper.cpp"
|
||
if bundled == "cpu":
|
||
return "This install has no GPU libraries, so whisper.cpp runs on the CPU"
|
||
if bundled == "cuda":
|
||
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.
|
||
|
||
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()
|
||
_style_combo(self.gpu_device_combo)
|
||
_combo_chrome(
|
||
self.gpu_device_combo,
|
||
tooltip=(
|
||
"Select which GPU to use for whisper.cpp Vulkan acceleration. "
|
||
"Has no effect when pywhispercpp was built without GPU libraries."
|
||
),
|
||
)
|
||
self._populate_gpu_devices()
|
||
gpu_row = PreferenceRow(
|
||
title="Vulkan GPU",
|
||
subtitle=self._gpu_acceleration_subtitle(),
|
||
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_post_processing_section(self) -> None:
|
||
"""Build the Post-Processing section."""
|
||
group = PreferencesGroup(
|
||
title="Post-Processing Script",
|
||
description=(
|
||
"Run an executable on each transcription result. "
|
||
"The script receives the text on stdin and must write the replacement text to stdout."
|
||
),
|
||
)
|
||
|
||
path_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
self.post_processor_entry = Gtk.Entry()
|
||
self.post_processor_entry.set_placeholder_text("/path/to/script.sh")
|
||
self.post_processor_entry.set_tooltip_text(
|
||
"Path to an executable that transforms transcribed text"
|
||
)
|
||
self.post_processor_entry.set_hexpand(True)
|
||
|
||
browse_button = Gtk.Button(label="Browse…")
|
||
browse_button.connect("clicked", self._on_post_processor_browse_clicked)
|
||
|
||
path_box.pack_start(self.post_processor_entry, True, True, 0)
|
||
path_box.pack_start(browse_button, False, False, 0)
|
||
|
||
script_row = PreferenceRow(
|
||
title="Script Path",
|
||
subtitle="Leave empty to disable post-processing",
|
||
widget=path_box,
|
||
)
|
||
group.add_row(script_row)
|
||
self.post_processing_tab.pack_start(group, False, False, 0)
|
||
|
||
self.post_processor_entry.connect("changed", self._on_post_processor_script_changed)
|
||
|
||
def _on_post_processor_browse_clicked(self, widget: Gtk.Button) -> None:
|
||
dialog = Gtk.FileChooserDialog(
|
||
title="Select Post-Processing Script",
|
||
parent=self,
|
||
action=Gtk.FileChooserAction.OPEN,
|
||
)
|
||
dialog.add_buttons(
|
||
Gtk.STOCK_CANCEL,
|
||
Gtk.ResponseType.CANCEL,
|
||
Gtk.STOCK_OPEN,
|
||
Gtk.ResponseType.OK,
|
||
)
|
||
if dialog.run() == Gtk.ResponseType.OK:
|
||
self.post_processor_entry.set_text(dialog.get_filename())
|
||
dialog.destroy()
|
||
|
||
def _on_post_processor_script_changed(self, widget: Gtk.Entry) -> None:
|
||
if self._initializing or self._applying_settings:
|
||
return
|
||
path = widget.get_text().strip()
|
||
self.config_manager.set("post_processing", "script_path", path)
|
||
self.config_manager.save_config()
|
||
|
||
def _build_remote_server_section(self):
|
||
"""Build the Remote Server configuration section (shown when Remote API engine is selected)."""
|
||
self.remote_server_group = PreferencesGroup(
|
||
title="Remote Server",
|
||
description=(
|
||
"Offload speech recognition to a server on your network. "
|
||
"Supports whisper.cpp and OpenAI-compatible APIs."
|
||
),
|
||
)
|
||
|
||
# Server URL
|
||
self.remote_api_url_entry = Gtk.Entry()
|
||
self.remote_api_url_entry.set_placeholder_text("http://192.168.1.100:8080")
|
||
self.remote_api_url_entry.set_tooltip_text(
|
||
"URL of the remote speech recognition server\n"
|
||
"Supports OpenAI compatible API and whisper.cpp server"
|
||
)
|
||
self.remote_api_url_entry.set_size_request(_CONTROL_WIDTH, -1)
|
||
remote_url_row = PreferenceRow(
|
||
title="Server URL",
|
||
subtitle="Remote speech recognition server address",
|
||
widget=self.remote_api_url_entry,
|
||
)
|
||
self.remote_server_group.add_row(remote_url_row)
|
||
|
||
# API Key
|
||
self.remote_api_key_entry = Gtk.Entry()
|
||
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(_CONTROL_WIDTH, -1)
|
||
remote_key_row = PreferenceRow(
|
||
title="API Key",
|
||
subtitle="Authentication key (optional)",
|
||
widget=self.remote_api_key_entry,
|
||
)
|
||
self.remote_server_group.add_row(remote_key_row)
|
||
|
||
# API Endpoint
|
||
self.remote_api_endpoint_combo = Gtk.ComboBoxText()
|
||
_style_combo(self.remote_api_endpoint_combo)
|
||
_combo_chrome(
|
||
self.remote_api_endpoint_combo,
|
||
tooltip="Select the API format of the remote server (API Endpoint Format)",
|
||
)
|
||
self.remote_api_endpoint_combo.append(
|
||
"/v1/audio/transcriptions", "OpenAI/FunASR (/v1/audio/transcriptions)"
|
||
)
|
||
self.remote_api_endpoint_combo.append("/inference", "Whisper.cpp (/inference)")
|
||
remote_endpoint_row = PreferenceRow(
|
||
title="API Endpoint",
|
||
subtitle="API format for the remote server",
|
||
widget=self.remote_api_endpoint_combo,
|
||
)
|
||
self.remote_server_group.add_row(remote_endpoint_row)
|
||
|
||
# Model name
|
||
self.remote_api_model_entry = Gtk.Entry()
|
||
self.remote_api_model_entry.set_placeholder_text("whisper-1")
|
||
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(_CONTROL_WIDTH, -1)
|
||
remote_model_row = PreferenceRow(
|
||
title="Model",
|
||
subtitle="Remote model name, for example whisper-1 or sensevoice",
|
||
widget=self.remote_api_model_entry,
|
||
)
|
||
self.remote_server_group.add_row(remote_model_row)
|
||
|
||
# Connection test
|
||
self.remote_test_btn = Gtk.Button(label="Test Connection")
|
||
self.remote_test_btn.set_tooltip_text("Test connection to remote server")
|
||
self.remote_test_btn.connect("clicked", self._on_test_remote_connection)
|
||
remote_test_row = PreferenceRow(
|
||
title="Connection Test",
|
||
subtitle="Verify remote server is reachable",
|
||
widget=self.remote_test_btn,
|
||
)
|
||
self.remote_server_group.add_row(remote_test_row)
|
||
|
||
self.advanced_box.pack_start(self.remote_server_group, False, False, 0)
|
||
|
||
# Status label below the group
|
||
self.remote_status_label = Gtk.Label(label="", use_markup=True, xalign=0)
|
||
self.remote_status_label.set_margin_start(16)
|
||
self.remote_status_label.set_margin_top(4)
|
||
self.remote_status_label.get_style_context().add_class("status-info")
|
||
self.advanced_box.pack_start(self.remote_status_label, False, False, 0)
|
||
|
||
# Load saved values into the widgets
|
||
saved_url = self.config_manager.get("speech_recognition", "remote_api_url", "")
|
||
saved_key = self.config_manager.get("speech_recognition", "remote_api_key", "")
|
||
saved_endpoint = self.config_manager.get(
|
||
"speech_recognition", "remote_api_endpoint", "/inference"
|
||
)
|
||
saved_model = self.config_manager.get("speech_recognition", "remote_api_model", "whisper-1")
|
||
if saved_url:
|
||
self.remote_api_url_entry.set_text(saved_url)
|
||
if saved_key:
|
||
self.remote_api_key_entry.set_text(saved_key)
|
||
self.remote_api_endpoint_combo.set_active_id(saved_endpoint)
|
||
self.remote_api_model_entry.set_text(saved_model or "whisper-1")
|
||
|
||
self.remote_api_url_entry.connect("changed", self._on_remote_api_settings_changed)
|
||
self.remote_api_key_entry.connect("changed", self._on_remote_api_settings_changed)
|
||
self.remote_api_endpoint_combo.connect("changed", self._on_remote_api_settings_changed)
|
||
self.remote_api_model_entry.connect("changed", self._on_remote_api_settings_changed)
|
||
|
||
self.remote_server_group.hide()
|
||
self.remote_status_label.hide()
|
||
|
||
def _build_gateway_embed_section(self) -> None:
|
||
"""Optional local VocaGateway controls (podman-first). Lives on the Advanced island."""
|
||
self._gateway_manager = get_gateway_embed_manager()
|
||
# Do not clobber LAN restored from leftover compose (orphan adopt).
|
||
if not self._gateway_manager.managed_by_us:
|
||
self._gateway_manager.lan_publish = bool(
|
||
self.config_manager.get("gateway_embed", "lan_publish", False)
|
||
)
|
||
lan_publish = bool(self._gateway_manager.lan_publish)
|
||
|
||
self.gateway_embed_group = PreferencesGroup(
|
||
title="Local VocaGateway",
|
||
description=(
|
||
"Optional self-hosted gateway via podman (or docker). "
|
||
"Audio goes to that container on this machine; it is not on-device "
|
||
"whisper.cpp. Pinned to VocaGateway v0.1.0."
|
||
),
|
||
keywords=(
|
||
"vocagateway",
|
||
"gateway",
|
||
"podman",
|
||
"docker",
|
||
"pairing",
|
||
"phone",
|
||
"remote",
|
||
),
|
||
)
|
||
|
||
self.gateway_run_btn = Gtk.Button(label="Run VocaGateway locally")
|
||
self.gateway_run_btn.set_size_request(_CONTROL_WIDTH, -1)
|
||
self.gateway_run_btn.connect("clicked", self._on_gateway_run_clicked)
|
||
self.gateway_embed_group.add_row(
|
||
PreferenceRow(
|
||
title="Local gateway",
|
||
subtitle="Start or stop the compose project vocagateway",
|
||
widget=self.gateway_run_btn,
|
||
)
|
||
)
|
||
|
||
self.gateway_status_label = Gtk.Label(label="Stopped", xalign=0)
|
||
self.gateway_status_label.get_style_context().add_class("status-info")
|
||
self.gateway_embed_group.add_row(
|
||
PreferenceRow(
|
||
title="Status",
|
||
subtitle="Stopped, Starting, Live, Pairable, Ready, or Error",
|
||
widget=self.gateway_status_label,
|
||
)
|
||
)
|
||
|
||
self.gateway_detail_label = Gtk.Label(label="", xalign=0, wrap=True)
|
||
self.gateway_detail_label.set_max_width_chars(55)
|
||
self.gateway_detail_label.get_style_context().add_class("preference-row-subtitle")
|
||
detail_row = Gtk.ListBoxRow()
|
||
detail_row.set_activatable(False)
|
||
detail_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||
detail_box.set_margin_start(16)
|
||
detail_box.set_margin_end(16)
|
||
detail_box.set_margin_top(4)
|
||
detail_box.set_margin_bottom(8)
|
||
detail_box.pack_start(self.gateway_detail_label, False, False, 0)
|
||
detail_row.add(detail_box)
|
||
self.gateway_embed_group.add_row(detail_row)
|
||
|
||
self.gateway_lan_switch = Gtk.Switch()
|
||
self.gateway_lan_switch.set_active(lan_publish)
|
||
self.gateway_lan_switch.connect("notify::active", self._on_gateway_lan_toggled)
|
||
self.gateway_embed_group.add_row(
|
||
PreferenceRow(
|
||
title="Allow LAN access for Phone",
|
||
subtitle=(
|
||
"Publishes 0.0.0.0:8765 and prefers a LAN URL in pairing. "
|
||
"Open the port in your firewall on trusted networks only."
|
||
),
|
||
widget=self.gateway_lan_switch,
|
||
)
|
||
)
|
||
|
||
self.gateway_use_btn = Gtk.Button(label="Use this Gateway")
|
||
self.gateway_use_btn.set_sensitive(False)
|
||
self.gateway_use_btn.set_tooltip_text(
|
||
"Fill Remote Server with this gateway URL and bearer token "
|
||
"(engine remote_api, endpoint /v1/audio/transcriptions)"
|
||
)
|
||
self.gateway_use_btn.connect("clicked", self._on_gateway_use_clicked)
|
||
self.gateway_embed_group.add_row(
|
||
PreferenceRow(
|
||
title="Remote API preset",
|
||
subtitle="Points Voculinux at the local gateway",
|
||
widget=self.gateway_use_btn,
|
||
)
|
||
)
|
||
|
||
self.gateway_qr_image = Gtk.Image()
|
||
self.gateway_qr_image.set_no_show_all(True)
|
||
self.gateway_pairing_label = Gtk.Label(label="", xalign=0, wrap=True, selectable=True)
|
||
self.gateway_pairing_label.set_max_width_chars(55)
|
||
self.gateway_pairing_label.get_style_context().add_class("preference-row-subtitle")
|
||
pairing_row = Gtk.ListBoxRow()
|
||
pairing_row.set_activatable(False)
|
||
pairing_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||
pairing_box.set_margin_start(16)
|
||
pairing_box.set_margin_end(16)
|
||
pairing_box.set_margin_top(8)
|
||
pairing_box.set_margin_bottom(12)
|
||
title = Gtk.Label(label="Phone pairing", xalign=0)
|
||
title.get_style_context().add_class("preference-row-title")
|
||
pairing_box.pack_start(title, False, False, 0)
|
||
pairing_box.pack_start(self.gateway_qr_image, False, False, 0)
|
||
pairing_box.pack_start(self.gateway_pairing_label, False, False, 0)
|
||
pairing_row.add(pairing_box)
|
||
self.gateway_embed_group.add_row(pairing_row)
|
||
|
||
if self.advanced_box is not None:
|
||
self.advanced_box.pack_start(self.gateway_embed_group, False, False, 0)
|
||
|
||
self._gateway_manager.add_listener(self._on_gateway_status_from_worker)
|
||
self.connect("destroy", self._on_gateway_embed_dialog_destroy)
|
||
# Runtime probe is async so Settings never blocks on podman/docker.
|
||
# When runtime is already ready, this probes leftover compose immediately.
|
||
self._gateway_manager.begin_runtime_detection()
|
||
if not self._gateway_manager.runtime_ready:
|
||
self.gateway_run_btn.set_sensitive(False)
|
||
self.gateway_detail_label.set_text("Detecting container runtime…")
|
||
elif not self._gateway_manager.available:
|
||
self.gateway_run_btn.set_sensitive(False)
|
||
self.gateway_detail_label.set_text(self._gateway_manager.unavailable_hint)
|
||
else:
|
||
hint = getattr(self._gateway_manager.runner.sandbox, "hint", "") or ""
|
||
if hint:
|
||
self.gateway_detail_label.set_text(hint)
|
||
|
||
self._apply_gateway_status_ui(
|
||
self._gateway_manager.status, self._gateway_manager.status_detail
|
||
)
|
||
|
||
def _on_gateway_embed_dialog_destroy(self, *_args) -> None:
|
||
"""Drop status listener so polls never touch a destroyed Settings dialog."""
|
||
manager = getattr(self, "_gateway_manager", None)
|
||
if manager is not None:
|
||
manager.remove_listener(self._on_gateway_status_from_worker)
|
||
|
||
def _on_gateway_status_from_worker(self, status: GatewayStatus, detail: str) -> None:
|
||
"""Marshal status updates onto the GTK main loop."""
|
||
GLib.idle_add(self._apply_gateway_status_ui, status, detail)
|
||
|
||
def _apply_gateway_status_ui(self, status: GatewayStatus, detail: str) -> bool:
|
||
if not hasattr(self, "gateway_status_label"):
|
||
return False
|
||
if not self._dialog_is_alive():
|
||
return False
|
||
label = status.value if isinstance(status, GatewayStatus) else str(status)
|
||
self.gateway_status_label.set_text(label)
|
||
if detail:
|
||
self.gateway_detail_label.set_text(detail)
|
||
|
||
runningish = status in {
|
||
GatewayStatus.STARTING,
|
||
GatewayStatus.LIVE,
|
||
GatewayStatus.PAIRABLE,
|
||
GatewayStatus.READY,
|
||
GatewayStatus.ERROR,
|
||
}
|
||
if not self._gateway_manager.runtime_ready:
|
||
self.gateway_run_btn.set_sensitive(False)
|
||
if not detail:
|
||
self.gateway_detail_label.set_text("Detecting container runtime…")
|
||
elif self._gateway_manager.available:
|
||
if runningish or self._gateway_manager.managed_by_us:
|
||
self.gateway_run_btn.set_label("Stop local Gateway")
|
||
else:
|
||
self.gateway_run_btn.set_label("Run VocaGateway locally")
|
||
self.gateway_run_btn.set_sensitive(status is not GatewayStatus.STARTING)
|
||
else:
|
||
self.gateway_run_btn.set_sensitive(False)
|
||
if not detail:
|
||
self.gateway_detail_label.set_text(self._gateway_manager.unavailable_hint)
|
||
|
||
can_use = status in {GatewayStatus.PAIRABLE, GatewayStatus.READY}
|
||
self.gateway_use_btn.set_sensitive(can_use)
|
||
self._sync_gateway_lan_switch()
|
||
self._update_gateway_pairing_widgets()
|
||
return False
|
||
|
||
def _sync_gateway_lan_switch(self) -> None:
|
||
"""Keep Allow LAN honest with compose; skip the toggle handler."""
|
||
desired = bool(self._gateway_manager.lan_publish)
|
||
if bool(self.gateway_lan_switch.get_active()) != desired:
|
||
was_init = bool(getattr(self, "_initializing", False))
|
||
self._initializing = True
|
||
try:
|
||
self.gateway_lan_switch.set_active(desired)
|
||
finally:
|
||
self._initializing = was_init
|
||
saved = bool(self.config_manager.get("gateway_embed", "lan_publish", False))
|
||
if saved != desired:
|
||
self.config_manager.set("gateway_embed", "lan_publish", desired)
|
||
self.config_manager.save_config()
|
||
|
||
def _update_gateway_pairing_widgets(self) -> None:
|
||
info = self._gateway_manager.pairing
|
||
if info is None:
|
||
self._gateway_qr_cache_key = None
|
||
self.gateway_qr_image.hide()
|
||
self.gateway_qr_image.clear()
|
||
self.gateway_pairing_label.set_text(
|
||
"Pairing QR appears when the gateway is Live with a phone-reachable URL "
|
||
"(enable LAN for Phone, or set VOCAGATEWAY_PUBLIC_URL)."
|
||
)
|
||
return
|
||
if not info.display_url:
|
||
self._gateway_qr_cache_key = None
|
||
self.gateway_qr_image.hide()
|
||
self.gateway_qr_image.clear()
|
||
self.gateway_pairing_label.set_text(
|
||
"Gateway is live on loopback only. Enable LAN access for Phone "
|
||
"(or configure a non-loopback PUBLIC_URL) before pairing another device. "
|
||
"Token is held for Use this Gateway and is not shown here."
|
||
)
|
||
return
|
||
|
||
self.gateway_pairing_label.set_text(
|
||
f"URL: {info.display_url}\n"
|
||
"Token is available to Use this Gateway and the QR; it is not logged."
|
||
)
|
||
if info.qr_svg:
|
||
cache_key = (info.display_url, len(info.qr_svg))
|
||
if getattr(self, "_gateway_qr_cache_key", None) != cache_key:
|
||
pixbuf = self._pixbuf_from_svg_bytes(info.qr_svg)
|
||
if pixbuf is not None:
|
||
self.gateway_qr_image.set_from_pixbuf(pixbuf)
|
||
self._gateway_qr_cache_key = cache_key
|
||
if getattr(self, "_gateway_qr_cache_key", None) == cache_key:
|
||
self.gateway_qr_image.show()
|
||
else:
|
||
self._gateway_qr_cache_key = None
|
||
self.gateway_qr_image.hide()
|
||
self.gateway_qr_image.clear()
|
||
|
||
def _pixbuf_from_svg_bytes(self, data: bytes) -> Any:
|
||
"""Best-effort SVG to GdkPixbuf; returns None when loaders are missing."""
|
||
try:
|
||
from gi.repository import GdkPixbuf
|
||
|
||
loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
|
||
loader.write(data[: 512 * 1024])
|
||
loader.close()
|
||
return loader.get_pixbuf()
|
||
except Exception:
|
||
return None
|
||
|
||
def _on_gateway_run_clicked(self, widget: Any) -> None:
|
||
if not self._gateway_manager.available:
|
||
return
|
||
status = self._gateway_manager.status
|
||
if (
|
||
status
|
||
in {
|
||
GatewayStatus.STARTING,
|
||
GatewayStatus.LIVE,
|
||
GatewayStatus.PAIRABLE,
|
||
GatewayStatus.READY,
|
||
GatewayStatus.ERROR,
|
||
}
|
||
or self._gateway_manager.managed_by_us
|
||
):
|
||
self._gateway_manager.stop_async()
|
||
else:
|
||
lan = bool(self.gateway_lan_switch.get_active())
|
||
self._gateway_manager.lan_publish = lan
|
||
self._gateway_manager.start_async(lan_publish=lan)
|
||
|
||
def _on_gateway_lan_toggled(self, widget: Any, _pspec: Any = None) -> None:
|
||
if getattr(self, "_initializing", False):
|
||
return
|
||
active = bool(self.gateway_lan_switch.get_active())
|
||
self.config_manager.set("gateway_embed", "lan_publish", active)
|
||
self.config_manager.save_config()
|
||
# Republish when compose is already running so QR/bind match LAN.
|
||
self._gateway_manager.apply_lan_publish(active)
|
||
self._update_gateway_pairing_widgets()
|
||
|
||
def _on_gateway_use_clicked(self, widget: Any) -> None:
|
||
try:
|
||
preset = self._gateway_manager.use_this_gateway(self.config_manager)
|
||
except Exception as exc:
|
||
self.gateway_detail_label.set_text(str(exc))
|
||
return
|
||
self._applying_settings = True
|
||
try:
|
||
self.remote_api_url_entry.set_text(preset["remote_api_url"])
|
||
self.remote_api_key_entry.set_text(preset["remote_api_key"])
|
||
self.remote_api_endpoint_combo.set_active_id(preset["remote_api_endpoint"])
|
||
self.remote_api_model_entry.set_text(preset.get("remote_api_model") or "whisper-1")
|
||
if hasattr(self, "engine_combo"):
|
||
self.engine_combo.set_active_id("remote_api")
|
||
finally:
|
||
self._applying_settings = False
|
||
self._auto_apply_settings()
|
||
self.gateway_detail_label.set_text(
|
||
"Remote Server now points at this gateway " f"({preset['remote_api_endpoint']})."
|
||
)
|
||
self._update_engine_specific_ui()
|
||
|
||
def _on_power_user_toggled(self, widget, state):
|
||
"""Handle the power-user opt-in toggle."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
if getattr(self, "_power_user_dialog_open", False):
|
||
return True
|
||
if state:
|
||
self._power_user_dialog_open = True
|
||
try:
|
||
dialog = Gtk.MessageDialog(
|
||
transient_for=self,
|
||
flags=Gtk.DialogFlags.MODAL,
|
||
message_type=Gtk.MessageType.WARNING,
|
||
buttons=Gtk.ButtonsType.NONE,
|
||
text="Advanced Settings",
|
||
)
|
||
dialog.format_secondary_text(
|
||
"These settings control whisper.cpp's internal decoding parameters. "
|
||
"Changing them can affect transcription quality and performance. "
|
||
"Only proceed if you understand the impact."
|
||
)
|
||
dialog.add_button("_Keep it Simple", Gtk.ResponseType.CANCEL)
|
||
confirm_btn = dialog.add_button("_I Know What I'm Doing", Gtk.ResponseType.YES)
|
||
confirm_btn.get_style_context().add_class("suggested-action")
|
||
response = dialog.run()
|
||
dialog.destroy()
|
||
finally:
|
||
self._power_user_dialog_open = False
|
||
if response != Gtk.ResponseType.YES:
|
||
return True
|
||
self.config_manager.set("advanced", "power_user_mode", state)
|
||
self.config_manager.save_settings()
|
||
self.advanced_revealer.set_reveal_child(state)
|
||
return False
|
||
|
||
def _on_settings_dialog_response(self, dialog, response_id):
|
||
"""Persist deferred text edits before the settings dialog closes."""
|
||
if response_id in (Gtk.ResponseType.CLOSE, Gtk.ResponseType.DELETE_EVENT):
|
||
self._cancel_audio_test()
|
||
self._flush_advanced_prompt_if_dirty()
|
||
self._resync_engine_ui_if_unapplied()
|
||
|
||
def _on_advanced_prompt_changed(self, buffer):
|
||
"""Track prompt edits without applying settings on every keystroke."""
|
||
if self._initializing:
|
||
return
|
||
self._advanced_prompt_dirty = True
|
||
|
||
def _on_language_candidates_changed(self, entry: Gtk.Entry) -> None:
|
||
"""Track candidate edits without applying settings on every keystroke."""
|
||
if self._initializing:
|
||
return
|
||
self._language_candidates_dirty = True
|
||
|
||
def _deferred_text_edit_settings(self) -> dict[str, Any]:
|
||
"""Current values of the deferred text fields, for a mid-apply close."""
|
||
pending: dict[str, Any] = {}
|
||
if self._advanced_prompt_dirty:
|
||
pending["whispercpp_initial_prompt"] = self.advanced_initial_prompt_buffer.get_text(
|
||
self.advanced_initial_prompt_buffer.get_start_iter(),
|
||
self.advanced_initial_prompt_buffer.get_end_iter(),
|
||
False,
|
||
)
|
||
if self._language_candidates_dirty:
|
||
pending["whispercpp_language_candidates"] = (
|
||
self.advanced_language_candidates_entry.get_text()
|
||
)
|
||
return pending
|
||
|
||
def _flush_advanced_prompt_if_dirty(self):
|
||
"""Apply deferred advanced text edits (initial prompt, language candidates)."""
|
||
if self._initializing:
|
||
return
|
||
if not (self._advanced_prompt_dirty or self._language_candidates_dirty):
|
||
return
|
||
if self._applying_settings:
|
||
# The apply holding the guard cannot see these edits; stash them so
|
||
# _finish_auto_apply can re-apply (dialog open) or persist (closed).
|
||
self._pending_text_edits = self._deferred_text_edit_settings()
|
||
# Pre-edit values: lets a later persist keep keys a newer apply
|
||
# never touched instead of dropping the whole snapshot.
|
||
self._pending_text_edit_baseline = {
|
||
key: self.config_manager.get("advanced", key) for key in self._pending_text_edits
|
||
}
|
||
self._pending_apply_generation = _apply_settings_generation
|
||
return
|
||
self._auto_apply_settings()
|
||
|
||
def _persist_pending_text_edits(self) -> None:
|
||
"""Persist deferred edits captured when the dialog closed mid-apply."""
|
||
pending = self._pending_text_edits
|
||
baseline = self._pending_text_edit_baseline or {}
|
||
self._pending_text_edits = None
|
||
self._pending_text_edit_baseline = None
|
||
if not pending:
|
||
return
|
||
|
||
def persist() -> None:
|
||
try:
|
||
with _apply_settings_lock:
|
||
if _apply_settings_generation != self._pending_apply_generation:
|
||
# A newer apply already reconfigured the shared engine
|
||
# and saved newer values. Keep only the keys it left
|
||
# untouched — the rest of the snapshot is stale and
|
||
# must not reach the engine or the config again. A key
|
||
# counts as touched only when the newer apply wrote
|
||
# it: its choice can legally equal the baseline, and
|
||
# the older edit must not overwrite it.
|
||
surviving = {
|
||
key: value
|
||
for key, value in pending.items()
|
||
if _apply_settings_written.get(key, self._pending_apply_generation)
|
||
<= self._pending_apply_generation
|
||
and self.config_manager.get("advanced", key) == baseline.get(key)
|
||
}
|
||
else:
|
||
surviving = dict(pending)
|
||
if not surviving:
|
||
return
|
||
if all(
|
||
self.config_manager.get("advanced", key) == value
|
||
for key, value in surviving.items()
|
||
):
|
||
# The apply that was running already landed these values.
|
||
return
|
||
self.speech_engine.reconfigure(**surviving)
|
||
for key, value in surviving.items():
|
||
self.config_manager.set("advanced", key, value)
|
||
_apply_settings_written[key] = _apply_settings_generation
|
||
self.config_manager.save_settings()
|
||
except (OSError, ValueError, TypeError, RuntimeError) as e:
|
||
logger.warning(
|
||
"Could not persist deferred settings edits for keys %s: %s",
|
||
sorted(pending),
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
|
||
# reconfigure() restarts the model; like the normal apply path it runs
|
||
# on a worker so the model load never blocks the GTK main loop.
|
||
threading.Thread(target=persist, daemon=True).start()
|
||
|
||
def _on_advanced_param_changed(self, widget, *args):
|
||
"""Handle any advanced parameter change."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
self._auto_apply_settings()
|
||
return False
|
||
|
||
def _on_reset_advanced_clicked(self, widget):
|
||
"""Reset whisper.cpp advanced parameters to defaults."""
|
||
if _handlers_suppressed(self):
|
||
return
|
||
|
||
defaults = DEFAULT_CONFIG["advanced"]
|
||
with _suppressed(self, "_applying_settings"):
|
||
self.advanced_no_timestamps_switch.set_active(defaults["whispercpp_no_timestamps"])
|
||
self.advanced_no_context_switch.set_active(defaults["whispercpp_no_context"])
|
||
self.advanced_initial_prompt_buffer.set_text(defaults["whispercpp_initial_prompt"], -1)
|
||
self.advanced_language_candidates_entry.set_text(
|
||
defaults["whispercpp_language_candidates"]
|
||
)
|
||
self.advanced_temperature_spin.set_value(defaults["whispercpp_temperature"])
|
||
self.advanced_temperature_inc_spin.set_value(defaults["whispercpp_temperature_inc"])
|
||
self.advanced_entropy_thold_spin.set_value(defaults["whispercpp_entropy_thold"])
|
||
self.advanced_logprob_thold_spin.set_value(defaults["whispercpp_logprob_thold"])
|
||
self.advanced_no_speech_thold_spin.set_value(defaults["whispercpp_no_speech_thold"])
|
||
self.gpu_device_combo.set_active_id("-1")
|
||
|
||
self._auto_apply_settings()
|
||
|
||
def _load_and_apply_settings(self):
|
||
"""Load current settings and populate the UI."""
|
||
settings = self._get_current_settings()
|
||
self.current_engine = settings["engine"]
|
||
self.language = settings["language"]
|
||
# The picker holds languages, not modes. Remember the mode separately and
|
||
# seed the picker with whatever the layout resolves to right now, so the
|
||
# disabled row reads as a live answer instead of going blank (#821).
|
||
self._follow_layout_saved = self.language == LANGUAGE_FOLLOWS_LAYOUT
|
||
if self._follow_layout_saved:
|
||
self.language = language_for_active_layout(SUPPORTED_LANGUAGES) or "auto"
|
||
if self.current_engine == "parakeet":
|
||
# Config may already be auto; keep any non-auto leftover as restore seed.
|
||
self._last_non_parakeet_language = (
|
||
self.language if self.language and self.language != "auto" else None
|
||
)
|
||
else:
|
||
self._last_non_parakeet_language = self.language
|
||
self._engine_for_language_memory = self.current_engine
|
||
self.current_model_size = settings["model_size"]
|
||
self.current_vad = settings.get("vad_sensitivity", 3)
|
||
self.current_silence = settings.get("silence_timeout", 1.2)
|
||
|
||
logger.info(
|
||
f"Starting dialog with settings: engine={self.current_engine}, model={self.current_model_size}"
|
||
)
|
||
|
||
general_settings = self.config_manager.get_settings().get("general", {})
|
||
ui_settings = self.config_manager.get_settings().get("ui", {})
|
||
text_injection_settings = self.config_manager.get_settings().get("text_injection", {})
|
||
history_settings = self.config_manager.get_settings().get("history", {})
|
||
|
||
# The login desktop entry can be added or removed outside the app; the
|
||
# real file state wins over the stored preference (previously synced by
|
||
# the tray menu's Start on Login item).
|
||
from . import autostart_manager
|
||
|
||
autostart_enabled = autostart_manager.is_autostart_enabled()
|
||
if autostart_enabled != general_settings.get("autostart", False):
|
||
self.config_manager.set("general", "autostart", autostart_enabled)
|
||
self.config_manager.save_settings()
|
||
|
||
start_minimized = ui_settings.get("start_minimized", False)
|
||
show_missing_tray_warning = ui_settings.get("show_missing_tray_warning", True)
|
||
show_overlay = ui_settings.get("show_overlay", True)
|
||
copy_to_clipboard = text_injection_settings.get("copy_to_clipboard", False)
|
||
dictate_to_pad = text_injection_settings.get("dictate_to_pad", False)
|
||
auto_capitalize = text_injection_settings.get("auto_capitalize", True)
|
||
append_trailing_space = text_injection_settings.get("append_trailing_space", True)
|
||
paste_shortcut = self.config_manager.get_paste_shortcut()
|
||
paste_long_text = self.config_manager.get_paste_long_text()
|
||
history_enabled = bool(history_settings.get("enabled", True))
|
||
# A hand-edited config.json can hold a non-numeric value; sanitize so a
|
||
# bad preference cannot crash the settings dialog.
|
||
history_max_items = sanitize_max_items(history_settings.get("max_items", 10))
|
||
|
||
self.autostart_switch.set_active(autostart_enabled)
|
||
self.start_minimized_switch.set_active(start_minimized)
|
||
self.missing_tray_warning_switch.set_active(show_missing_tray_warning)
|
||
self.show_overlay_switch.set_active(show_overlay)
|
||
self.copy_to_clipboard_switch.set_active(copy_to_clipboard)
|
||
self.dictation_pad_switch.set_active(dictate_to_pad)
|
||
self.auto_capitalize_switch.set_active(auto_capitalize)
|
||
self.append_trailing_space_switch.set_active(append_trailing_space)
|
||
self.paste_long_text_switch.set_active(paste_long_text)
|
||
if not self.paste_shortcut_combo.set_active_id(paste_shortcut):
|
||
self.paste_shortcut_combo.set_active_id(DEFAULT_PASTE_SHORTCUT)
|
||
sound_effects_enabled = self.config_manager.is_sound_effects_enabled()
|
||
self.sound_effects_switch.set_active(sound_effects_enabled)
|
||
self._update_sound_effects_sensitivity(sound_effects_enabled)
|
||
duck_enabled = self.config_manager.is_playback_duck_enabled()
|
||
self.duck_level_scale.set_value(self.config_manager.get_playback_duck_percent())
|
||
self.duck_playback_switch.set_active(duck_enabled)
|
||
self.duck_level_scale.set_sensitive(duck_enabled)
|
||
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._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))
|
||
self.auto_pause_switch.set_active(auto_pause_enabled)
|
||
self._update_auto_pause_sensitivity(auto_pause_enabled)
|
||
self._refresh_auto_pause_list()
|
||
self._refresh_dictionary_ui()
|
||
|
||
keepalive_settings = self.config_manager.get_settings().get("model_keepalive", {})
|
||
keepalive_enabled = bool(keepalive_settings.get("enabled", False))
|
||
self.model_keepalive_switch.set_active(keepalive_enabled)
|
||
self.model_reload_buffer_switch.set_active(
|
||
bool(keepalive_settings.get("buffer_during_reload", False))
|
||
)
|
||
self._update_model_keepalive_sensitivity(keepalive_enabled)
|
||
timeout_seconds = int(keepalive_settings.get("idle_timeout_seconds", 300) or 300)
|
||
if not self.model_keepalive_timeout_combo.set_active_id(str(timeout_seconds)):
|
||
self.model_keepalive_timeout_combo.set_active_id("300")
|
||
|
||
self.history_enabled_switch.set_active(history_enabled)
|
||
self.history_max_items_spin.set_value(history_max_items)
|
||
self.history_persist_switch.set_active(bool(history_settings.get("persist", False)))
|
||
|
||
disable_internal_hotkey = self.config_manager.get_bool(
|
||
"shortcuts", "disable_internal_hotkey", False
|
||
)
|
||
self.disable_internal_hotkey_switch.set_active(disable_internal_hotkey)
|
||
self._update_internal_hotkey_sensitivity(disable_internal_hotkey)
|
||
|
||
post_processing_settings = self.config_manager.get_settings().get("post_processing", {})
|
||
self.post_processor_entry.set_text(post_processing_settings.get("script_path", ""))
|
||
|
||
available_engines = get_available_engines()
|
||
available_count = 0
|
||
|
||
for engine in ENGINE_MODELS.keys():
|
||
if available_engines.get(engine, False):
|
||
display_name = _engine_display_name(engine)
|
||
self.engine_combo.append(display_name, display_name)
|
||
available_count += 1
|
||
|
||
if available_count == 0:
|
||
logger.error("No speech recognition engines available!")
|
||
# Still add them so the UI works, but log the error
|
||
for engine in ENGINE_MODELS.keys():
|
||
display_name = _engine_display_name(engine)
|
||
self.engine_combo.append(display_name, display_name)
|
||
else:
|
||
logger.info(f"Populated {available_count} available engines: {available_engines}")
|
||
|
||
if not available_engines.get(self.current_engine, False):
|
||
logger.warning(
|
||
f"Current engine '{self.current_engine}' is not available, "
|
||
"selecting first available"
|
||
)
|
||
for engine in ENGINE_MODELS.keys():
|
||
if available_engines.get(engine, False):
|
||
self.current_engine = engine
|
||
break
|
||
|
||
engine_text = _engine_display_name(self.current_engine)
|
||
logger.info(f"Setting active engine to: {engine_text}")
|
||
if not self.engine_combo.set_active_id(engine_text):
|
||
logger.warning("Could not set engine by ID, trying by index")
|
||
# Find index of current engine
|
||
model = self.engine_combo.get_model()
|
||
for i, row in enumerate(model):
|
||
if _engine_from_display(row[0]) == self.current_engine:
|
||
self.engine_combo.set_active(i)
|
||
break
|
||
else:
|
||
# Fallback to first available
|
||
if self.engine_combo.get_model():
|
||
self.engine_combo.set_active(0)
|
||
|
||
# Populate model and language options for the selected engine
|
||
self._populate_model_options()
|
||
self._sync_language_options_for_selected_model(self.language)
|
||
self.follow_layout_switch.set_active(
|
||
self._follow_layout_saved and self._follow_layout_supported()
|
||
)
|
||
self._sync_follow_layout_controls()
|
||
|
||
# Set spin button values
|
||
self.vad_spin.set_value(self.current_vad)
|
||
self.silence_spin.set_value(self.current_silence)
|
||
|
||
# Set voice commands switch based on config
|
||
voice_commands_enabled = self.config_manager.is_voice_commands_enabled()
|
||
self.voice_commands_switch.set_active(voice_commands_enabled)
|
||
|
||
advanced_settings = self.config_manager.get_settings().get("advanced", {})
|
||
power_user_mode = advanced_settings.get("power_user_mode", False)
|
||
self.power_user_switch.set_active(power_user_mode)
|
||
self.advanced_revealer.set_reveal_child(power_user_mode)
|
||
self.advanced_no_timestamps_switch.set_active(
|
||
advanced_settings.get("whispercpp_no_timestamps", True)
|
||
)
|
||
self.advanced_no_context_switch.set_active(
|
||
advanced_settings.get("whispercpp_no_context", True)
|
||
)
|
||
self.advanced_initial_prompt_buffer.set_text(
|
||
advanced_settings.get("whispercpp_initial_prompt", ""), -1
|
||
)
|
||
# The saved value may be a JSON list (the engine accepts both forms);
|
||
# render the canonical comma-separated text its normalization produces.
|
||
from ..speech_recognition.recognition_manager import SpeechRecognitionManager
|
||
|
||
self.advanced_language_candidates_entry.set_text(
|
||
",".join(
|
||
SpeechRecognitionManager._normalize_language_candidates(
|
||
advanced_settings.get("whispercpp_language_candidates", "")
|
||
)
|
||
)
|
||
)
|
||
self.advanced_temperature_spin.set_value(
|
||
advanced_settings.get("whispercpp_temperature", 0.0)
|
||
)
|
||
self.advanced_temperature_inc_spin.set_value(
|
||
advanced_settings.get("whispercpp_temperature_inc", -1.0)
|
||
)
|
||
self.advanced_entropy_thold_spin.set_value(
|
||
advanced_settings.get("whispercpp_entropy_thold", 2.4)
|
||
)
|
||
self.advanced_logprob_thold_spin.set_value(
|
||
advanced_settings.get("whispercpp_logprob_thold", -1.0)
|
||
)
|
||
self.advanced_no_speech_thold_spin.set_value(
|
||
advanced_settings.get("whispercpp_no_speech_thold", 0.6)
|
||
)
|
||
|
||
def _get_current_settings(self):
|
||
"""Get current settings from config manager."""
|
||
self.config_manager.load_config()
|
||
settings = self.config_manager.get_settings()
|
||
|
||
sr_settings = settings.get("speech_recognition", {})
|
||
engine = sr_settings.get("engine", "vosk")
|
||
language = sr_settings.get("language", "en-us")
|
||
model_size = self.config_manager.get_model_size_for_engine(engine)
|
||
vad_sensitivity = sr_settings.get("vad_sensitivity", 3)
|
||
silence_timeout = sr_settings.get("silence_timeout", 1.2)
|
||
|
||
logger.info(
|
||
f"Loaded current settings: engine={engine}, language={language}, model_size={model_size}, "
|
||
f"vad={vad_sensitivity}, silence={silence_timeout}"
|
||
)
|
||
|
||
return {
|
||
"engine": engine,
|
||
"language": language,
|
||
"model_size": model_size,
|
||
"vad_sensitivity": vad_sensitivity,
|
||
"silence_timeout": silence_timeout,
|
||
}
|
||
|
||
def _get_selected_engine(self) -> str:
|
||
"""Return the currently selected engine ID."""
|
||
engine_text = self.engine_combo.get_active_text()
|
||
return _engine_from_display(engine_text) if engine_text else "vosk"
|
||
|
||
def _get_selected_whispercpp_model(self) -> str:
|
||
"""Return the selected whisper.cpp model variant."""
|
||
variant_id = self.model_variant_combo.get_active_id()
|
||
if variant_id:
|
||
return cast(str, variant_id)
|
||
|
||
size_id = self.model_combo.get_active_id()
|
||
model_size = size_id.lower() if size_id else "small"
|
||
variants = get_whispercpp_model_variants(model_size)
|
||
return variants[0] if variants else "small"
|
||
|
||
def _get_recommended_whispercpp_model_for_language(self) -> tuple[str, str]:
|
||
"""Return the recommended whisper.cpp variant for the selected language."""
|
||
recommended_model, reason = get_recommended_whispercpp_model()
|
||
language_id = _language_for_whispercpp_variant(self)
|
||
return _recommended_whispercpp_variant_for_language(
|
||
recommended_model,
|
||
reason,
|
||
cast(str, language_id),
|
||
)
|
||
|
||
def _get_default_whispercpp_variant_for_size(self, model_size: str) -> Optional[str]:
|
||
"""Return the default specialization for a user-selected size."""
|
||
language_id = _language_for_whispercpp_variant(self)
|
||
return _default_whispercpp_variant_for_size(model_size, cast(str, language_id))
|
||
|
||
def _is_selected_whispercpp_model_english_only(self) -> bool:
|
||
"""Return whether the selected model is a whisper.cpp English-only variant."""
|
||
if self._get_selected_engine() != "whisper_cpp":
|
||
return False
|
||
return is_english_only_whispercpp_model(self._get_selected_whispercpp_model())
|
||
|
||
def _set_combo_active_id_or_first(self, combo, active_id: Optional[str]) -> bool:
|
||
"""Set a combo to an ID, falling back to the first row."""
|
||
if active_id and combo.set_active_id(active_id):
|
||
return True
|
||
|
||
model = combo.get_model()
|
||
if model:
|
||
combo.set_active(0)
|
||
return True
|
||
return False
|
||
|
||
def _default_language_for_engine(self, engine: str) -> str:
|
||
"""Return a safe default language for the selected engine and model."""
|
||
if engine == "vosk":
|
||
return "en-us"
|
||
return "auto"
|
||
|
||
def _update_model_picker_tooltips(self):
|
||
"""Refresh model picker hover guidance for the current selection."""
|
||
self.model_combo.set_tooltip_text(MODEL_SIZE_TOOLTIP)
|
||
self.model_row.set_tooltip_text(MODEL_SIZE_TOOLTIP)
|
||
self.language_combo.set_tooltip_text(LANGUAGE_TOOLTIP)
|
||
self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP)
|
||
|
||
if self._get_selected_engine() == "whisper_cpp":
|
||
specialization_tooltip = _model_specialization_tooltip(
|
||
self._get_selected_whispercpp_model()
|
||
)
|
||
else:
|
||
specialization_tooltip = MODEL_SPECIALIZATION_TOOLTIP
|
||
|
||
self.model_variant_combo.set_tooltip_text(specialization_tooltip)
|
||
self.model_variant_row.set_tooltip_text(specialization_tooltip)
|
||
|
||
def _sync_language_options_for_selected_model(self, preferred_language: Optional[str] = None):
|
||
"""Refresh language options and keep the current model/language pair valid."""
|
||
engine = self._get_selected_engine()
|
||
if engine == "parakeet":
|
||
# Coverage is the model (v2-english vs v3-european), not this picker.
|
||
# Remember a non-auto preferred leftover, but never clobber memory with
|
||
# the forced auto that follows an engine switch into Parakeet.
|
||
if preferred_language and preferred_language != "auto":
|
||
self._last_non_parakeet_language = preferred_language
|
||
self.language = "auto"
|
||
language_to_keep = "auto"
|
||
else:
|
||
language_to_keep = (
|
||
preferred_language or self.language_combo.get_active_id() or self.language
|
||
)
|
||
|
||
with _suppressed(self, "_processing_language_change"):
|
||
self._populate_language_options()
|
||
if not self._set_combo_active_id_or_first(self.language_combo, language_to_keep):
|
||
return
|
||
|
||
active_language = self.language_combo.get_active_id()
|
||
if active_language != language_to_keep:
|
||
fallback_language = self._default_language_for_engine(engine)
|
||
self._set_combo_active_id_or_first(self.language_combo, fallback_language)
|
||
|
||
if engine == "parakeet":
|
||
self.language = "auto"
|
||
else:
|
||
self.language = (
|
||
self.language_combo.get_active_id() or self._default_language_for_engine(engine)
|
||
)
|
||
if self.language and not _vosk_display_is_coerced_fallback(
|
||
engine, self.language, self._last_non_parakeet_language
|
||
):
|
||
self._last_non_parakeet_language = self.language
|
||
|
||
self._sync_follow_layout_controls()
|
||
self._update_language_warning()
|
||
self._update_model_picker_tooltips()
|
||
|
||
def _populate_model_options(self):
|
||
"""Populate model options based on the current engine selection."""
|
||
try:
|
||
with _suppressed(self, "_populating_models"):
|
||
self.model_combo.remove_all()
|
||
self.model_variant_combo.remove_all()
|
||
|
||
engine_text = self.engine_combo.get_active_text()
|
||
if not engine_text:
|
||
logger.warning("No engine selected during model options population")
|
||
return
|
||
|
||
engine = _engine_from_display(engine_text)
|
||
logger.info(f"Populating model options for engine: {engine}")
|
||
|
||
# Remote API does not need model options
|
||
if engine == "remote_api":
|
||
logger.info("Remote API engine selected, no model options needed")
|
||
return
|
||
|
||
saved_model_for_engine = self.config_manager.get_model_size_for_engine(engine)
|
||
logger.info(f"Saved model for {engine}: {saved_model_for_engine}")
|
||
|
||
if engine == "whisper_cpp":
|
||
self._populate_whispercpp_model_options(saved_model_for_engine)
|
||
return
|
||
|
||
downloaded_models = []
|
||
smallest_model = None
|
||
if engine == "whisper":
|
||
recommended_model, _ = _get_recommended_whisper_model()
|
||
elif engine == "parakeet":
|
||
recommended_model = parakeet.RECOMMENDED_MODEL
|
||
elif engine == "faster_whisper":
|
||
recommended_model, _ = get_recommended_faster_whisper_model()
|
||
recommended_model, _ = _recommended_faster_whisper_variant_for_language(
|
||
recommended_model,
|
||
"",
|
||
self.language_combo.get_active_id() or self.language,
|
||
)
|
||
else:
|
||
recommended_model, _ = _get_recommended_vosk_model()
|
||
|
||
if engine in ENGINE_MODELS:
|
||
for size in ENGINE_MODELS[engine]:
|
||
if engine == "whisper" and size in WHISPER_MODEL_INFO:
|
||
info = WHISPER_MODEL_INFO[size]
|
||
is_downloaded = _is_whisper_model_downloaded(size)
|
||
elif engine == "faster_whisper" and size in FASTER_WHISPER_MODEL_INFO:
|
||
info = FASTER_WHISPER_MODEL_INFO[size]
|
||
is_downloaded = is_faster_whisper_model_downloaded(size)
|
||
elif engine == "vosk" and size in VOSK_MODEL_INFO:
|
||
info = VOSK_MODEL_INFO[size]
|
||
is_downloaded = _is_vosk_model_downloaded(size, self.language)
|
||
elif engine == "parakeet":
|
||
info = parakeet.PARAKEET_MODEL_INFO[size]
|
||
is_downloaded = parakeet.is_model_downloaded(size)
|
||
else:
|
||
is_downloaded = False
|
||
info = {"size_mb": 0}
|
||
|
||
model_display_name = _model_display_name(size)
|
||
status = "✓" if is_downloaded else "↓"
|
||
star = " ★" if size == recommended_model else ""
|
||
display_text = (
|
||
f"{model_display_name} ({_format_size(info.get('size_mb', 0))}) "
|
||
f"{status}{star}"
|
||
)
|
||
|
||
if is_downloaded:
|
||
downloaded_models.append(size)
|
||
if smallest_model is None:
|
||
smallest_model = size
|
||
|
||
# whisper.cpp already appends the catalog id; Faster Whisper
|
||
# ids include dots (`small.en`) which capitalize() mangles.
|
||
combo_id = size if engine == "faster_whisper" else size.capitalize()
|
||
self.model_combo.append(combo_id, display_text)
|
||
|
||
# Determine which model to select. A staged spec wins over the
|
||
# saved model so a rebuild (e.g. a language change) keeps the
|
||
# unconfirmed pick instead of snapping back to the saved one.
|
||
saved_model = saved_model_for_engine.lower()
|
||
valid_models = [m.lower() for m in ENGINE_MODELS.get(engine, [])]
|
||
|
||
staged = self._staged_model_spec
|
||
staged_model = None
|
||
if isinstance(staged, dict) and staged.get("engine") == engine:
|
||
candidate = str(staged.get("model_size") or "").lower()
|
||
if candidate in valid_models:
|
||
staged_model = candidate
|
||
|
||
if staged_model:
|
||
selected = staged_model
|
||
elif saved_model in valid_models:
|
||
selected = saved_model
|
||
elif downloaded_models:
|
||
selected = downloaded_models[0]
|
||
else:
|
||
selected = smallest_model
|
||
|
||
if engine == "faster_whisper":
|
||
model_to_set = selected or "tiny"
|
||
else:
|
||
model_to_set = selected.capitalize() if selected else "Small"
|
||
|
||
logger.info(f"Setting active model to: {model_to_set}")
|
||
|
||
if not self.model_combo.set_active_id(model_to_set):
|
||
logger.warning(f"Could not set model by ID '{model_to_set}'")
|
||
model = self.model_combo.get_model()
|
||
for i, row in enumerate(model):
|
||
if row[0].lower() == model_to_set.lower():
|
||
self.model_combo.set_active(i)
|
||
break
|
||
else:
|
||
if len(ENGINE_MODELS.get(engine, [])) > 0:
|
||
self.model_combo.set_active(0)
|
||
|
||
logger.info(f"Final selected model: {self.model_combo.get_active_text()}")
|
||
finally:
|
||
self._refresh_unused_downloads()
|
||
|
||
def _resolve_saved_whispercpp_variant(self, saved_model_for_engine: str) -> str:
|
||
"""Resolve which variant the saved configuration actually asks for.
|
||
|
||
``whisper_cpp_model_size`` holds the id that loads the model, but for every
|
||
size below "large" the multilingual variant id *is* the bare size name, so
|
||
a stored "medium" cannot say whether the user chose the multilingual variant
|
||
or never chose anything. The explicit pin written by Settings answers that.
|
||
Without a pin the variant follows the selected language, which is what makes
|
||
picking English actually select an English-only model.
|
||
|
||
A leftover plain ``{size}.en`` English-only id is not treated as a permanent
|
||
specialization: it is re-derived from the language currently in the combo.
|
||
"""
|
||
pinned = self.config_manager.get_model_variant_for_engine("whisper_cpp")
|
||
language_id = _language_for_whispercpp_variant(self)
|
||
return resolve_whispercpp_variant(saved_model_for_engine, pinned, cast(str, language_id))
|
||
|
||
def _populate_whispercpp_model_options(self, saved_model_for_engine: str):
|
||
"""Populate whisper.cpp size and specialization selectors."""
|
||
recommended_model, _ = self._get_recommended_whispercpp_model_for_language()
|
||
recommended_size = get_whispercpp_model_size(recommended_model)
|
||
|
||
staged = self._staged_model_spec
|
||
staged_model = ""
|
||
if isinstance(staged, dict) and staged.get("engine") == "whisper_cpp":
|
||
staged_model = str(staged.get("model_size") or "")
|
||
if staged_model in WHISPERCPP_MODEL_INFO:
|
||
# The staged spec survives rebuilds until its Download confirmation
|
||
# or a real apply (#894); it wins over the saved seed.
|
||
saved_model = staged_model
|
||
else:
|
||
saved_model = self._resolve_saved_whispercpp_variant(saved_model_for_engine)
|
||
if saved_model not in WHISPERCPP_MODEL_INFO:
|
||
saved_model = (
|
||
recommended_model if recommended_model in WHISPERCPP_MODEL_INFO else "tiny"
|
||
)
|
||
|
||
saved_size = get_whispercpp_model_size(saved_model)
|
||
|
||
for model_size in ENGINE_MODELS["whisper_cpp"]:
|
||
# Same shape as the specialization list: a size is a download decision
|
||
# too, so its price and whether it is already paid for belong here (#778).
|
||
size_variant = self._get_default_whispercpp_variant_for_size(model_size)
|
||
display_text = _model_display_name(model_size)
|
||
if size_variant in WHISPERCPP_MODEL_INFO:
|
||
info = WHISPERCPP_MODEL_INFO[size_variant]
|
||
status = "✓" if is_whispercpp_model_downloaded(size_variant) else "↓"
|
||
display_text += f" ({_format_size(cast(int, info['size_mb']))}) {status}"
|
||
if model_size == recommended_size:
|
||
display_text += " ★"
|
||
self.model_combo.append(model_size, display_text)
|
||
|
||
self._set_combo_active_id_or_first(self.model_combo, saved_size)
|
||
active_size = self.model_combo.get_active_id() or saved_size
|
||
self._populate_whispercpp_variant_options(active_size, saved_model)
|
||
|
||
def _populate_whispercpp_variant_options(
|
||
self,
|
||
model_size: str,
|
||
selected_model: Optional[str] = None,
|
||
):
|
||
"""Populate the whisper.cpp specialization selector for a size."""
|
||
self.model_variant_combo.remove_all()
|
||
|
||
variants = get_whispercpp_model_variants(model_size.lower())
|
||
recommended_model, _ = self._get_recommended_whispercpp_model_for_language()
|
||
|
||
for model_name in variants:
|
||
info = cast(dict[str, Any], WHISPERCPP_MODEL_INFO[model_name])
|
||
is_downloaded = is_whispercpp_model_downloaded(model_name)
|
||
status = "✓" if is_downloaded else "↓"
|
||
star = " ★" if model_name == recommended_model else ""
|
||
display_text = (
|
||
f"{_model_specialization_display_name(model_name)} "
|
||
f"({_format_size(info['size_mb'])}) {status}{star}"
|
||
)
|
||
self.model_variant_combo.append(model_name, display_text)
|
||
|
||
language_id = _language_for_whispercpp_variant(self)
|
||
model_to_set = selected_model if selected_model in variants else None
|
||
if model_to_set:
|
||
# Bare size ids double as multilingual variants; retarget for language
|
||
# so English picks .en instead of leaving Standard multilingual stuck.
|
||
# Follow mode uses the layout sentinel here so an English *display*
|
||
# does not retarget onto .en and then refuse the mode.
|
||
model_to_set = _whispercpp_variant_for_language(model_to_set, cast(str, language_id))
|
||
if model_to_set not in variants:
|
||
model_to_set = None
|
||
if not model_to_set and recommended_model in variants:
|
||
model_to_set = recommended_model
|
||
if not model_to_set:
|
||
model_to_set = self._get_default_whispercpp_variant_for_size(model_size.lower())
|
||
if not model_to_set and variants:
|
||
model_to_set = variants[0]
|
||
|
||
self._set_combo_active_id_or_first(self.model_variant_combo, model_to_set)
|
||
self._update_model_picker_tooltips()
|
||
|
||
def _active_removable_model_id(self) -> Optional[str]:
|
||
"""Return the on-disk id of the model currently selected in Settings."""
|
||
engine = self._get_selected_engine()
|
||
if engine == "whisper_cpp":
|
||
return self._get_selected_whispercpp_model()
|
||
if engine == "whisper":
|
||
model_id = self.model_combo.get_active_id()
|
||
return model_id.lower() if model_id else None
|
||
if engine == "vosk":
|
||
size = (self.model_combo.get_active_id() or "").lower()
|
||
language = self.language_combo.get_active_id() or self.language
|
||
return vosk_model_dirname(size, language)
|
||
if engine == "parakeet":
|
||
model_id = self.model_combo.get_active_id()
|
||
return model_id.lower() if model_id else None
|
||
if engine == "faster_whisper":
|
||
model_id = self.model_combo.get_active_id()
|
||
return model_id.lower() if model_id else None
|
||
return None
|
||
|
||
def _list_unused_downloads(self) -> list[tuple[str, str, str]]:
|
||
"""Return (id, title, size_label) for unused downloaded models."""
|
||
return [
|
||
(model_id, label, size_label)
|
||
for model_id, label, size_label, in_use in self._list_downloaded_models()
|
||
if not in_use
|
||
]
|
||
|
||
def _list_downloaded_models(self) -> list[tuple[str, str, str, bool]]:
|
||
"""Return (id, title, size_label, in_use) for downloaded models."""
|
||
engine = self._get_selected_engine()
|
||
active_id = self._active_removable_model_id()
|
||
items: list[tuple[str, str, str, bool]] = []
|
||
|
||
if engine == "whisper_cpp":
|
||
for name in list_downloaded_whispercpp_models():
|
||
info = cast(dict[str, Any], WHISPERCPP_MODEL_INFO[name])
|
||
size_mb = cast(int, info["size_mb"])
|
||
items.append(
|
||
(
|
||
name,
|
||
_model_display_name(name),
|
||
_format_size(size_mb),
|
||
name == active_id,
|
||
)
|
||
)
|
||
elif engine == "whisper":
|
||
for name in _list_downloaded_whisper_models():
|
||
info = cast(dict[str, Any], WHISPER_MODEL_INFO[name])
|
||
size_mb = cast(int, info["size_mb"])
|
||
items.append(
|
||
(
|
||
name,
|
||
_model_display_name(name),
|
||
_format_size(size_mb),
|
||
name == active_id,
|
||
)
|
||
)
|
||
elif engine == "vosk":
|
||
for model in list_downloaded_vosk_models():
|
||
lang_info = SUPPORTED_LANGUAGES.get(model.language, {})
|
||
lang_name = model.language
|
||
if isinstance(lang_info, dict):
|
||
language_name_value = lang_info.get("name")
|
||
if isinstance(language_name_value, str):
|
||
lang_name = language_name_value
|
||
items.append(
|
||
(
|
||
model.dirname,
|
||
f"{lang_name} · {_model_display_name(model.size)}",
|
||
_format_size(model.size_mb),
|
||
model.dirname == active_id,
|
||
)
|
||
)
|
||
elif engine == "parakeet":
|
||
for name in parakeet.list_downloaded_models():
|
||
items.append(
|
||
(
|
||
name,
|
||
_model_display_name(name),
|
||
_format_size(parakeet.PARAKEET_MODEL_INFO[name]["size_mb"]),
|
||
name == active_id,
|
||
)
|
||
)
|
||
elif engine == "faster_whisper":
|
||
for name in list_downloaded_faster_whisper_models():
|
||
items.append(
|
||
(
|
||
name,
|
||
_model_display_name(name),
|
||
_format_size(FASTER_WHISPER_MODEL_INFO[name]["size_mb"]),
|
||
name == active_id,
|
||
)
|
||
)
|
||
|
||
return items
|
||
|
||
def _refresh_unused_downloads(self):
|
||
"""Rebuild the Unused downloads list, or hide it when empty."""
|
||
if not hasattr(self, "unused_models_group") or self.unused_island is None:
|
||
return
|
||
|
||
if self._get_selected_engine() == "remote_api":
|
||
self.unused_island.hide()
|
||
return
|
||
|
||
unused = self._list_unused_downloads()
|
||
self.unused_models_group.clear_rows()
|
||
if not unused:
|
||
self.unused_island.hide()
|
||
return
|
||
|
||
count = len(unused)
|
||
leftover = "unused model" if count == 1 else "unused models"
|
||
self.unused_expander_subtitle.set_text(f"{count} {leftover} on disk")
|
||
|
||
was_expanded = self.unused_expander.get_expanded()
|
||
for model_id, title, size_label in unused:
|
||
delete_btn = Gtk.Button(label="Delete")
|
||
delete_btn.set_tooltip_text(f"Delete {title} from disk")
|
||
delete_btn.get_style_context().add_class("destructive-action")
|
||
delete_btn.connect(
|
||
"clicked",
|
||
self._on_unused_download_delete_clicked,
|
||
model_id,
|
||
f"{title} ({size_label})",
|
||
)
|
||
row = PreferenceRow(
|
||
title=title,
|
||
subtitle=size_label,
|
||
widget=delete_btn,
|
||
keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"),
|
||
)
|
||
self.unused_models_group.add_row(row)
|
||
|
||
self.unused_island.show_all()
|
||
self.unused_expander.set_expanded(was_expanded)
|
||
self._fit_unused_downloads_height()
|
||
|
||
def _fit_unused_downloads_height(self):
|
||
"""Size the list from the rows it actually holds.
|
||
|
||
A per-row estimate cannot know how tall a row renders: with margins and
|
||
a Delete button the rows are taller than the estimate, so the last one
|
||
was cut off below the edge of the viewport while the header still
|
||
counted it (#683).
|
||
"""
|
||
if not hasattr(self, "unused_models_scroll"):
|
||
return
|
||
if self.unused_expander is not None and not self.unused_expander.get_expanded():
|
||
return
|
||
_, natural_height = self.unused_models_group.listbox.get_preferred_height()
|
||
self.unused_models_scroll.set_min_content_height(
|
||
_clamp_unused_downloads_height(natural_height)
|
||
)
|
||
|
||
def _confirm_model_delete(self, text: str, secondary: str) -> bool:
|
||
"""Ask before deleting model files from disk."""
|
||
dialog = Gtk.MessageDialog(
|
||
transient_for=self,
|
||
flags=Gtk.DialogFlags.MODAL,
|
||
message_type=Gtk.MessageType.WARNING,
|
||
buttons=Gtk.ButtonsType.NONE,
|
||
text=text,
|
||
)
|
||
dialog.format_secondary_text(secondary)
|
||
dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL)
|
||
delete_btn = dialog.add_button("_Delete", Gtk.ResponseType.YES)
|
||
delete_btn.get_style_context().add_class("destructive-action")
|
||
response = dialog.run()
|
||
dialog.destroy()
|
||
return cast(bool, response == Gtk.ResponseType.YES)
|
||
|
||
def _delete_model_from_disk(self, model_id: str) -> None:
|
||
"""Delete a downloaded model for the selected engine."""
|
||
engine = self._get_selected_engine()
|
||
if engine == "whisper_cpp":
|
||
delete_whispercpp_model(model_id)
|
||
elif engine == "whisper":
|
||
_delete_whisper_model(model_id)
|
||
elif engine == "vosk":
|
||
delete_vosk_model(model_id)
|
||
elif engine == "parakeet":
|
||
parakeet.delete_model(model_id)
|
||
elif engine == "faster_whisper":
|
||
delete_faster_whisper_model(model_id)
|
||
else:
|
||
raise ValueError(f"No local models to delete for engine {engine}")
|
||
|
||
def _on_unused_download_delete_clicked(self, widget, model_id: str, label: str):
|
||
"""Confirm and delete one unused downloaded model."""
|
||
if model_id == self._active_removable_model_id():
|
||
return
|
||
if not self._confirm_model_delete(
|
||
"Delete unused download?",
|
||
f"{label} will be removed from disk. You can download it again later if needed.",
|
||
):
|
||
return
|
||
|
||
try:
|
||
self._delete_model_from_disk(model_id)
|
||
except (OSError, ValueError, FileNotFoundError) as e:
|
||
logger.error("Failed to delete model %s: %s", model_id, e)
|
||
err = Gtk.MessageDialog(
|
||
transient_for=self,
|
||
flags=Gtk.DialogFlags.MODAL,
|
||
message_type=Gtk.MessageType.ERROR,
|
||
buttons=Gtk.ButtonsType.OK,
|
||
text="Could not delete model",
|
||
)
|
||
err.format_secondary_text(str(e))
|
||
err.run()
|
||
err.destroy()
|
||
return
|
||
|
||
self._populate_model_options()
|
||
self._update_model_info()
|
||
|
||
def _on_engine_changed(self, widget):
|
||
"""Handle changes in the selected engine."""
|
||
engine_text = self.engine_combo.get_active_text()
|
||
if not engine_text:
|
||
return
|
||
|
||
engine = _engine_from_display(engine_text)
|
||
|
||
# A programmatic change — the resync after a failed apply — must repaint
|
||
# for the engine now shown, but must not rewrite the language the user
|
||
# picked, and must not cascade into another apply. Every other control on
|
||
# this page checks these flags itself rather than leaning on
|
||
# _auto_apply_settings to do it.
|
||
programmatic = _handlers_suppressed(self)
|
||
|
||
current_lang = None if self._applying_settings else self.language_combo.get_active_id()
|
||
previous_engine = self._engine_for_language_memory
|
||
# When Vosk coerces an unsupported language to en-us, keep the user
|
||
# preference in memory and re-apply it after sync (which would otherwise
|
||
# write the coerced active value back into memory).
|
||
preserve_language_memory = None
|
||
vosk_coerced = False
|
||
if engine == "parakeet":
|
||
# Force auto for honesty, but remember the prior catalog preference so
|
||
# leaving Parakeet can restore Whisper/cpp language instead of auto.
|
||
# Only capture on entry: a re-fired changed signal while already on
|
||
# Parakeet would otherwise overwrite memory with the forced auto.
|
||
if previous_engine != "parakeet":
|
||
remembered = current_lang or self.language
|
||
# Vosk may still display coerced en-us for an unsupported
|
||
# catalog language. Entering Parakeet must not record that
|
||
# fallback over the pre-coercion preference (e.g. Greek or auto).
|
||
if remembered and not _vosk_display_is_coerced_fallback(
|
||
previous_engine, remembered, self._last_non_parakeet_language
|
||
):
|
||
self._last_non_parakeet_language = remembered
|
||
self.language = "auto"
|
||
elif current_lang is None and self._applying_settings:
|
||
# Programmatic resync must not rewrite the language the user picked.
|
||
pass
|
||
else:
|
||
# Prefer remembered preference over a prior engine's coerced active
|
||
# value (Vosk en-us for Greek). Trust the combo for other engines.
|
||
if (
|
||
current_lang
|
||
and current_lang != "auto"
|
||
and not _vosk_display_is_coerced_fallback(
|
||
previous_engine, current_lang, self._last_non_parakeet_language
|
||
)
|
||
):
|
||
chosen = current_lang
|
||
elif self._last_non_parakeet_language:
|
||
chosen = self._last_non_parakeet_language
|
||
else:
|
||
chosen = current_lang or self.language or self._default_language_for_engine(engine)
|
||
|
||
if engine == "vosk" and (
|
||
chosen == "auto" or not SUPPORTED_LANGUAGES.get(chosen, {}).get("vosk")
|
||
):
|
||
# Keep the pre-coercion preference (including auto) so sync cannot
|
||
# persist Vosk's en-us fallback as the remembered Whisper language.
|
||
preserve_language_memory = (
|
||
chosen
|
||
if chosen and chosen != "auto"
|
||
else (self._last_non_parakeet_language or "auto")
|
||
)
|
||
chosen = "en-us"
|
||
vosk_coerced = True
|
||
|
||
self.language = chosen
|
||
# Never store the coerced en-us fallback as the user preference.
|
||
if vosk_coerced:
|
||
self._last_non_parakeet_language = preserve_language_memory
|
||
elif chosen:
|
||
self._last_non_parakeet_language = chosen
|
||
|
||
self._engine_for_language_memory = engine
|
||
self._populate_model_options()
|
||
self._sync_language_options_for_selected_model(self.language)
|
||
if vosk_coerced and preserve_language_memory is not None:
|
||
self._last_non_parakeet_language = preserve_language_memory
|
||
self._update_engine_specific_ui()
|
||
self._update_model_info()
|
||
self._update_voice_commands_for_engine()
|
||
|
||
if programmatic:
|
||
return
|
||
|
||
# Every other control on this page applies as soon as it changes. Without
|
||
# this the new engine is only displayed: the config and the running
|
||
# recognizer stay on the previous one until some other control is touched,
|
||
# and closing the dialog drops the change silently.
|
||
if engine == "remote_api" and not self.remote_api_url_entry.get_text().strip():
|
||
# Applying now would only fail validation; _on_remote_api_settings_changed
|
||
# applies once the server URL is filled in.
|
||
return
|
||
self._auto_apply_settings()
|
||
|
||
def _update_voice_commands_for_engine(self):
|
||
"""Update voice commands switch based on current engine."""
|
||
sr_config = self.config_manager.get_settings().get("speech_recognition", {})
|
||
voice_commands_enabled = sr_config.get("voice_commands_enabled")
|
||
|
||
if voice_commands_enabled is None:
|
||
engine_text = self.engine_combo.get_active_text()
|
||
engine = _engine_from_display(engine_text) if engine_text else "whisper_cpp"
|
||
auto_enabled = engine == "vosk"
|
||
self.voice_commands_switch.set_active(auto_enabled)
|
||
|
||
def _on_model_changed(self, widget):
|
||
"""Handle changes in the selected model."""
|
||
if self._populating_models:
|
||
return
|
||
if _handlers_suppressed(self):
|
||
return
|
||
|
||
if self._get_selected_engine() == "whisper_cpp":
|
||
model_size = self.model_combo.get_active_id()
|
||
if model_size:
|
||
with _suppressed(self, "_populating_models"):
|
||
self._populate_whispercpp_variant_options(model_size)
|
||
self._sync_language_options_for_selected_model()
|
||
|
||
self._update_model_info()
|
||
self._refresh_unused_downloads()
|
||
self._auto_apply_settings()
|
||
|
||
def _on_model_variant_changed(self, widget):
|
||
"""Handle changes in the selected whisper.cpp specialization."""
|
||
if self._populating_models:
|
||
return
|
||
if _handlers_suppressed(self):
|
||
return
|
||
|
||
self._sync_language_options_for_selected_model()
|
||
self._update_model_info()
|
||
self._refresh_unused_downloads()
|
||
self._auto_apply_settings()
|
||
|
||
def _on_vad_changed(self, widget):
|
||
"""Handle changes in VAD sensitivity."""
|
||
self._auto_apply_settings()
|
||
|
||
def _on_silence_changed(self, widget):
|
||
"""Handle changes in silence timeout."""
|
||
self._auto_apply_settings()
|
||
|
||
def _on_voice_commands_toggled(self, widget, state):
|
||
"""Handle toggle of the voice commands switch."""
|
||
if _handlers_suppressed(self):
|
||
return False
|
||
|
||
enabled = bool(state)
|
||
logger.info(f"Voice commands toggled: {enabled}")
|
||
|
||
self.config_manager.set("speech_recognition", "voice_commands_enabled", enabled)
|
||
self.config_manager.save_settings()
|
||
try:
|
||
self.speech_engine.reconfigure(voice_commands_enabled=enabled, force_download=False)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to apply voice commands toggle immediately: {e}")
|
||
logger.info(f"Voice commands {'enabled' if enabled else 'disabled'}")
|
||
return False
|
||
|
||
def _populate_language_options(self):
|
||
"""Populate language dropdown with supported languages."""
|
||
self.language_combo.remove_all()
|
||
engine = self.engine_combo.get_active_text()
|
||
if not engine:
|
||
return
|
||
|
||
engine = _engine_from_display(engine)
|
||
|
||
for lang_code, lang_info in SUPPORTED_LANGUAGES.items():
|
||
display_text = lang_info["name"]
|
||
|
||
if engine == "vosk":
|
||
has_model = lang_info["vosk"] is not None
|
||
if not has_model or lang_code == "auto":
|
||
continue
|
||
is_downloaded = _is_vosk_model_downloaded("small", lang_code)
|
||
display_text += " ✓" if is_downloaded else " ↓"
|
||
elif engine in ["whisper", "whisper_cpp", "parakeet", "faster_whisper", "remote_api"]:
|
||
# An English-only model must not hide other languages: picking
|
||
# Polish (or auto) retargets to the multilingual sibling.
|
||
if lang_code == "auto":
|
||
display_text += " ⚠"
|
||
else:
|
||
continue
|
||
|
||
self.language_combo.append(lang_code, display_text)
|
||
|
||
def _update_language_warning(self):
|
||
"""Update language help text for the selected engine/model/language."""
|
||
if self._get_selected_engine() == "parakeet":
|
||
self.language_warning.set_markup("")
|
||
self.language_warning.hide()
|
||
return
|
||
|
||
lang_code = self.language_combo.get_active_id()
|
||
lang_info = SUPPORTED_LANGUAGES.get(lang_code, {})
|
||
|
||
if self._is_selected_whispercpp_model_english_only():
|
||
self.language_warning.set_markup(
|
||
"<span foreground='#e5a50a'>⚠ This model only understands English.</span>"
|
||
)
|
||
self.language_warning.show()
|
||
elif _is_following_layout(self):
|
||
resolved = language_for_active_layout(SUPPORTED_LANGUAGES)
|
||
resolved_name = SUPPORTED_LANGUAGES.get(resolved, {}).get("name") if resolved else None
|
||
self.language_warning.set_markup(
|
||
"<span foreground='#3584e4'>Follows your active keyboard layout. "
|
||
f"Right now that is {resolved_name or 'auto-detect'}.</span>"
|
||
)
|
||
self.language_warning.show()
|
||
elif lang_info.get("warning"):
|
||
lang_name = lang_info.get("name", "This language")
|
||
self.language_warning.set_markup(
|
||
f"<span foreground='#e5a50a'>⚠ {lang_name}: {lang_info['warning'].lower()}</span>"
|
||
)
|
||
self.language_warning.show()
|
||
else:
|
||
self.language_warning.set_markup("")
|
||
self.language_warning.hide()
|
||
|
||
def _follow_layout_supported(self, engine: Optional[str] = None) -> bool:
|
||
"""Whether the selected engine can follow the keyboard layout."""
|
||
return (engine or self._get_selected_engine()) in FOLLOW_LAYOUT_ENGINES
|
||
|
||
def _sync_follow_layout_controls(self) -> None:
|
||
"""Enable/disable the switch for the engine, and the picker for the mode."""
|
||
# An English-only model has no non-English weights, so it cannot honour
|
||
# a layout that points anywhere else. Refusing the mode here is honest;
|
||
# the alternative is a switch that silently does nothing.
|
||
supported = self._follow_layout_supported() and not (
|
||
self._is_selected_whispercpp_model_english_only()
|
||
)
|
||
self.follow_layout_row.set_sensitive(supported)
|
||
|
||
if not supported and self.follow_layout_switch.get_active():
|
||
# An engine or model that cannot run the mode must not keep it on.
|
||
with _suppressed(self, "_processing_language_change"):
|
||
self.follow_layout_switch.set_active(False)
|
||
|
||
self._follow_layout_active = bool(self.follow_layout_switch.get_active()) and supported
|
||
active = self._follow_layout_active
|
||
# The picker still shows a language while the mode drives it -- the one
|
||
# the layout resolves to right now -- so it reads as a live readout
|
||
# rather than going blank, and turning the mode off pins what is shown.
|
||
self.language_row.set_sensitive(not active)
|
||
|
||
def _on_follow_layout_toggled(self, *_args: Any) -> None:
|
||
"""Apply the follow mode, or pin whatever the picker shows when it goes off."""
|
||
if _handlers_suppressed(self) or self._processing_language_change:
|
||
return
|
||
|
||
if self.follow_layout_switch.get_active():
|
||
# Show what it resolves to, so the greyed-out picker stays meaningful.
|
||
resolved = language_for_active_layout(SUPPORTED_LANGUAGES)
|
||
if resolved:
|
||
with _suppressed(self, "_processing_language_change"):
|
||
self._set_combo_active_id_or_first(self.language_combo, resolved)
|
||
|
||
self._sync_follow_layout_controls()
|
||
self._update_language_warning()
|
||
self._refresh_simple_readout()
|
||
self._auto_apply_settings()
|
||
|
||
def _on_language_changed(self, widget: Any) -> None:
|
||
"""Handle language selection change.
|
||
|
||
Repaint the model information after the size list rebuilds.
|
||
"""
|
||
if self._processing_language_change:
|
||
return
|
||
if _handlers_suppressed(self) or self._simple_driving:
|
||
return
|
||
|
||
lang_code = self.language_combo.get_active_id()
|
||
if not lang_code:
|
||
return
|
||
|
||
engine = self.engine_combo.get_active_text()
|
||
if not engine:
|
||
return
|
||
|
||
with _suppressed(self, "_processing_language_change"):
|
||
self.language = lang_code
|
||
if _engine_from_display(engine) != "parakeet":
|
||
self._last_non_parakeet_language = lang_code
|
||
self._populate_model_options()
|
||
self._update_language_warning()
|
||
# Explicit refresh: Mock tests assert this call; _update_model_info
|
||
# also refreshes on a real dialog.
|
||
self._refresh_simple_readout()
|
||
self._update_model_info()
|
||
self._auto_apply_settings()
|
||
|
||
def _on_language_entry_activate(self, entry: Any) -> None:
|
||
"""Commit a unique typed match when Enter is pressed in the language entry."""
|
||
self._commit_or_restore_language_entry()
|
||
|
||
def _on_language_entry_focus_out(self, entry: Any, event: Any) -> bool:
|
||
"""Commit or restore the language after the entry loses focus."""
|
||
# Defer so a completion click can set the active id before we restore.
|
||
GLib.idle_add(self._commit_or_restore_language_entry)
|
||
return False
|
||
|
||
def _commit_or_restore_language_entry(self) -> bool:
|
||
"""Resolve typed language text, or restore the last valid selection."""
|
||
if self._processing_language_change or self._initializing:
|
||
return False
|
||
if self.language_combo.get_active_id():
|
||
return False
|
||
entry = self.language_combo.get_child()
|
||
typed = entry.get_text() if entry is not None else ""
|
||
match_id = _resolve_combo_text_query(typed, _combo_text_rows(self.language_combo))
|
||
if match_id:
|
||
self.language_combo.set_active_id(match_id)
|
||
return False
|
||
# Restoring the same language should not re-apply settings.
|
||
with _suppressed(self, "_processing_language_change"):
|
||
self._set_combo_active_id_or_first(self.language_combo, self.language)
|
||
return False
|
||
|
||
def _sync_simple_from_advanced(self) -> None:
|
||
"""Point the simple questions at the configuration that is actually live.
|
||
|
||
Switching modes must not change the model on its own, so the priority is
|
||
read back from the size already chosen rather than reset to a default.
|
||
|
||
A leftover simple multi answer is restored only when it still decodes
|
||
to the live Advanced language. Otherwise the next simple edit would
|
||
silently replace it: a named second language over a pin becomes auto,
|
||
and two English answers over Advanced auto pin English. Two English
|
||
answers still pin English, so that configuration is kept on an English
|
||
pin and dropped when Advanced is auto.
|
||
"""
|
||
language = self.language_combo.get_active_id() or self.language or "auto"
|
||
|
||
if _is_following_layout(self):
|
||
# Neither simple question can say "follow the layout", so show the
|
||
# resolved language as a disabled readout without changing the mode.
|
||
with _suppressed(self, "_simple_syncing"):
|
||
if not self.simple_language_combo.set_active_id(language):
|
||
self.simple_language_combo.clear_active("Detected each time")
|
||
self.simple_language_row.set_sensitive(False)
|
||
self.simple_language_row.set_tooltip_text(
|
||
"Follows your keyboard layout. Turn off Follow keyboard layout "
|
||
"in Advanced to pick a language."
|
||
)
|
||
recommended, _ = self._get_recommended_whispercpp_model_for_language()
|
||
current = self._get_selected_whispercpp_model()
|
||
self.simple_priority_combo.set_active_id(
|
||
priority_for_size(
|
||
get_whispercpp_model_size(recommended),
|
||
get_whispercpp_model_size(current),
|
||
)
|
||
)
|
||
self._update_simple_visibility()
|
||
return
|
||
|
||
self.simple_language_row.set_sensitive(True)
|
||
self.simple_language_row.set_tooltip_text(LANGUAGE_TOOLTIP)
|
||
is_auto = language == "auto"
|
||
|
||
stored_second = self.config_manager.get("speech_recognition", "simple_second_language", "")
|
||
|
||
with _suppressed(self, "_simple_syncing"):
|
||
if is_auto:
|
||
self.simple_multi_switch.set_active(True)
|
||
primary = self.simple_language_combo.get_active_id()
|
||
if not primary:
|
||
self.simple_language_combo.set_active_id("en-us")
|
||
primary = "en-us"
|
||
else:
|
||
self.simple_language_combo.set_active_id(language)
|
||
primary = language
|
||
|
||
keeps_live = bool(stored_second) and (
|
||
_decode_simple_languages(primary, True, stored_second) == language
|
||
)
|
||
if keeps_live:
|
||
if not is_auto:
|
||
self.simple_multi_switch.set_active(True)
|
||
self.simple_second_language_combo.set_active_id(stored_second)
|
||
else:
|
||
if not is_auto:
|
||
self.simple_multi_switch.set_active(False)
|
||
if stored_second:
|
||
self.config_manager.set("speech_recognition", "simple_second_language", "")
|
||
# Reset so turning the switch on later starts from "any", not a
|
||
# stale pick that Advanced already superseded.
|
||
self.simple_second_language_combo.set_active_id("auto")
|
||
|
||
recommended, _ = self._get_recommended_whispercpp_model_for_language()
|
||
current = self._get_selected_whispercpp_model()
|
||
priority = priority_for_size(
|
||
get_whispercpp_model_size(recommended), get_whispercpp_model_size(current)
|
||
)
|
||
self.simple_priority_combo.set_active_id(priority)
|
||
self._update_simple_visibility()
|
||
|
||
def _simple_decoding_language(self) -> str:
|
||
"""Resolve the two simple language answers into what the engine accepts."""
|
||
primary = self.simple_language_combo.get_active_id() or "en-us"
|
||
return _decode_simple_languages(
|
||
primary,
|
||
self.simple_multi_switch.get_active(),
|
||
self.simple_second_language_combo.get_active_id(),
|
||
)
|
||
|
||
def _on_disk_stand_in(self, variant: str, size: str, language: str) -> str:
|
||
"""Prefer a downloaded weight of the same size over fetching a sibling.
|
||
|
||
Flipping the "other languages" switch swapped base for base.en, or back:
|
||
same size, different weights, and a modal download each way, blocking the
|
||
window for it. A same-size weight already on disk that can serve the
|
||
language stands in instead; the info card still offers the better variant
|
||
as a download, it just no longer forces it.
|
||
"""
|
||
return whispercpp_on_disk_stand_in(variant, size, _language_is_english(language))
|
||
|
||
def _apply_simple_choice(self) -> None:
|
||
"""Drive the advanced controls from the simple questions.
|
||
|
||
Simple mode deliberately steers the existing widgets instead of writing the
|
||
configuration itself, so applying, downloading and the info card keep going
|
||
through exactly one code path.
|
||
|
||
Faster Whisper and Parakeet stay selected: language still follows the
|
||
simple questions, but the engine is not forced to whisper.cpp. Other
|
||
engines are steered onto whisper.cpp and get the full size/variant derive.
|
||
"""
|
||
language = self._simple_decoding_language()
|
||
priority = self.simple_priority_combo.get_active_id() or BALANCED
|
||
|
||
engine = self._get_selected_engine()
|
||
if engine not in ("faster_whisper", "parakeet"):
|
||
self.engine_combo.set_active_id("whisper_cpp")
|
||
engine = "whisper_cpp"
|
||
|
||
if not _is_following_layout(self):
|
||
self._set_combo_active_id_or_first(self.language_combo, language)
|
||
self.language = language
|
||
|
||
if engine != "whisper_cpp":
|
||
return
|
||
|
||
recommended, _ = self._get_recommended_whispercpp_model_for_language()
|
||
size = size_for_priority(get_whispercpp_model_size(recommended), priority)
|
||
variant_language = (
|
||
cast(str, _language_for_whispercpp_variant(self))
|
||
if _is_following_layout(self)
|
||
else language
|
||
)
|
||
variant = _default_whispercpp_variant_for_size(size, variant_language) or size
|
||
variant = self._on_disk_stand_in(variant, size, variant_language)
|
||
|
||
self.model_combo.set_active_id(size)
|
||
self._populate_whispercpp_variant_options(size, variant)
|
||
self.model_variant_combo.set_active_id(variant)
|
||
|
||
def _on_advanced_expanded(self, expander, _param) -> None:
|
||
"""Expand or collapse the advanced island, remembering the choice."""
|
||
expanded = expander.get_expanded()
|
||
if expanded:
|
||
cast(Any, self.advanced_box).show_all()
|
||
# Per-engine visibility has to run after show_all, which would
|
||
# otherwise reveal rows the active engine does not use.
|
||
self._update_engine_specific_ui()
|
||
else:
|
||
self._sync_simple_from_advanced()
|
||
if not self._initializing:
|
||
self.config_manager.set("speech_recognition", "show_advanced", expanded)
|
||
self.config_manager.save_settings()
|
||
|
||
def _refresh_simple_readout(self) -> None:
|
||
"""Keep the simple answers describing the live model.
|
||
|
||
With both cards on screen, a change made in the advanced rows has to show
|
||
in the simple ones too, or the two would contradict each other. Skipped
|
||
while simple mode is itself steering the advanced rows, and during init.
|
||
"""
|
||
if self._initializing or self._simple_driving or self._simple_syncing:
|
||
return
|
||
self._sync_simple_from_advanced()
|
||
|
||
def _on_simple_choice_changed(self, *_args) -> None:
|
||
"""React to one of the simple questions changing.
|
||
|
||
Driving the four advanced controls emits "changed" on each of them, and
|
||
every one of those handlers ends in _auto_apply_settings, so a single pick
|
||
used to reconfigure the engine up to four times and freeze the window while
|
||
each reload ran. Suppress those while steering, then apply exactly once.
|
||
"""
|
||
if _handlers_suppressed(self) or self._simple_syncing:
|
||
return
|
||
if self._simple_driving:
|
||
return
|
||
|
||
# Turning the switch on with nothing picked yet means "any language";
|
||
# otherwise the switch alone would visibly do nothing.
|
||
if (
|
||
self.simple_multi_switch.get_active()
|
||
and not self.simple_second_language_combo.get_active_id()
|
||
):
|
||
with _suppressed(self, "_simple_syncing"):
|
||
self.simple_second_language_combo.set_active_id("auto")
|
||
|
||
with _suppressed(self, "_simple_driving"):
|
||
self._apply_simple_choice()
|
||
|
||
second = (
|
||
self.simple_second_language_combo.get_active_id()
|
||
if self.simple_multi_switch.get_active()
|
||
else ""
|
||
)
|
||
self.config_manager.set("speech_recognition", "simple_second_language", second or "")
|
||
self._update_simple_visibility()
|
||
# A simple answer resolves to a complete spec, so its pick keeps the
|
||
# instant apply-and-download flow the Advanced pickers no longer get.
|
||
self._auto_apply_settings(allow_download=True)
|
||
|
||
def _commit_or_restore_simple_language_entry(self) -> bool:
|
||
"""Resolve text typed into the simple language box, or restore the last pick."""
|
||
if self._initializing or self._simple_syncing:
|
||
return False
|
||
if self.simple_language_combo.get_active_id():
|
||
return False
|
||
|
||
entry = self.simple_language_combo.get_child()
|
||
typed = entry.get_text() if entry is not None else ""
|
||
match_id = _resolve_combo_text_query(typed, _combo_text_rows(self.simple_language_combo))
|
||
if match_id:
|
||
self.simple_language_combo.set_active_id(match_id)
|
||
return False
|
||
|
||
# Restoring the same language must not re-apply settings.
|
||
with _suppressed(self, "_simple_syncing"):
|
||
fallback = self.language if self.language != "auto" else "en-us"
|
||
self._set_combo_active_id_or_first(self.simple_language_combo, fallback)
|
||
return False
|
||
|
||
def _on_simple_language_entry_activate(self, _entry: Any) -> None:
|
||
self._commit_or_restore_simple_language_entry()
|
||
|
||
def _on_simple_language_entry_focus_out(self, _entry: Any, _event: Any) -> bool:
|
||
return self._commit_or_restore_simple_language_entry()
|
||
|
||
def _update_simple_visibility(self) -> None:
|
||
"""Show the simple questions, with the second language only when asked for."""
|
||
cast(Any, self.simple_group).show_all()
|
||
wants_second = self.simple_multi_switch.get_active()
|
||
_set_no_show_all_visible(self.simple_second_language_row, wants_second)
|
||
|
||
def _update_engine_specific_ui(self):
|
||
"""Show/hide UI elements driven by the active engine."""
|
||
engine_text = self.engine_combo.get_active_text()
|
||
engine = _engine_from_display(engine_text) if engine_text else "vosk"
|
||
is_remote = engine == "remote_api"
|
||
|
||
if is_remote:
|
||
self.model_row.hide()
|
||
self.model_variant_row.hide()
|
||
if self.unused_island is not None:
|
||
self.unused_island.hide()
|
||
self.model_info_card.hide()
|
||
self.remote_server_group.show_all()
|
||
self.remote_status_label.show()
|
||
else:
|
||
self.model_row.show_all()
|
||
if engine == "whisper_cpp":
|
||
self.model_variant_row.show_all()
|
||
else:
|
||
self.model_variant_row.hide()
|
||
self.remote_server_group.hide()
|
||
self.remote_status_label.hide()
|
||
|
||
self._update_simple_visibility()
|
||
|
||
self._update_model_info()
|
||
self._refresh_unused_downloads()
|
||
self._update_language_warning()
|
||
if engine == "parakeet":
|
||
self.language_row.hide()
|
||
self.language_warning.hide()
|
||
else:
|
||
self.language_row.show_all()
|
||
self._update_model_picker_tooltips()
|
||
self._update_advanced_tab_sensitivity()
|
||
|
||
def _update_advanced_tab_sensitivity(self):
|
||
"""Enable or disable advanced settings based on selected engine."""
|
||
is_whispercpp = self._get_selected_engine() == "whisper_cpp"
|
||
|
||
widgets = [
|
||
self.advanced_no_timestamps_switch,
|
||
self.advanced_no_context_switch,
|
||
self.advanced_initial_prompt_textview,
|
||
self.advanced_temperature_spin,
|
||
self.advanced_temperature_inc_spin,
|
||
self.advanced_entropy_thold_spin,
|
||
self.advanced_logprob_thold_spin,
|
||
self.advanced_no_speech_thold_spin,
|
||
self.advanced_reset_button,
|
||
]
|
||
for widget in widgets:
|
||
widget.set_sensitive(is_whispercpp)
|
||
|
||
if is_whispercpp:
|
||
self.advanced_info_label.set_text("These settings apply to the whisper.cpp engine.")
|
||
else:
|
||
self.advanced_info_label.set_text(
|
||
"These settings only apply when the whisper.cpp engine is selected."
|
||
)
|
||
|
||
def _update_model_info(self):
|
||
"""Update the model info card display."""
|
||
self._refresh_simple_readout()
|
||
engine_text = self.engine_combo.get_active_text()
|
||
if not engine_text:
|
||
self.model_info_card.hide()
|
||
return
|
||
|
||
engine = _engine_from_display(engine_text)
|
||
|
||
if engine == "whisper_cpp":
|
||
model_name = self._get_selected_whispercpp_model()
|
||
else:
|
||
model_id = self.model_combo.get_active_id()
|
||
if not model_id:
|
||
self.model_info_card.hide()
|
||
return
|
||
model_name = model_id.lower()
|
||
|
||
if engine == "whisper":
|
||
if model_name not in WHISPER_MODEL_INFO:
|
||
self.model_info_card.hide()
|
||
return
|
||
info = WHISPER_MODEL_INFO[model_name]
|
||
is_downloaded = _is_whisper_model_downloaded(model_name)
|
||
recommended, reason = _get_recommended_whisper_model()
|
||
extra_info = f"Parameters: {info['params']}"
|
||
elif engine == "whisper_cpp":
|
||
if model_name not in WHISPERCPP_MODEL_INFO:
|
||
self.model_info_card.hide()
|
||
return
|
||
info = WHISPERCPP_MODEL_INFO[model_name]
|
||
is_downloaded = is_whispercpp_model_downloaded(model_name)
|
||
recommended, reason = self._get_recommended_whispercpp_model_for_language()
|
||
backend, backend_info = detect_compute_backend()
|
||
extra_info = (
|
||
f"Parameters: {info['params']} • Backend: {get_backend_display_name(backend)}"
|
||
)
|
||
elif engine == "vosk":
|
||
if model_name not in VOSK_MODEL_INFO:
|
||
self.model_info_card.hide()
|
||
return
|
||
info = VOSK_MODEL_INFO[model_name]
|
||
is_downloaded = _is_vosk_model_downloaded(model_name, self.language)
|
||
recommended, reason = _get_recommended_vosk_model()
|
||
extra_info = f"Size: {_format_size(info['size_mb'])}"
|
||
elif engine == "parakeet":
|
||
if model_name not in parakeet.PARAKEET_MODEL_INFO:
|
||
self.model_info_card.hide()
|
||
return
|
||
info = parakeet.PARAKEET_MODEL_INFO[model_name]
|
||
is_downloaded = parakeet.is_model_downloaded(model_name)
|
||
recommended, reason = parakeet.RECOMMENDED_MODEL, parakeet.RECOMMENDED_REASON
|
||
extra_info = f"Size: {_format_size(info['size_mb'])}"
|
||
elif engine == "faster_whisper":
|
||
if model_name not in FASTER_WHISPER_MODEL_INFO:
|
||
self.model_info_card.hide()
|
||
return
|
||
info = FASTER_WHISPER_MODEL_INFO[model_name]
|
||
is_downloaded = is_faster_whisper_model_downloaded(model_name)
|
||
recommended, reason = get_recommended_faster_whisper_model()
|
||
recommended, reason = _recommended_faster_whisper_variant_for_language(
|
||
recommended,
|
||
reason,
|
||
self.language_combo.get_active_id() or self.language,
|
||
)
|
||
extra_info = f"Parameters: {info['params']}"
|
||
else:
|
||
self.model_info_card.hide()
|
||
return
|
||
|
||
model_display_name = _model_display_name(model_name)
|
||
recommended_display_name = _model_display_name(recommended)
|
||
recommended_info = {
|
||
"whisper": WHISPER_MODEL_INFO,
|
||
"whisper_cpp": WHISPERCPP_MODEL_INFO,
|
||
"vosk": VOSK_MODEL_INFO,
|
||
"parakeet": parakeet.PARAKEET_MODEL_INFO,
|
||
"faster_whisper": FASTER_WHISPER_MODEL_INFO,
|
||
}[engine].get(recommended, {})
|
||
recommended_size_mb = cast(int, recommended_info.get("size_mb", 0))
|
||
|
||
self.model_info_title.set_markup(f"<b>{model_display_name}</b>: {info['desc']}")
|
||
|
||
if is_downloaded:
|
||
status = "<span foreground='#26a269'>Downloaded</span>"
|
||
else:
|
||
status = f"<span foreground='#e5a50a'>Download ~{_format_size(info['size_mb'])}</span>"
|
||
self.model_info_subtitle.set_markup(f"{extra_info} · {status}")
|
||
# The confirm action follows the displayed spec: offered only while it
|
||
# names a model that is not on disk (#894).
|
||
self.model_download_button.set_visible(not is_downloaded)
|
||
|
||
recommendation_differs = (
|
||
get_whispercpp_model_size(recommended) != get_whispercpp_model_size(model_name)
|
||
if engine == "whisper_cpp"
|
||
else recommended != model_name
|
||
)
|
||
if not recommendation_differs:
|
||
self.model_recommendation_box.hide()
|
||
else:
|
||
self.model_recommendation.set_text(
|
||
f"Suggested for this computer: {recommended_display_name} "
|
||
f"({_format_size(recommended_size_mb)}, {reason})"
|
||
)
|
||
self.model_recommendation.show()
|
||
self.model_recommendation_box.show()
|
||
|
||
self._update_model_picker_tooltips()
|
||
self.model_info_card.show()
|
||
self.model_info_title.show()
|
||
self.model_info_subtitle.show()
|
||
|
||
def _auto_apply_settings(self, allow_download: bool = False) -> None:
|
||
"""Automatically apply settings when changed.
|
||
|
||
A picker change stages a model spec; it never confirms a download.
|
||
``allow_download`` is passed only by the simple questions, whose each
|
||
answer resolves to a complete spec, so their pick keeps the existing
|
||
apply-and-download flow. Every other caller leaves a missing model
|
||
staged: the pickers keep it, the info card offers Download, and only
|
||
``apply_settings`` (that button, or Test) starts the transfer (#894).
|
||
"""
|
||
if _handlers_suppressed(self):
|
||
return
|
||
|
||
if self._test_active:
|
||
return
|
||
|
||
if self._populating_models:
|
||
return
|
||
|
||
# Simple mode is mid-way through steering the advanced controls; it applies
|
||
# once itself when it is done.
|
||
if self._simple_driving:
|
||
return
|
||
|
||
# Bump before the guard is set so a close-time stash always reads a
|
||
# generation that already includes this in-flight apply.
|
||
global _apply_settings_generation
|
||
_apply_settings_generation += 1
|
||
apply_generation = _apply_settings_generation
|
||
self._applying_settings = True
|
||
# Already-downloaded apply is handed to a worker that clears this flag
|
||
# via GLib.idle_add. Download still holds it for the modal run() below.
|
||
worker_holds_guard = False
|
||
try:
|
||
settings = self.get_selected_settings()
|
||
# Collecting consumed every deferred text field; clear the flags
|
||
# here so edits that arrived mid-apply still schedule a follow-up.
|
||
self._advanced_prompt_dirty = False
|
||
self._language_candidates_dirty = False
|
||
self._pending_text_edits = None
|
||
self._pending_text_edit_baseline = None
|
||
engine = settings.get("engine", "vosk")
|
||
model_name = settings.get("model_size", "small")
|
||
|
||
# Check if model needs to be downloaded
|
||
missing_model = _undownloaded_model_info(self.language, engine, model_name)
|
||
|
||
if missing_model is not None and not allow_download:
|
||
# Staging, not confirming: leave the staged spec in the pickers
|
||
# and the saved one in the config, and repaint the info card so
|
||
# its Download action offers the explicit confirmation (#894).
|
||
self._staged_model_spec = {
|
||
"engine": engine,
|
||
"model_size": model_name,
|
||
"model_variant": settings.get("model_variant", ""),
|
||
"language": self.language,
|
||
}
|
||
# Edits outside the spec are not part of the staging: apply
|
||
# and persist them so VAD, timeouts and advanced params are
|
||
# not dropped while a download is staged. Not reconfigure() —
|
||
# it cancels a buffered recording and waits on its worker
|
||
# here on the GTK thread, and every whispercpp_* kwarg forces
|
||
# a model reload. Apply live values through the
|
||
# manager so an overlapping model rollback cannot discard them;
|
||
# load-time params stay saved-only until the Download apply
|
||
# or the next start.
|
||
non_model_settings = {
|
||
key: value for key, value in settings.items() if key not in _MODEL_SPEC_KEYS
|
||
}
|
||
applied = True
|
||
live_settings: dict[str, Any] = {}
|
||
for key, clamp in _LIVE_ENGINE_SETTINGS.items():
|
||
if key not in non_model_settings:
|
||
continue
|
||
try:
|
||
live_value = clamp(non_model_settings[key])
|
||
except (TypeError, ValueError) as e:
|
||
logger.error(f"Invalid {key} while staging: {e}")
|
||
applied = False
|
||
continue
|
||
live_settings[key] = live_value
|
||
with _live_settings_lock:
|
||
if live_settings:
|
||
self.speech_engine.update_live_settings(**live_settings)
|
||
if non_model_settings and applied:
|
||
self._save_selected_settings(non_model_settings)
|
||
self._update_model_info()
|
||
return
|
||
|
||
if missing_model is not None:
|
||
if not self._confirm_model_download(
|
||
engine, model_name, int(missing_model["size_mb"])
|
||
):
|
||
self._drop_staged_model_spec_and_resync()
|
||
return
|
||
if not self.speech_engine.try_begin_download():
|
||
# The tray is already downloading a model; a second download
|
||
# would fight it over the engine's progress callback and
|
||
# configuration. Put the picker back on the saved model.
|
||
self._show_download_busy_dialog()
|
||
self._drop_staged_model_spec_and_resync()
|
||
return
|
||
logger.info(f"Model {model_name} needs download, showing progress dialog")
|
||
download_dialog = _make_download_dialog(
|
||
self, model_name, missing_model["size_mb"], engine
|
||
)
|
||
|
||
def progress_callback(fraction, speed, status):
|
||
GLib.idle_add(download_dialog.update_progress, fraction, speed, status)
|
||
|
||
def download_and_apply():
|
||
try:
|
||
self.speech_engine.set_download_progress_callback(progress_callback)
|
||
|
||
def check_cancelled():
|
||
if download_dialog.cancelled:
|
||
self.speech_engine.cancel_download()
|
||
return True
|
||
|
||
cancel_check_id = GLib.timeout_add(100, check_cancelled)
|
||
|
||
try:
|
||
applied = self._apply_settings_internal(
|
||
settings, raise_errors=True, force_reinit=True
|
||
)
|
||
if applied:
|
||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||
GLib.idle_add(self._populate_model_options)
|
||
else:
|
||
# raise_errors makes failures raise today, but a
|
||
# False return must not be read as success: it
|
||
# means nothing was saved and the pickers still
|
||
# show settings the engine never took.
|
||
GLib.idle_add(self._idle_drop_staged_model_spec_and_resync)
|
||
GLib.idle_add(
|
||
download_dialog.set_complete,
|
||
False,
|
||
"Could not apply the new settings",
|
||
)
|
||
finally:
|
||
GLib.source_remove(cancel_check_id)
|
||
self.speech_engine.set_download_progress_callback(None)
|
||
self.speech_engine.end_download()
|
||
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
# The settings were never saved, so the previously working
|
||
# model is still the configured one; put the pickers back on
|
||
# it so the UI matches the config and a retry is possible.
|
||
GLib.idle_add(self._idle_drop_staged_model_spec_and_resync)
|
||
if "cancelled" in error_msg.lower():
|
||
GLib.idle_add(
|
||
download_dialog.set_complete,
|
||
False,
|
||
"Download cancelled",
|
||
)
|
||
elif engine == "whisper" and "no module named" in error_msg.lower():
|
||
GLib.idle_add(
|
||
download_dialog.set_complete,
|
||
False,
|
||
"Whisper not installed",
|
||
)
|
||
GLib.idle_add(self._show_whisper_install_dialog)
|
||
else:
|
||
GLib.idle_add(download_dialog.set_complete, False, error_msg[:100])
|
||
|
||
threading.Thread(target=download_and_apply, daemon=True).start()
|
||
download_dialog.run()
|
||
download_dialog.destroy()
|
||
# Whatever ended the modal — cancel, failure, or a path not
|
||
# covered above — the pickers must not outlive the config.
|
||
self._drop_staged_model_spec_and_resync()
|
||
# Repaint the info card too: after a success it still showed the
|
||
# pre-download render (amber size line, Download action) until
|
||
# some other change repainted it (#894 follow-up).
|
||
self._update_model_info()
|
||
return
|
||
|
||
logger.info(f"Auto-applying settings: {settings}")
|
||
|
||
def apply_already_downloaded() -> None:
|
||
try:
|
||
self._apply_settings_internal(
|
||
settings, raise_errors=True, apply_generation=apply_generation
|
||
)
|
||
logger.info("Settings auto-applied successfully")
|
||
except Exception as e:
|
||
logger.error(f"Failed to auto-apply settings: {e}")
|
||
GLib.idle_add(self._idle_resync_model_ui_from_config)
|
||
finally:
|
||
GLib.idle_add(self._finish_auto_apply)
|
||
|
||
threading.Thread(target=apply_already_downloaded, daemon=True).start()
|
||
worker_holds_guard = True
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"Failed to auto-apply settings: {e}")
|
||
self._resync_model_ui_from_config()
|
||
finally:
|
||
if not worker_holds_guard:
|
||
self._applying_settings = False
|
||
|
||
def _finish_auto_apply(self) -> bool:
|
||
"""Release the apply-guard after an already-downloaded worker finishes.
|
||
|
||
Always resync while the dialog is alive: handlers early-return during
|
||
apply, so selected settings can still match the saved config even when
|
||
a combo (whisper.cpp size) has already moved.
|
||
"""
|
||
self._applying_settings = False
|
||
if self._dialog_is_alive():
|
||
if self._advanced_prompt_dirty or self._language_candidates_dirty:
|
||
# Edits landed while the guard was held; run their apply now.
|
||
self._auto_apply_settings()
|
||
else:
|
||
self._resync_model_ui_from_config()
|
||
else:
|
||
# Closed mid-apply: the running apply saved an older snapshot, so
|
||
# write any edits it could not see now that it has finished.
|
||
self._persist_pending_text_edits()
|
||
return False
|
||
|
||
def _idle_resync_model_ui_from_config(self) -> bool:
|
||
"""Main-loop resync from a worker thread; no-op if the dialog is gone."""
|
||
if self._dialog_is_alive():
|
||
self._resync_model_ui_from_config()
|
||
return False
|
||
|
||
def _drop_staged_model_spec_and_resync(self) -> None:
|
||
"""Forget a model intent that was not applied and restore saved pickers."""
|
||
self._staged_model_spec = None
|
||
self._resync_model_ui_from_config()
|
||
|
||
def _idle_drop_staged_model_spec_and_resync(self) -> bool:
|
||
"""Drop an unapplied model intent before resyncing from a worker thread."""
|
||
self._staged_model_spec = None
|
||
return self._idle_resync_model_ui_from_config()
|
||
|
||
def _resync_model_ui_from_config(self):
|
||
"""Put the pickers back on the settings that are actually saved.
|
||
|
||
Called when applying failed: the config still names the previous
|
||
engine and model, and leaving the pickers on the rejected ones also
|
||
blocks a retry, since re-selecting the same entry emits no "changed"
|
||
signal.
|
||
"""
|
||
try:
|
||
sr_config = self.config_manager.get_settings().get("speech_recognition", {})
|
||
saved_engine = sr_config.get("engine")
|
||
if saved_engine:
|
||
display = _engine_display_name(saved_engine)
|
||
if self.engine_combo.get_active_text() != display:
|
||
# Hold the apply-suppression flag: restoring the combo fires
|
||
# _on_engine_changed(), which must repaint the pickers but
|
||
# not cascade into another apply or download prompt. The
|
||
# outer frame may already hold the guard, so restore the
|
||
# previous value rather than clearing it.
|
||
was_applying = self._applying_settings
|
||
self._applying_settings = True
|
||
try:
|
||
self.engine_combo.set_active_id(display)
|
||
finally:
|
||
self._applying_settings = was_applying
|
||
self._populate_model_options()
|
||
saved_language = sr_config.get("language")
|
||
follow = saved_language == LANGUAGE_FOLLOWS_LAYOUT
|
||
self._follow_layout_saved = follow
|
||
resolved_language = (
|
||
language_for_active_layout(SUPPORTED_LANGUAGES) or "auto"
|
||
if follow
|
||
else saved_language
|
||
)
|
||
if resolved_language:
|
||
self._sync_language_options_for_selected_model(resolved_language)
|
||
with _suppressed(self, "_processing_language_change"):
|
||
self.follow_layout_switch.set_active(follow and self._follow_layout_supported())
|
||
self._sync_follow_layout_controls()
|
||
self._update_language_warning()
|
||
self._update_model_info()
|
||
self._sync_simple_from_advanced()
|
||
except Exception as e: # pragma: no cover - UI resync must never mask the original error
|
||
logger.debug(f"Could not resync model pickers: {e}")
|
||
|
||
def _resync_engine_ui_if_unapplied(self):
|
||
"""Resync when the engine on screen is not the engine that got saved.
|
||
|
||
Covers every way an apply can end without writing the config: a
|
||
cancelled download, a failure, and Remote API left without a server
|
||
URL, where applying is deliberately deferred. Leaving the combo on
|
||
the unapplied engine misreports what dictation will actually use.
|
||
"""
|
||
try:
|
||
saved_engine = (
|
||
self.config_manager.get_settings().get("speech_recognition", {}).get("engine")
|
||
)
|
||
if not saved_engine:
|
||
return
|
||
if self._get_selected_engine() == saved_engine:
|
||
return
|
||
self._resync_model_ui_from_config()
|
||
except Exception as e: # pragma: no cover - a resync must never mask the real error
|
||
logger.debug(f"Could not check the engine picker against the config: {e}")
|
||
|
||
def _save_selected_settings(self, settings: dict[str, Any]) -> None:
|
||
"""Persist selected settings to their appropriate config sections."""
|
||
sr_settings = {k: v for k, v in settings.items() if not k.startswith("whispercpp_")}
|
||
advanced_settings = {k: v for k, v in settings.items() if k.startswith("whispercpp_")}
|
||
|
||
self.config_manager.update_speech_recognition_settings(sr_settings)
|
||
for key, value in advanced_settings.items():
|
||
self.config_manager.set("advanced", key, value)
|
||
self.config_manager.save_settings()
|
||
if any(key in settings for key in _MODEL_SPEC_KEYS):
|
||
# A real spec just persisted, so nothing is staged anymore (#894).
|
||
self._staged_model_spec = None
|
||
|
||
def get_selected_settings(self) -> dict[str, Any]:
|
||
"""Return the currently selected settings from the UI."""
|
||
engine_text = self.engine_combo.get_active_text()
|
||
model_id = self.model_combo.get_active_id()
|
||
language_id = self.language_combo.get_active_id()
|
||
|
||
engine = _engine_from_display(engine_text) if engine_text else "vosk"
|
||
model_variant = ""
|
||
if engine == "whisper_cpp":
|
||
selected = self._get_selected_whispercpp_model()
|
||
size = get_whispercpp_model_size(selected)
|
||
derived = self._get_default_whispercpp_variant_for_size(size)
|
||
# Full id for reconfigure/download. Pin deliberate specializations
|
||
# (including multilingual while English). A language-derived default
|
||
# stays unpinned so a later language change can re-derive — except
|
||
# when an existing bare-size pin already matches the selection: that
|
||
# is a deliberate multilingual pin that became the new language's
|
||
# default, and must survive so returning to English still honours it.
|
||
model_size = selected
|
||
existing_pin = (
|
||
self.config_manager.get_model_variant_for_engine("whisper_cpp") or ""
|
||
).lower()
|
||
if selected != derived:
|
||
model_variant = selected
|
||
elif existing_pin == selected and selected in WHISPERCPP_MODEL_SIZES:
|
||
model_variant = selected
|
||
else:
|
||
model_variant = ""
|
||
else:
|
||
model_size = model_id.lower() if model_id else "small"
|
||
if engine == "parakeet":
|
||
language = "auto"
|
||
elif _is_following_layout(self):
|
||
language = LANGUAGE_FOLLOWS_LAYOUT
|
||
else:
|
||
language = language_id if language_id else self._default_language_for_engine(engine)
|
||
|
||
vad = int(self.vad_spin.get_value())
|
||
silence = self.silence_spin.get_value()
|
||
|
||
settings = {
|
||
"engine": engine,
|
||
"model_size": model_size,
|
||
"model_variant": model_variant,
|
||
"language": language,
|
||
"vad_sensitivity": vad,
|
||
"silence_timeout": silence,
|
||
"whispercpp_no_timestamps": self.advanced_no_timestamps_switch.get_active(),
|
||
"whispercpp_no_context": self.advanced_no_context_switch.get_active(),
|
||
"whispercpp_initial_prompt": self.advanced_initial_prompt_buffer.get_text(
|
||
self.advanced_initial_prompt_buffer.get_start_iter(),
|
||
self.advanced_initial_prompt_buffer.get_end_iter(),
|
||
False,
|
||
),
|
||
"whispercpp_language_candidates": self.advanced_language_candidates_entry.get_text(),
|
||
"whispercpp_temperature": self.advanced_temperature_spin.get_value(),
|
||
"whispercpp_temperature_inc": self.advanced_temperature_inc_spin.get_value(),
|
||
"whispercpp_entropy_thold": self.advanced_entropy_thold_spin.get_value(),
|
||
"whispercpp_logprob_thold": self.advanced_logprob_thold_spin.get_value(),
|
||
"whispercpp_no_speech_thold": self.advanced_no_speech_thold_spin.get_value(),
|
||
}
|
||
|
||
gpu_device_id = self.gpu_device_combo.get_active_id()
|
||
if gpu_device_id is not None:
|
||
gpu_device_val = int(gpu_device_id)
|
||
settings["whispercpp_gpu_device"] = None if gpu_device_val == -1 else gpu_device_val
|
||
|
||
# Remote API additional settings
|
||
if engine == "remote_api":
|
||
settings["remote_api_url"] = self.remote_api_url_entry.get_text().strip()
|
||
settings["remote_api_key"] = self.remote_api_key_entry.get_text().strip()
|
||
settings["remote_api_endpoint"] = (
|
||
self.remote_api_endpoint_combo.get_active_id() or "/inference"
|
||
)
|
||
settings["remote_api_model"] = (
|
||
self.remote_api_model_entry.get_text().strip() or "whisper-1"
|
||
)
|
||
|
||
return settings
|
||
|
||
def _on_test_clicked(self, widget: Any) -> None:
|
||
"""Handle click on the test button."""
|
||
if self._test_active:
|
||
logger.warning("Test already in progress.")
|
||
return
|
||
|
||
if self._applying_settings:
|
||
# The live engine may still be mid-reconfigure even when the UI
|
||
# already matches the saved config. Do not apply or start a test.
|
||
self.test_output_revealer.set_reveal_child(True)
|
||
self.test_buffer.set_text("Settings are still applying. Try Test again in a moment.")
|
||
return
|
||
|
||
current_config = self.config_manager.get_settings().get("speech_recognition", {})
|
||
selected_settings = self.get_selected_settings()
|
||
selected_engine = selected_settings.get("engine")
|
||
selected_model = selected_settings.get("model_size")
|
||
if selected_engine and selected_model:
|
||
missing_model = _undownloaded_model_info(self.language, selected_engine, selected_model)
|
||
if missing_model is not None:
|
||
self.test_output_revealer.set_reveal_child(True)
|
||
self.test_buffer.set_text(
|
||
f"{_model_display_name(selected_model)} is not downloaded yet. "
|
||
"Press Download on the Speech Model page first, or pick a downloaded model."
|
||
)
|
||
return
|
||
# Compare to the live engine too: UI can match the file while the
|
||
# in-memory manager is still on another engine/size (sidecar, pending apply).
|
||
live_engine = getattr(self.speech_engine, "engine", None)
|
||
live_model = getattr(self.speech_engine, "model_size", None)
|
||
|
||
settings_differ = False
|
||
if (
|
||
current_config.get("engine") != selected_engine
|
||
or current_config.get("model_size") != selected_model
|
||
or live_engine != selected_engine
|
||
or live_model != selected_model
|
||
):
|
||
settings_differ = True
|
||
elif selected_engine == "vosk":
|
||
if current_config.get("vad_sensitivity") != selected_settings.get(
|
||
"vad_sensitivity"
|
||
) or current_config.get("silence_timeout") != selected_settings.get("silence_timeout"):
|
||
settings_differ = True
|
||
|
||
if settings_differ:
|
||
self.test_buffer.set_text("Applying settings...")
|
||
if not self.apply_settings():
|
||
self.test_buffer.set_text("Failed to apply settings. Please try again.")
|
||
return
|
||
self.test_buffer.set_text("Settings applied. Starting test...")
|
||
|
||
self.connect_to_recognition_manager()
|
||
|
||
self._saved_text_callbacks = self.speech_engine.get_text_callbacks()
|
||
self.speech_engine.set_text_callbacks([self._test_text_callback])
|
||
# History recording runs on segment callbacks keyed by capture time:
|
||
# stamp the window so test speech stays out of Recent Snippets while
|
||
# leftover segments from a dictation still file normally.
|
||
self.speech_engine.test_capture_ceiling = None
|
||
self.speech_engine.test_capture_floor = time.monotonic()
|
||
|
||
if not self.speech_engine.start_recognition():
|
||
self.speech_engine.set_text_callbacks(self._saved_text_callbacks)
|
||
self.speech_engine.test_capture_floor = None
|
||
self.speech_engine.test_capture_ceiling = None
|
||
del self._saved_text_callbacks
|
||
self.test_output_revealer.set_reveal_child(True)
|
||
if getattr(self.speech_engine, "is_auto_paused", False):
|
||
self.test_buffer.set_text(
|
||
"Dictation is paused. Close the listed app or remove it from "
|
||
"Auto-Pause settings."
|
||
)
|
||
elif not getattr(self.speech_engine, "model_ready", True):
|
||
self.test_buffer.set_text(
|
||
"No speech model downloaded. Open the Speech Model page and "
|
||
"download a model to use Test Dictation."
|
||
)
|
||
else:
|
||
self.test_buffer.set_text("Could not start recognition test.")
|
||
return
|
||
|
||
self._test_active = True
|
||
self._test_token += 1
|
||
self._test_cancelled = False
|
||
self._test_timeout_cancel_attempted = False
|
||
self.test_button.set_sensitive(False)
|
||
self.test_button.set_label("Testing… Speak Now!")
|
||
self.test_cancel_button.set_sensitive(True)
|
||
self.test_cancel_button.show()
|
||
self.test_output_revealer.set_reveal_child(True)
|
||
self.test_buffer.set_text("")
|
||
self._test_result = ""
|
||
self.update_recognition_progress("Listening", info="Starting recognition test...")
|
||
|
||
# Enough room for one utterance plus the configured silence window.
|
||
delay = float(selected_settings.get("silence_timeout", 1.2)) + 2.0
|
||
threading.Thread(target=self._stop_test_after_delay, args=(delay, self._test_token)).start()
|
||
|
||
def _on_test_cancel_clicked(self, widget: Any) -> None:
|
||
"""Cancel the test and discard any text already captured."""
|
||
if not self._test_active or self._test_cancelled:
|
||
return
|
||
|
||
self._test_cancelled = True
|
||
self.test_button.set_sensitive(False)
|
||
self.test_cancel_button.set_sensitive(False)
|
||
self.test_button.set_label("Cancelling…")
|
||
|
||
def cancel_test() -> None:
|
||
try:
|
||
self.speech_engine.cancel_transcription()
|
||
except (AttributeError, OSError, RuntimeError, TypeError):
|
||
logger.exception("Test Dictation cancellation failed")
|
||
GLib.idle_add(self._finish_test_after_user_cancel)
|
||
|
||
threading.Thread(
|
||
target=cancel_test,
|
||
daemon=True,
|
||
name="voculinux-test-dictation-cancel",
|
||
).start()
|
||
|
||
def _finish_test_after_user_cancel(self) -> bool:
|
||
"""Restore test UI and callbacks without checking for captured speech."""
|
||
self._test_active = False
|
||
self._test_timeout_cancel_attempted = False
|
||
self.test_button.set_sensitive(True)
|
||
self.test_button.set_label("Test Dictation")
|
||
self.test_cancel_button.hide()
|
||
self.test_cancel_button.set_sensitive(True)
|
||
self.update_recognition_progress("Idle")
|
||
if hasattr(self, "_saved_text_callbacks"):
|
||
self.speech_engine.set_text_callbacks(self._saved_text_callbacks)
|
||
del self._saved_text_callbacks
|
||
self.speech_engine.test_capture_ceiling = time.monotonic()
|
||
self.test_buffer.set_text("(Test cancelled)")
|
||
return False
|
||
|
||
def _test_text_callback(self, text: str) -> None:
|
||
"""Callback specifically for the test recognition."""
|
||
GLib.idle_add(self._append_test_result, text)
|
||
|
||
def _append_test_result(self, text: str) -> bool:
|
||
if getattr(self, "_test_cancelled", False) is True:
|
||
return False
|
||
current_text = self.test_buffer.get_text(
|
||
self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False
|
||
)
|
||
separator = " " if current_text.strip() else ""
|
||
self.test_buffer.insert(self.test_buffer.get_end_iter(), separator + text)
|
||
mark = self.test_buffer.get_insert()
|
||
self.test_textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0)
|
||
return False
|
||
|
||
def _stop_test_after_delay(self, delay: float, token: Optional[int] = None) -> None:
|
||
"""Stops the recognition test after a specified delay."""
|
||
time.sleep(delay)
|
||
GLib.idle_add(self._finalize_test, token)
|
||
|
||
def _test_engine_busy(self) -> bool:
|
||
"""Return whether recognition can still deliver text to the test callback."""
|
||
state = getattr(self.speech_engine, "state", RecognitionState.IDLE)
|
||
has_pending_transcription = getattr(self.speech_engine, "has_pending_transcription", None)
|
||
return (
|
||
state in (RecognitionState.LISTENING, RecognitionState.PROCESSING)
|
||
or getattr(self.speech_engine, "_buffered_reload_session", False)
|
||
or (callable(has_pending_transcription) and has_pending_transcription() is True)
|
||
)
|
||
|
||
def _finalize_test(self, token: Optional[int] = None) -> bool:
|
||
"""Finalize the test state and UI updates."""
|
||
if (token is not None and token != getattr(self, "_test_token", 0)) or not getattr(
|
||
self, "_test_active", False
|
||
):
|
||
return False
|
||
if getattr(self, "_test_cancelled", False):
|
||
return False
|
||
|
||
self.test_button.set_sensitive(False)
|
||
self.test_button.set_label("Processing…")
|
||
self.test_cancel_button.set_sensitive(True)
|
||
self.test_cancel_button.show()
|
||
self.update_recognition_progress("Processing")
|
||
|
||
def stop_test() -> None:
|
||
try:
|
||
self.speech_engine.stop_recognition()
|
||
except (AttributeError, OSError, RuntimeError, TypeError):
|
||
logger.exception("Test Dictation stop failed")
|
||
|
||
threading.Thread(
|
||
target=stop_test,
|
||
daemon=True,
|
||
name="voculinux-test-dictation-stop",
|
||
).start()
|
||
|
||
self._test_idle_wait_ticks = 0
|
||
self._test_timeout_cancel_attempted = False
|
||
GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks)
|
||
|
||
return False
|
||
|
||
def _wait_for_idle_then_restore_callbacks(self) -> bool:
|
||
"""Poll until recognition is idle, then restore callbacks / check result.
|
||
|
||
Returns True so GLib.timeout_add keeps scheduling while recognition
|
||
may still deliver text.
|
||
|
||
On the ~3 minute timeout, never restore live injector callbacks while a
|
||
recognition worker may still deliver text (privacy-adjacent Test
|
||
Dictation inject-into-app race). Prefer cancelling a buffered reload via
|
||
``_cancel_reload_recording`` (off the GTK thread — it joins) and only
|
||
then restoring. If cancel is unavailable or fails, keep polling with
|
||
test callbacks and ``_test_active`` so live injectors are not restored
|
||
early and later dictation cannot be hijacked into the test buffer.
|
||
"""
|
||
if getattr(self, "_test_cancelled", False) is True:
|
||
return False
|
||
|
||
if not hasattr(self, "_saved_text_callbacks"):
|
||
self._test_active = False
|
||
self.test_button.set_sensitive(True)
|
||
self.test_button.set_label("Test Dictation")
|
||
self.test_cancel_button.hide()
|
||
return False
|
||
|
||
if self._test_engine_busy():
|
||
self._test_idle_wait_ticks = getattr(self, "_test_idle_wait_ticks", 0) + 1
|
||
# ~3 minutes at 100ms — generous for cold large-model reload.
|
||
if self._test_idle_wait_ticks < 1800:
|
||
return True
|
||
if not getattr(self, "_test_timeout_cancel_attempted", False):
|
||
self._test_timeout_cancel_attempted = True
|
||
return self._on_test_idle_wait_timeout()
|
||
# Cancel already tried (or unavailable): keep waiting — never
|
||
# restore live injectors and never mark the test inactive while busy.
|
||
return True
|
||
|
||
return self._finish_test_restore_ui()
|
||
|
||
def _on_test_idle_wait_timeout(self) -> bool:
|
||
"""Handle idle-wait timeout without restoring live injectors early."""
|
||
cancel = getattr(self.speech_engine, "_cancel_reload_recording", None)
|
||
buffered_reload = getattr(self.speech_engine, "_buffered_reload_session", False) is True
|
||
if buffered_reload and callable(cancel):
|
||
logger.warning(
|
||
"Test Dictation: timed out waiting for recognition IDLE; "
|
||
"cancelling buffered reload before restoring callbacks"
|
||
)
|
||
self.test_button.set_sensitive(False)
|
||
self.test_button.set_label("Cancelling…")
|
||
self.update_recognition_progress("Cancelling")
|
||
threading.Thread(
|
||
target=self._cancel_buffered_reload_then_restore,
|
||
daemon=True,
|
||
name="voculinux-test-dictation-cancel",
|
||
).start()
|
||
return False
|
||
|
||
logger.error(
|
||
"Test Dictation: timed out waiting for recognition IDLE with no safe "
|
||
"buffered cancel; keeping test callbacks until idle"
|
||
)
|
||
self._keep_waiting_after_timeout(
|
||
"(Timed out waiting for recognition to finish. "
|
||
"Still waiting so live injectors are not restored early and later "
|
||
"dictation cannot be hijacked into the test buffer.)"
|
||
)
|
||
return True
|
||
|
||
def _cancel_buffered_reload_then_restore(self) -> None:
|
||
"""Cancel buffered reload off the GTK thread, then restore on idle."""
|
||
cancel = getattr(self.speech_engine, "_cancel_reload_recording", None)
|
||
try:
|
||
if callable(cancel):
|
||
cancel()
|
||
except (AttributeError, OSError, RuntimeError, TypeError):
|
||
logger.exception(
|
||
"Test Dictation: failed to cancel buffered reload after idle-wait timeout"
|
||
)
|
||
GLib.idle_add(self._on_buffered_cancel_failed)
|
||
return
|
||
GLib.idle_add(self._finish_test_after_buffered_cancel)
|
||
|
||
def _on_buffered_cancel_failed(self) -> bool:
|
||
"""Resume idle-wait polling if cancel failed — do not end the test early."""
|
||
self._keep_waiting_after_timeout(
|
||
"(Timed out waiting for recognition, and cancelling the buffered "
|
||
"reload failed. Still waiting for IDLE before restoring live callbacks.)"
|
||
)
|
||
# _test_timeout_cancel_attempted is already True, so the resumed poll
|
||
# will not re-enter cancel — it only waits for IDLE.
|
||
GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks)
|
||
return False
|
||
|
||
def _finish_test_after_buffered_cancel(self) -> bool:
|
||
"""Resume waiting if recognition can still deliver text after cancellation."""
|
||
if self._test_engine_busy():
|
||
GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks)
|
||
return False
|
||
return self._finish_test_restore_ui()
|
||
|
||
def _keep_waiting_after_timeout(self, message: str) -> None:
|
||
"""Keep Test Dictation active with test callbacks until recognition IDLE."""
|
||
self._test_active = True
|
||
self.test_button.set_sensitive(False)
|
||
self.test_button.set_label("Still processing…")
|
||
self.update_recognition_progress("Processing")
|
||
if hasattr(self, "test_buffer"):
|
||
self.test_buffer.set_text(message)
|
||
|
||
def _finish_test_restore_ui(self) -> bool:
|
||
"""Reset Test Dictation chrome and restore saved live callbacks."""
|
||
self._test_active = False
|
||
self._test_timeout_cancel_attempted = False
|
||
self.test_button.set_sensitive(True)
|
||
self.test_button.set_label("Test Dictation")
|
||
self.test_cancel_button.hide()
|
||
self.test_cancel_button.set_sensitive(True)
|
||
self.update_recognition_progress("Idle")
|
||
return self._restore_callbacks_and_check_result()
|
||
|
||
def _restore_callbacks_and_check_result(self) -> bool:
|
||
"""Restore callbacks and check test result after delay."""
|
||
# Restore original text callbacks
|
||
if hasattr(self, "_saved_text_callbacks"):
|
||
self.speech_engine.set_text_callbacks(self._saved_text_callbacks)
|
||
del self._saved_text_callbacks
|
||
# Close the test-capture window rather than reopening history for
|
||
# everything after the floor: segments captured during the test but
|
||
# still decoding must keep failing the window check.
|
||
self.speech_engine.test_capture_ceiling = time.monotonic()
|
||
|
||
# Check result after giving time for final callbacks to complete
|
||
GLib.timeout_add(300, self._check_test_result)
|
||
return False
|
||
|
||
def _check_test_result(self):
|
||
"""Check if any text was captured after all callbacks have run."""
|
||
final_text = self.test_buffer.get_text(
|
||
self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False
|
||
)
|
||
if not final_text.strip():
|
||
self.test_buffer.set_text("(No speech detected during test)")
|
||
return False
|
||
|
||
def _show_whisper_install_dialog(self):
|
||
"""Show a dialog with instructions for installing Whisper."""
|
||
dialog = Gtk.MessageDialog(
|
||
transient_for=self,
|
||
flags=0,
|
||
message_type=Gtk.MessageType.WARNING,
|
||
buttons=Gtk.ButtonsType.OK,
|
||
text="Whisper Not Installed",
|
||
)
|
||
|
||
install_text = """Whisper AI is not installed. To use Whisper for speech recognition, you need to install it first.
|
||
|
||
Installation Options:
|
||
|
||
1. Using the installation script:
|
||
./install.sh --engine=whisper
|
||
|
||
2. Manual installation in virtual environment:
|
||
source venv/bin/activate
|
||
pip install openai-whisper 'torch>=2.13.0'
|
||
|
||
3. If you have SSL issues, try:
|
||
pip install openai-whisper 'torch>=2.13.0' --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host files.pythonhosted.org
|
||
|
||
Note: Whisper requires significant disk space (~1-3GB) and may take time to download.
|
||
|
||
For now, the engine has been reverted to VOSK."""
|
||
|
||
dialog.format_secondary_text(install_text)
|
||
dialog.run()
|
||
dialog.destroy()
|
||
|
||
self.engine_combo.set_active_id("Vosk")
|
||
self._populate_model_options()
|
||
self._update_engine_specific_ui()
|
||
|
||
def _on_download_model_clicked(self, _button: Any) -> None:
|
||
"""Confirm the staged model spec and start its download (#894).
|
||
|
||
The pickers only stage a specification; this is the one explicit
|
||
confirmation that turns it into a transfer. It goes through
|
||
``apply_settings`` so the engine claim, the modal progress dialog, and
|
||
the post-download apply behave exactly like any other confirmed apply.
|
||
"""
|
||
self.apply_settings()
|
||
|
||
def apply_settings(self) -> bool:
|
||
"""Apply the selected settings."""
|
||
if self._applying_settings:
|
||
logger.warning("Ignoring apply_settings(); another apply is already in progress")
|
||
return False
|
||
|
||
global _apply_settings_generation
|
||
_apply_settings_generation += 1
|
||
settings = self.get_selected_settings()
|
||
# Same consumption as _auto_apply_settings: an apply snapshots the
|
||
# deferred text fields, so their pending-edit flags are done.
|
||
self._advanced_prompt_dirty = False
|
||
self._language_candidates_dirty = False
|
||
self._pending_text_edits = None
|
||
self._pending_text_edit_baseline = None
|
||
logger.info(f"Applying settings: {settings}")
|
||
|
||
engine = settings.get("engine", "vosk")
|
||
model_name = settings.get("model_size", "small")
|
||
|
||
missing_model = _undownloaded_model_info(self.language, engine, model_name)
|
||
|
||
if missing_model is not None:
|
||
if not self._confirm_model_download(engine, model_name, int(missing_model["size_mb"])):
|
||
self._drop_staged_model_spec_and_resync()
|
||
return False
|
||
if not self.speech_engine.try_begin_download():
|
||
# Same guard as in _auto_apply_settings: one download at a time.
|
||
self._show_download_busy_dialog()
|
||
self._drop_staged_model_spec_and_resync()
|
||
return False
|
||
download_dialog = _make_download_dialog(
|
||
self, model_name, missing_model["size_mb"], engine
|
||
)
|
||
download_outcome = {"applied": False}
|
||
|
||
def progress_callback(fraction, speed, status):
|
||
GLib.idle_add(download_dialog.update_progress, fraction, speed, status)
|
||
|
||
def download_and_apply():
|
||
try:
|
||
self.speech_engine.set_download_progress_callback(progress_callback)
|
||
|
||
def check_cancelled():
|
||
if download_dialog.cancelled:
|
||
self.speech_engine.cancel_download()
|
||
return True
|
||
|
||
cancel_check_id = GLib.timeout_add(100, check_cancelled)
|
||
|
||
try:
|
||
applied = self._apply_settings_internal(
|
||
settings, raise_errors=True, force_reinit=True
|
||
)
|
||
if applied:
|
||
download_outcome["applied"] = True
|
||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||
else:
|
||
GLib.idle_add(self._idle_drop_staged_model_spec_and_resync)
|
||
GLib.idle_add(
|
||
download_dialog.set_complete,
|
||
False,
|
||
"Could not apply the new settings",
|
||
)
|
||
finally:
|
||
GLib.source_remove(cancel_check_id)
|
||
self.speech_engine.set_download_progress_callback(None)
|
||
self.speech_engine.end_download()
|
||
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
# Nothing was saved, so the config still names the previous
|
||
# engine and model; put the pickers back on them.
|
||
GLib.idle_add(self._idle_drop_staged_model_spec_and_resync)
|
||
if "cancelled" in error_msg.lower():
|
||
GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
|
||
elif engine == "whisper" and "no module named" in error_msg.lower():
|
||
GLib.idle_add(download_dialog.set_complete, False, "Whisper not installed")
|
||
GLib.idle_add(self._show_whisper_install_dialog)
|
||
else:
|
||
GLib.idle_add(download_dialog.set_complete, False, error_msg[:100])
|
||
|
||
threading.Thread(target=download_and_apply, daemon=True).start()
|
||
download_dialog.run()
|
||
download_dialog.destroy()
|
||
|
||
self._populate_model_options()
|
||
if not download_outcome["applied"]:
|
||
self._drop_staged_model_spec_and_resync()
|
||
# Same repaint as the auto-apply path: a finished download must not
|
||
# leave the card offering a Download for the model just fetched.
|
||
self._update_model_info()
|
||
return bool(download_outcome["applied"])
|
||
|
||
return self._apply_settings_internal(settings)
|
||
|
||
def _confirm_model_download(self, engine: str, model_name: str, size_mb: int) -> bool:
|
||
"""Ask before downloading a model selected in Settings."""
|
||
info = _undownloaded_model_info(self.language, engine, model_name) or {}
|
||
url = _model_download_source_url(engine, model_name, self.language, info)
|
||
host = urlparse(url).netloc
|
||
secondary = f"About {_format_size(size_mb)}"
|
||
if host:
|
||
secondary += f" from {host}"
|
||
secondary += ". Nothing is downloaded until you confirm."
|
||
|
||
dialog = Gtk.MessageDialog(
|
||
transient_for=self,
|
||
flags=0,
|
||
message_type=Gtk.MessageType.QUESTION,
|
||
buttons=Gtk.ButtonsType.NONE,
|
||
text=f"Download {_model_display_name(model_name)}?",
|
||
)
|
||
dialog.format_secondary_text(secondary)
|
||
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||
dialog.add_button("Download", Gtk.ResponseType.OK)
|
||
dialog.set_default_response(Gtk.ResponseType.CANCEL)
|
||
try:
|
||
return cast(bool, dialog.run() == Gtk.ResponseType.OK)
|
||
finally:
|
||
dialog.destroy()
|
||
|
||
def _show_download_busy_dialog(self):
|
||
"""Tell the user a model download is already running elsewhere."""
|
||
dialog = Gtk.MessageDialog(
|
||
transient_for=self,
|
||
flags=0,
|
||
message_type=Gtk.MessageType.INFO,
|
||
buttons=Gtk.ButtonsType.OK,
|
||
text="A model download is already running",
|
||
)
|
||
dialog.format_secondary_text("Wait for it to finish, then pick the model again.")
|
||
dialog.run()
|
||
dialog.destroy()
|
||
|
||
def _apply_settings_internal(
|
||
self,
|
||
settings: dict,
|
||
raise_errors: bool = False,
|
||
force_reinit: bool = False,
|
||
apply_generation: Optional[int] = None,
|
||
) -> bool:
|
||
"""Internal method to apply settings.
|
||
|
||
Args:
|
||
settings: The settings to persist and hand to the engine.
|
||
raise_errors: Re-raise failures instead of showing an error dialog.
|
||
The download threads pass True: their own handlers report the
|
||
failure through the progress dialog, and building a Gtk dialog
|
||
off the main loop is not safe anyway.
|
||
force_reinit: Re-initialize the engine even when the selection
|
||
matches its live state. The download threads pass True: they
|
||
only run because the model is missing on disk, and a no-op
|
||
reconfigure would report success for a download that never ran.
|
||
apply_generation: The apply generation captured when this snapshot
|
||
was collected. When a newer apply began since, this snapshot is
|
||
stale and its write is skipped — otherwise lock order, not
|
||
collection order, would decide which snapshot wins.
|
||
"""
|
||
try:
|
||
# Persist only once the engine really runs these settings: this call
|
||
# downloads missing models, and a config saved up front would keep
|
||
# pointing at a model that never made it to disk. The lock orders
|
||
# this snapshot against _persist_pending_text_edits' deferred one —
|
||
# and the staleness check must precede stop_recognition, or a
|
||
# superseded apply would still interrupt a live dictation session.
|
||
with _apply_settings_lock:
|
||
if apply_generation is not None and _apply_settings_generation != apply_generation:
|
||
logger.info(
|
||
"Settings apply superseded by a newer one; " "skipping the stale snapshot"
|
||
)
|
||
return True
|
||
was_running = self.speech_engine.state != RecognitionState.IDLE
|
||
if was_running:
|
||
self.speech_engine.stop_recognition()
|
||
time.sleep(0.5)
|
||
|
||
self.speech_engine.reconfigure(force_reinit=force_reinit, **settings)
|
||
# A second dialog may have accepted live VAD edits while this
|
||
# model was loading. Its queued values are now active; save
|
||
# those instead of overwriting them with this older snapshot.
|
||
with _live_settings_lock:
|
||
accepted_settings = dict(settings)
|
||
for key, clamp in _LIVE_ENGINE_SETTINGS.items():
|
||
if key in accepted_settings:
|
||
accepted_settings[key] = clamp(getattr(self.speech_engine, key))
|
||
self._save_selected_settings(accepted_settings)
|
||
written_gen = (
|
||
apply_generation if apply_generation is not None else _apply_settings_generation
|
||
)
|
||
for key in settings:
|
||
if key.startswith("whispercpp_"):
|
||
_apply_settings_written[key] = written_gen
|
||
|
||
logger.info("Settings applied successfully.")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Failed to apply settings: {e}", exc_info=True)
|
||
if raise_errors:
|
||
raise
|
||
|
||
if "whisper" in str(e).lower() and "no module named" in str(e).lower():
|
||
self._show_whisper_install_dialog()
|
||
else:
|
||
error_dialog = Gtk.MessageDialog(
|
||
transient_for=self,
|
||
flags=0,
|
||
message_type=Gtk.MessageType.ERROR,
|
||
buttons=Gtk.ButtonsType.OK,
|
||
text="Error Applying Settings",
|
||
)
|
||
error_dialog.format_secondary_text(f"Could not apply settings: {e}")
|
||
error_dialog.run()
|
||
error_dialog.destroy()
|
||
return False
|
||
|
||
def _populate_gpu_devices(self) -> None:
|
||
"""Populate the GPU device dropdown with available Vulkan devices."""
|
||
saved_device = self.config_manager.get("advanced", "whispercpp_gpu_device", None)
|
||
_populate_gpu_device_choices(self.gpu_device_combo, saved_device)
|
||
|
||
def _populate_audio_devices(self):
|
||
"""Populate the audio device dropdown with available input devices."""
|
||
from ..speech_recognition.recognition_manager import get_audio_input_devices
|
||
|
||
self.audio_device_combo.remove_all()
|
||
|
||
self.audio_device_combo.append("-1", "System Default")
|
||
|
||
# List the snap's PulseAudio sources once and keep each entry's server
|
||
# name, so choosing a microphone never queries the sound server from
|
||
# the GTK thread. Empty outside the snap.
|
||
pulse_sources = get_pulse_sources()
|
||
self._pulse_source_names = {
|
||
PULSE_INDEX_BASE - position: source.name
|
||
for position, source in enumerate(pulse_sources)
|
||
}
|
||
devices = get_audio_input_devices(pulse_sources=pulse_sources)
|
||
|
||
for device_index, device_name, is_default in devices:
|
||
label = device_name
|
||
if is_default:
|
||
label += " (default)"
|
||
self.audio_device_combo.append(str(device_index), label)
|
||
|
||
saved_device = self.config_manager.get_optional_int("audio", "device_index", None)
|
||
saved_device_name = self.config_manager.get("audio", "device_name", None)
|
||
|
||
if saved_device is None:
|
||
self.audio_device_combo.set_active_id("-1")
|
||
else:
|
||
matched_index = _resolve_audio_device_selection(
|
||
devices, saved_device, saved_device_name, pulse_sources
|
||
)
|
||
if matched_index is not None:
|
||
self.audio_device_combo.set_active_id(str(matched_index))
|
||
# Migrate legacy configs that stored the UI "(default)" suffix.
|
||
saved_raw_name = _raw_audio_device_name(saved_device_name)
|
||
if saved_device_name and saved_raw_name and saved_device_name != saved_raw_name:
|
||
self.config_manager.set("audio", "device_name", saved_raw_name)
|
||
self.config_manager.set("audio", "device_index", matched_index)
|
||
self.config_manager.save_settings()
|
||
else:
|
||
logger.warning(
|
||
f"Saved audio device {saved_device} "
|
||
f"(name: {saved_device_name}) no longer available"
|
||
)
|
||
self.audio_device_combo.set_active_id("-1")
|
||
# Keep combo, config, and engine aligned on System Default.
|
||
self.config_manager.set("audio", "device_index", None)
|
||
self.config_manager.set("audio", "device_name", None)
|
||
self.config_manager.save_settings()
|
||
if self.speech_engine is not None:
|
||
self.speech_engine.set_audio_device(None, None)
|
||
|
||
_sync_audio_device_tooltip(self.audio_device_combo)
|
||
logger.info(f"Found {len(devices)} audio input devices")
|
||
|
||
def _on_refresh_audio_devices(self, widget):
|
||
"""Handle refresh button click for audio devices."""
|
||
self._populate_audio_devices()
|
||
self.audio_test_status.set_markup("<i>Device list refreshed</i>")
|
||
|
||
def _on_audio_device_changed(self, widget):
|
||
"""Handle changes in the selected audio device."""
|
||
_sync_audio_device_tooltip(self.audio_device_combo)
|
||
if self._initializing:
|
||
return
|
||
|
||
device_id = self.audio_device_combo.get_active_id()
|
||
if device_id is None:
|
||
return
|
||
|
||
device_index = int(device_id)
|
||
device_label = self.audio_device_combo.get_active_text()
|
||
device_name = _raw_audio_device_name(device_label)
|
||
if is_pulse_device_index(device_index):
|
||
# Save the stable server name; the label changes as devices come and go.
|
||
device_name = self._pulse_source_names.get(device_index, device_name)
|
||
|
||
if device_index == -1:
|
||
self.config_manager.set("audio", "device_index", None)
|
||
self.config_manager.set("audio", "device_name", None)
|
||
else:
|
||
self.config_manager.set("audio", "device_index", device_index)
|
||
self.config_manager.set("audio", "device_name", device_name)
|
||
|
||
self.config_manager.save_settings()
|
||
|
||
if device_index == -1:
|
||
self.speech_engine.set_audio_device(None, None)
|
||
else:
|
||
self.speech_engine.set_audio_device(device_index, device_name)
|
||
|
||
logger.info(f"Audio device changed to: [{device_index}] {device_name}")
|
||
# Device names are free text ("Mic & Line", "<A2DP>"); escape them for Pango.
|
||
self.audio_test_status.set_markup(
|
||
f"<i>Selected: {GLib.markup_escape_text(device_label or '')}</i>"
|
||
)
|
||
|
||
def _cancel_audio_test(self, *_args: object) -> None:
|
||
"""Cancel capture and invalidate any queued result when Settings closes."""
|
||
cancel = getattr(self, "_audio_test_cancel", None)
|
||
if cancel is not None:
|
||
cancel.set()
|
||
self._audio_test_cancel = None
|
||
|
||
def _complete_audio_test(self, result: dict, cancel: threading.Event) -> bool:
|
||
"""Deliver only the current test's result on the GTK main thread."""
|
||
if cancel.is_set() or self._audio_test_cancel is not cancel:
|
||
return False
|
||
self._audio_test_cancel = None
|
||
self._handle_audio_test_result(result)
|
||
return False
|
||
|
||
def _on_test_audio_clicked(self, widget):
|
||
"""Handle test audio button click."""
|
||
self._cancel_audio_test()
|
||
cancel = threading.Event()
|
||
self._audio_test_cancel = cancel
|
||
self.test_audio_btn.set_sensitive(False)
|
||
self.test_audio_btn.set_label("Testing...")
|
||
self.audio_test_status.set_markup("<i>Recording... speak into your microphone</i>")
|
||
self.audio_level_bar.set_value(0)
|
||
|
||
device_id = self.audio_device_combo.get_active_id()
|
||
device_index = None if device_id == "-1" else int(device_id)
|
||
|
||
def run_test() -> None:
|
||
from ..speech_recognition.recognition_manager import test_audio_input
|
||
|
||
result = test_audio_input(
|
||
device_index=device_index, duration=2.0, should_continue=lambda: not cancel.is_set()
|
||
)
|
||
GLib.idle_add(self._complete_audio_test, result, cancel)
|
||
|
||
threading.Thread(target=run_test, daemon=True).start()
|
||
|
||
def _handle_audio_test_result(self, result: dict):
|
||
"""Handle the result of an audio test."""
|
||
self.test_audio_btn.set_sensitive(True)
|
||
self.test_audio_btn.set_label("Test")
|
||
|
||
if result.get("success"):
|
||
max_level = result.get("max_amplitude", 0)
|
||
has_signal = result.get("has_signal", False)
|
||
sample_rate = result.get("sample_rate", 16000)
|
||
|
||
level_percent = min(100, (max_level / 327.68))
|
||
self.audio_level_bar.set_value(level_percent)
|
||
|
||
# Build sample rate info string
|
||
if sample_rate == 16000:
|
||
rate_info = "(16kHz native)"
|
||
else:
|
||
rate_info = f"({sample_rate // 1000}kHz → 16kHz auto)"
|
||
|
||
if has_signal:
|
||
self.audio_test_status.set_markup(
|
||
f"<span foreground='#26a269'>✓ Audio detected!</span> "
|
||
f"Peak: {level_percent:.0f}% {rate_info}"
|
||
)
|
||
else:
|
||
self.audio_test_status.set_markup(
|
||
f"<span foreground='#e5a50a'>⚠ Very low audio level</span> "
|
||
f"(peak: {level_percent:.1f}%)\n"
|
||
"<small>Check if microphone is muted or try a different device</small>"
|
||
)
|
||
else:
|
||
error_msg = GLib.markup_escape_text(str(result.get("error", "Unknown error")))
|
||
self.audio_test_status.set_markup(
|
||
f"<span foreground='#c01c28'>✗ Test failed:</span> {error_msg}"
|
||
)
|
||
|
||
return False
|
||
|
||
def update_recognition_progress(self, state: str, audio_level: float = 0.0, info: str = ""):
|
||
"""Update the recognition progress feedback UI."""
|
||
self.recognition_status_label.set_text(state)
|
||
|
||
# Remove existing state classes
|
||
for css_class in [
|
||
"recognition-idle",
|
||
"recognition-listening",
|
||
"recognition-processing",
|
||
"recognition-error",
|
||
]:
|
||
self.recognition_status_label.get_style_context().remove_class(css_class)
|
||
|
||
if state == "Listening":
|
||
self.recognition_indicator.set_opacity(1.0)
|
||
self.recognition_status_label.get_style_context().add_class("recognition-listening")
|
||
self.progress_info_label.set_markup("<span foreground='#26a269'>● Listening...</span>")
|
||
elif state == "Processing":
|
||
self.recognition_indicator.set_opacity(1.0)
|
||
self.recognition_status_label.get_style_context().add_class("recognition-processing")
|
||
self.progress_info_label.set_markup(
|
||
"<span foreground='#e5a50a'>● Processing speech...</span>"
|
||
)
|
||
elif state == "Idle":
|
||
self.recognition_indicator.set_opacity(0.3)
|
||
self.recognition_status_label.get_style_context().add_class("recognition-idle")
|
||
self.progress_info_label.set_text("")
|
||
elif state == "Error":
|
||
self.recognition_indicator.set_opacity(0.3)
|
||
self.recognition_status_label.get_style_context().add_class("recognition-error")
|
||
self.progress_info_label.set_markup(
|
||
f"<span foreground='#c01c28'>✗ Error: {info}</span>"
|
||
)
|
||
else:
|
||
self.recognition_indicator.set_opacity(0.3)
|
||
if info:
|
||
self.progress_info_label.set_text(info)
|
||
|
||
if audio_level > 0:
|
||
normalized_level = min(100, max(0, audio_level))
|
||
self.recognition_audio_level.set_value(normalized_level)
|
||
elif state == "Idle":
|
||
self.recognition_audio_level.set_value(0)
|
||
|
||
def connect_to_recognition_manager(self):
|
||
"""Connect to speech recognition manager for progress updates."""
|
||
if hasattr(self, "speech_engine") and self.speech_engine:
|
||
if not hasattr(self, "_callbacks_registered"):
|
||
self.speech_engine.state_callbacks.append(self._on_recognition_state_changed)
|
||
self.speech_engine.register_audio_level_callback(self._on_audio_level_changed)
|
||
self._callbacks_registered = True
|
||
self.connect("destroy", self._on_dialog_destroy)
|
||
|
||
def _on_dialog_destroy(self, widget):
|
||
"""Clean up callbacks when dialog is destroyed."""
|
||
self._recording_shortcut = False
|
||
self._stop_evdev_shortcut_recorder()
|
||
if hasattr(self, "speech_engine") and self.speech_engine:
|
||
if self._on_recognition_state_changed in self.speech_engine.state_callbacks:
|
||
self.speech_engine.state_callbacks.remove(self._on_recognition_state_changed)
|
||
self.speech_engine.unregister_audio_level_callback(self._on_audio_level_changed)
|
||
|
||
def _on_recognition_state_changed(self, state):
|
||
"""Handle recognition state changes."""
|
||
state_map = {
|
||
RecognitionState.IDLE: "Idle",
|
||
RecognitionState.LISTENING: "Listening",
|
||
RecognitionState.PROCESSING: "Processing",
|
||
RecognitionState.ERROR: "Error",
|
||
}
|
||
|
||
state_str = state_map.get(state, "Unknown")
|
||
GLib.idle_add(self.update_recognition_progress, state_str)
|
||
|
||
def _on_audio_level_changed(self, level: float):
|
||
"""Handle audio level changes."""
|
||
GLib.idle_add(self.update_recognition_progress, "Listening", level)
|