diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index f84ad00..89594b6 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,400 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [2.03.32] - 2026-06-10 + +### Changed +- **Settings dialog navigation redesigned.** Replaced the `Gtk.Notebook` tab bar + with a 170 px left sidebar (`Gtk.ListBox` of flat buttons with section headers) + and a `Gtk.Stack` for the content area. The dialog is now 860 × 700 px by default. + Lazy-loading is preserved: each page is built only on first visit. +- **Input page split into Keyboard and Wakeword.** The former "Input" tab is now + two separate pages — "Keyboard" (input mode, hotkeys, quality gate, audio cues) + and "Wakeword" (enable switch, mic level, test button, silence timeout, cancel/send + words, engine preset selector, engine config card, wakeword sound cues). Either + page may be visited first; the mic level-meter starts on whichever is opened. +- **Benchmark page split into Benchmark — STT and Benchmark — Wakeword.** The + single "Benchmark" tab is now two dedicated pages. All field names and collect + logic are unchanged. + +## [2.03.31] - 2026-06-10 + +### Added +- **First-run setup wizard.** A paged GTK dialog (`setup_wizard.py`) guides + new users through five steps: trigger method (keyboard / wakeword / both), + keyboard shortcut assignment (with live key capture), wakeword server + configuration (with connection test), speech-to-text engine selection + (local Whisper model size or remote API), and optional AI rewriting (LLM + endpoint + model). Navigation has Back, Next, and Skip buttons. The wizard + shows automatically on first launch (before the daemon starts) and can be + reopened via the "Setup Wizard…" button in the Settings header bar. + Completing the wizard sets `setup_complete = true` in the config so it + does not reappear. + +## [2.03.30] - 2026-06-10 + +### Added +- **Dedicated wakeword models for cancel and send.** Two new optional fields in + the wakeword settings card — "Cancel model" and "Send model" — let you assign + a specific wakeword model (e.g. a custom "stop" or "send it" ONNX model) to + each action. When configured, a `WakewordActionListener` opens a second + Wyoming connection during recording and fires the action the instant the model + triggers — no Whisper pass, no silence timer. The Whisper-based cancel watcher + from v2.03.29 remains as a fallback when no cancel wakeword model is set. + Model dropdowns are populated from the same server fetch as the trigger model. + +## [2.03.29] - 2026-06-10 + +### Changed +- **Cancel keywords now fire immediately during wakeword recording.** A + real-time `_CancelWatcher` accumulates raw PCM from the VAD level-meter, + then every ~0.6 s of new audio runs a fast `beam_size=1` local transcription + pass to check for cancel keywords. When one is found it calls + `cancel_dictation()` instantly — no waiting for the silence timer or a full + transcription of the whole clip. Falls back to the existing post-transcription + check if no local transcriber is loaded or no cancel keywords are configured. + +## [2.03.28] - 2026-06-10 + +### Fixed +- **Dropdowns no longer change on accidental scroll.** All `ComboBoxText` + widgets in the settings dialog now swallow scroll events so hovering over + a combo and scrolling doesn't silently change the selected value. + +## [2.03.27] - 2026-06-10 + +### Added +- **Overlay × cancel button.** A small × button appears in the top-right corner + of the on-screen waveform HUD while recording or streaming. Clicking it + cancels the current dictation. The rest of the overlay remains fully + click-through; only the button area receives pointer events. +- **Cancel/Send keyboard shortcuts in Wakeword tab.** The "Cancel words" and + "Send words" rows in the Input → Wakeword section now include an inline + shortcut entry + "Set" button, so you can configure `key_cancel` / + `key_send` right next to the spoken-word equivalents without visiting the + keyboard-mode card. + +## [2.03.26] - 2026-06-10 + +### Fixed +- **Cancel key now works during wakeword-triggered recording.** Previously the + `ModifierScheme` state machine stayed in "idle" when the wakeword fired + (it bypasses the key-press path), so the cancel hotkey was silently ignored. + It now checks `daemon.is_recording` as a fallback so it fires regardless of + how recording started. + +### Added +- **"✕ Cancel recording" in the tray menu.** Always visible; grayed out when + idle, enabled as soon as recording starts (wakeword or manual). The primary + escape hatch when the wakeword fires on audiobook / TV audio and spoken + cancel words can't be heard over the background audio. + +## [2.03.25] - 2026-06-10 + +### Changed +- **Smart Save — no restart popup anymore.** "Save" now diffs the changed + settings against what requires a daemon restart. If only safe settings + changed (language, sounds, LLM prompt, keywords, overlay, …) it shows + "✓ Settings applied" inline in the header bar for 4 s and closes the + dialog. If restart-required fields changed (STT engine, hotkeys, + microphone, wakeword server) it shows "⚠ Saved — restart needed for: …" + and highlights "Save & Restart" so you can act on it. No modal popups. + +## [2.03.24] - 2026-06-10 + +### Fixed +- **Input level meter works without visiting General first.** The level meter + was only started inside `_build_general()` and referenced `mic_level` + unconditionally. If Input was opened first the meter never started. Now + `_build_input()` also starts the meter when it isn't running yet, and both + level bars (`mic_level` in General and `ww_mic_level` in Input) are updated + defensively via `hasattr` so either tab can be visited in any order. + +## [2.03.23] - 2026-06-10 + +### Fixed +- **Wakeword results table actually resizable.** The controls pane is now + wrapped in a ScrolledWindow with `shrink=True`, so dragging the divider + upward collapses the controls and expands the table freely. + +## [2.03.22] - 2026-06-10 + +### Changed +- **Wakeword benchmark uses a split pane.** The TTS config / engine selector + controls sit in the top pane; the results table sits in the bottom pane. + Drag the divider to give the table as much vertical space as needed. + +## [2.03.21] - 2026-06-10 + +### Added +- **Wakeword results table: sortable columns.** Click any column header to sort + ascending/descending. Numeric columns (Detected, Total, Recall %, False fires, + Time) sort numerically. +- **Wakeword results table: CSV export.** "Copy as CSV" copies the table to the + clipboard; "Save CSV…" opens a file chooser to write a `.csv` file. + +## [2.03.20] - 2026-06-10 + +### Added +- **Wakeword benchmark results table.** Results are now shown in a TreeView + with one row per engine per voice: Engine | Wakeword | Voice | Detected | + Total | Recall % | False fires | Time. Rows are colour-coded green/orange/red + by recall. An aggregate "ALL (N voices)" row is appended per engine. + +### Fixed +- **Section header icons now vertically centred with the headline text.** + The `.bt-section` CSS class was inadvertently applied to the icon widget, + giving it a 14 px top margin and pushing it down. The image no longer + receives that class; a `set_pixel_size(14)` pin ensures consistent sizing. + +## [2.03.19] - 2026-06-10 + +### Added +- **Wakeword engine checkboxes in benchmark.** A row of checkboxes above the + "Run wakeword benchmark" button lets you pick which engines to include. + All are checked by default. +- **Wakeword model selector in benchmark.** A "Wakeword" combo lets you + override which wakeword phrase (model) to test. Leave empty for the default + (each engine uses its own configured model). Pick a specific model (e.g. + `okay_computer`) to test that phrase on every selected engine. + +## [2.03.18] - 2026-06-10 + +### Fixed +- **TTS model dropdown no longer floods with voice names.** Servers like Kokoro + expose each voice as a `/models` entry. The ⟳ button now detects this case + and skips filling the model combo, prompting the user to type the model id + manually (e.g. `kokoro`). The status line shows "type model id manually" as + a hint. + +### Changed +- **Wakeword benchmark runs across all engines and shows per-engine results.** + Previously a callback signature mismatch caused the benchmark to crash when + more than one engine was configured. Now progress shows `[1/3] engine name`, + and the results panel lists Recall / False fires / time per engine. + +## [2.03.17] - 2026-06-10 + +### Added +- **Wakeword model fetch feedback.** The ⟳ button now shows a status line while + connecting; after loading it reports how many models were found (with their + names) or "Unreachable" if the server is down. +- **Wakeword Quickstart covers all four ports.** The Quickstart menu now lists + presets for ports 10400–10403, plus `hey_jarvis` and `alexa` variants. +- **Wakeword info box.** An info banner explains how wyoming-openwakeword works, + where to put model files, and lists the common built-in models. + +## [2.03.16] - 2026-06-10 + +### Added +- **MP3/OGG/FLAC support for sound cues.** The sound file picker now accepts + WAV, MP3, OGG, FLAC, M4A, AAC, AIFF, and Opus. Playback uses `ffplay` or + `gst-play-1.0` as a universal fallback when the native `pw-play`/`paplay` + can't handle the format. +- **Browse dialog with auto-preview.** The 📁 browse button opens a + `FileChooserDialog`; selecting a file auto-plays it so you can hear it before + confirming. The ▶ play button still works on the current selection. + +## [2.03.15] - 2026-06-10 + +### Added +- **Wakeword engine CRUD.** The wakeword server section now has the same full + management UI as STT engines: a named-preset selector combo, + Add, Quickstart + (with 4 common wyoming-openwakeword templates), ⟳ reload, and Delete. Existing + users are migrated: their `wakeword_uri` / `wakeword_model` become the first + preset automatically. + +## [2.03.14] - 2026-06-10 + +### Fixed +- **Engines tab.** Removed the "Internal engine — device & precision" section + header. The Device and Compute type fields already only appear when a local + engine type is selected; the separate header was redundant. + +## [2.03.13] - 2026-06-10 + +### Changed +- **Settings header bar.** Save and Save & Restart moved from the bottom button + bar into the title bar (GTK HeaderBar). The X button closes without saving. + Bottom button row removed. +- **Section icon alignment.** Icons in section headers are now vertically + centred with the label text (`SMALL_TOOLBAR` size, `valign=CENTER`). + +## [2.03.12] - 2026-06-10 + +### Added +- **Icons in settings.** All tab labels (Presets, Engines, Input, General, + Benchmark, Log, Manual, About) and every section header inside each tab now + show a small GTK symbolic icon, making the layout easier to scan. + +### Fixed +- **Resize grip position.** The grip indicator now appears correctly at the + bottom-right corner below the notebook, not misplaced in the tab bar. + +## [2.03.11] - 2026-06-09 + +### Added +- **Resize grip indicator.** A dotted SE-corner grip is drawn over the + bottom-right of the settings window so users discover it is resizable. + +## [2.03.10] - 2026-06-09 + +### Fixed +- **Server RAM probe.** Prometheus `/metrics` is almost always at the server + root (`http://host:port/metrics`), not under `/v1`. Now tries the root URL + first before falling back to the API base path. + +## [2.03.09] - 2026-06-09 + +### Added +- **Server RAM in benchmark.** For remote/Docker STT engines the benchmark now + probes the server's Prometheus `/metrics` endpoint for + `process_resident_memory_bytes` (standard Python/Go exporter) or + `container_memory_rss` (cAdvisor) and shows the server-side RSS in MB in the + RAM column. Falls back to `server` when the endpoint is not exposed. + +## [2.03.08] - 2026-06-09 + +### Fixed +- **Engines tab layout.** The "Internal engine — device & precision" section is + now hidden when a remote (Server) or streaming engine type is selected — + removing the confusing whitespace gap and irrelevant device controls for + non-local engines. + +## [2.03.01] - 2026-06-09 + +### Added +- **RAM usage column in benchmark.** The results table now shows a **RAM (MB)** + column — the increase in process RSS while the transcription ran. For local + models this captures the memory cost of loading the model on first use; for + remote engines it shows `—` (work happens server-side). Values are measured via + `/proc/self/status` (VmRSS), so they reflect actual resident memory, not + virtual address space. + +## [2.03.00] - 2026-06-09 + +### Fixed +- **"Not responding" / system instability on Save.** `_collect()` was calling + `socket.create_connection()` *synchronously* on the GTK main thread when + wakeword is enabled — freezing the UI for up to 1.5 s (longer if DNS is slow). + The check is now done on a daemon thread and the result is logged instead of + blocking the save path. +- **GTK thread-safety crash in wakeword model load.** `_ww_load()` read + `self.ww_uri.get_text()` from inside a background thread — unsafe. The URI is + now captured on the main thread before the thread is spawned. +- **HTTP 404 with WhisperX and other non-standard endpoints.** The remote + transcription call always appended `/audio/transcriptions` to the base URL, but + services like WhisperX use `/transcribe` as the full path. The URL path is now + inspected: if it is anything other than empty / `/v1` / `/v1.0`, the URL is + used as the complete endpoint with nothing appended — so + `http://host:8081/transcribe` works out of the box. +- **Log levels.** `logbuffer` now stores `(timestamp, level, message)` tuples and + accepts a `level=` keyword (`DEBUG` / `INFO` / `WARNING` / `ERROR`). The Log + tab gains a **Level** dropdown (Verbose · Info · Warning · Error) that filters + the displayed entries live. Wakeword and socket errors are now tagged + `WARNING`; library records are forwarded at their native level. + +### Added +- **Wakeword server preset dropdown** (Input → Hands-free wakeword). A + **Server preset** combo lists all configured wakeword server engines by name. + Picking one auto-fills the URI and model fields and re-probes reachability. + The selection is persisted as `wakeword_active` in config. + +## [2.02.03] - 2026-06-09 + +### Added +- Wakeword server preset dropdown in Input tab. + +## [2.02.02] - 2026-06-09 + +### Fixed +- License tab now renders with markdown styling. +- Benchmark pane minimum height (320 px, `shrink=False`) prevents the engine + list or results table from collapsing to zero when the window is small. + +## [2.02.01] - 2026-06-09 + +### Added +- Last benchmark time and accuracy shown on the selected STT engine in the + Engines tab. Persisted to config so it survives restarts. + +## [2.02.00] - 2026-06-09 + +### Added +- **Language metadata in benchmark.** The engine checkbox list shows supported + language codes next to each engine (fetched async). Filter box searches by + language code. Results table has a **Lang** column. Data comes from the + `/v1/models` `language` field (faster-whisper-server) or NVIDIA NIM `/metadata`. + +## [1.9.5] - 2026-06-09 + +### Added +- **Emoji picker search.** A search field at the top of the emoji picker filters + all categories in real time using Unicode character names (e.g. "fire", "dog", + "heart"). Typing hides the category bar and shows matching results; clearing + restores the category view. + +### Fixed +- **Manual tab now shows content.** `MANUAL.md` is copied next to the package + module so the Manual tab finds it in both venv and deb installs. +- **Info banner no longer bright blue.** The `.bt-infobox` background now uses + a neutral 5 % tint of the foreground colour instead of the theme accent + colour, so text stays readable on any theme. + +## [1.9.4] - 2026-06-09 + +### Changed +- **Settings UI completely redesigned.** All six settings tabs (Presets, Engines, + Input, General, Input, General) now use a card-based layout following GTK3 best + practices: related fields are grouped inside visually distinct cards with bold + section titles. CSS is injected at start-up to give cards a consistent rounded + border (`boxed-list` + `bt-card`) and a styled info banner at the top of each + tab. +- **Dialog is larger (740×700 px) and every tab scrolls.** The notebook pages + now wrap their content in a `Gtk.ScrolledWindow` so no fields are ever clipped, + even on small screens. +- **Engines toolbar reorganised.** Creation actions (+ Add, + Stream, Quickstart) + are left-aligned; destructive/status actions (Delete, Test, ⟳) are + right-aligned via `pack_end`, making the bar scannable at a glance. +- **Section titles replace plain separators.** The old `Gtk.Separator` + + unstyled `Gtk.Label` pattern is gone; every section now has a small, dimmed, + bold all-caps header rendered with markup. +- **Cleaner section names.** "WW - Wakeword (Hands-free)" → "Hands-free + wakeword"; "Audio cues (manual dictation)" → "Audio cues (keyboard / hotkey + dictation)"; "Local engine … device & precision" → "Internal engine — device & + precision". + +## [1.9.3] - 2026-06-09 + +### Added +- **ⓘ info buttons on every settings field.** Each field in every tab now has a + small information icon that opens a plain-language help popover when clicked — + so non-technical users can understand what each setting does without hovering + or reading the manual. +- **Manual tab in Settings.** A new "Manual" tab shows the full `MANUAL.md` + reference doc directly inside the Settings window. +- **Quickstart templates for engines.** A "Quickstart ▾" button in the STT and + LLM engine toolbars opens a menu of common services (OpenAI, Groq, OpenRouter, + Ollama, LM Studio, vLLM, llama-swap, faster-whisper-server, NVIDIA Riva) and + pre-fills the form — one click to configure a provider. + +### Changed +- **Engine type names are now human-readable.** STT types now read "Internal — + faster-whisper, runs inside the app", "Server — OpenAI-compatible API (LAN or + cloud)", and "Realtime — NVIDIA Riva / NIM streaming" instead of the raw + identifiers. LLM types read "LAN server — runs on your machine or local + network" and "Cloud service — OpenAI, Groq, OpenRouter, …". +- **Device selector now shows "GPU (CUDA)" instead of "cuda"**, and compute + types have plain-language descriptions (e.g. "int8 — fast, less memory"). + +## [1.9.2] - 2026-06-09 + +### Added +- **Emoji picker for preset icons.** The "Icon (emoji)" field in Settings → + Presets now has a 😀 button that opens a scrollable emoji grid (60 common + emojis across six categories). Click any emoji to insert it — or keep typing + directly into the field as before. + ## [1.9.1] - 2026-06-08 ### Changed diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index 959baf1..b4a4c3f 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host (not in a container) so it can type into any application via xdotool. """ -__version__ = "1.9.1" +__version__ = "2.03.32" diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 4052aa6..7c891e3 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -1,13 +1,16 @@ -"""GTK settings: dropdown + editor managers for prompt presets and engines. +"""GTK settings: sidebar + stack layout for prompt presets and engines. -Tabs: - Presets - prompt presets (select from a dropdown, edit, add, delete) - Engines - STT and LLM engine presets with online/offline status + STT test - Input - input scheme, keys, quality gate - General - mic (with live level meter), output, language, notifications, autostart - Benchmark- compare STT engines against a reference clip - Log - runtime log output - About - version, source, changelog, and license +Pages: + Presets - prompt presets (select from a dropdown, edit, add, delete) + General - mic (with live level meter), output, language, notifications, autostart + Keyboard - input scheme, keys, quality gate, audio cues + Wakeword - hands-free wakeword, engine config, wakeword sound cues + STT Engines - STT and LLM engine presets with online/offline status + STT test + Benchmark — STT - compare STT engines against a reference clip + Benchmark — WW - stress-test wakeword engines with synthesized speech + Log - runtime log output + Manual - user manual + About - version, source, changelog, and license """ from __future__ import annotations @@ -16,13 +19,14 @@ import os import sys import threading import time +import unicodedata from pathlib import Path import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") -from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 +from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 # type: ignore[import-untyped] from . import __version__, audio, autostart, benchmark, llm, logbuffer, stt, wakeword_bench # noqa: E402 from .config import Config, save # noqa: E402 @@ -34,6 +38,153 @@ RESP_SAVE = 1 RESP_SAVE_RESTART = 2 GREEN, RED, GREY = "#34c759", "#ff3b30", "#b8b8be" +# (stored_value, display_label) pairs used by _type_combo / _type_key +_STT_TYPES: list[tuple[str, str]] = [ + ("local", "Internal — faster-whisper, runs inside the app"), + ("openai", "Server — OpenAI-compatible API (LAN or cloud)"), + ("riva_realtime", "Realtime — NVIDIA Riva / NIM streaming"), +] +_LLM_TYPES: list[tuple[str, str]] = [ + ("local", "LAN server — runs on your machine or local network"), + ("cloud", "Cloud service — OpenAI, Groq, OpenRouter, …"), +] +_DEVICE_OPTIONS: list[tuple[str, str]] = [ + ("auto", "Auto (try GPU / CUDA first, fall back to CPU)"), + ("cpu", "CPU"), + ("cuda", "GPU (CUDA)"), +] +_COMPUTE_OPTIONS: list[tuple[str, str]] = [ + ("auto", "Auto"), + ("int8", "int8 — fast, less memory"), + ("float16", "float16 — accurate, needs more VRAM"), + ("int8_float16", "int8_float16 — balanced"), +] + +# (name, url, api_key_env, type_key, default_model) — quickstart templates +_LLM_TEMPLATES: list[tuple[str, str, str, str, str]] = [ + ("OpenAI", "https://api.openai.com/v1", "OPENAI_API_KEY", "cloud", "gpt-4o-mini"), + ("Groq", "https://api.groq.com/openai/v1", "GROQ_API_KEY", "cloud", "llama3-8b-8192"), + ("OpenRouter", "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "cloud", "openai/gpt-4o-mini"), + ("Ollama", "http://localhost:11434/v1", "", "local", ""), + ("LM Studio", "http://localhost:1234/v1", "", "local", ""), + ("vLLM", "http://localhost:8000/v1", "", "local", ""), + ("llama-swap", "http://localhost:28080/v1", "", "local", ""), +] +_STT_TEMPLATES: list[tuple[str, str, str, str, str, int]] = [ + # (name, url, api_key_env, type, model, timeout_s) + # ── Cloud / API services ────────────────────────────────────────────────── + ("OpenAI Whisper", "https://api.openai.com/v1", "OPENAI_API_KEY", "openai", "whisper-1", 30), + ("Groq Whisper (free tier)","https://api.groq.com/openai/v1", "GROQ_API_KEY", "openai", "whisper-large-v3-turbo", 30), + # ── Local servers ───────────────────────────────────────────────────────── + ("faster-whisper-server", "http://localhost:8010/v1", "", "openai", "", 30), + ("Speaches (docker)", "http://localhost:8080/v1", "", "openai", "", 30), + ("whisper.cpp server", "http://localhost:8081/v1", "", "openai", "", 30), + ("WhisperX server", "http://localhost:8081/transcribe","", "openai", "whisper-large-v3", 120), + ("NVIDIA NIM / Parakeet", "http://localhost:8007/v1", "", "openai", "", 30), + ("Realtime (Riva / NIM)", "http://localhost:8006/v1", "", "riva_realtime", "", 30), + # ── Built-in local models (no server needed) ────────────────────────────── + ("Local tiny — fastest, ~39 MB", "", "", "local", "tiny", 30), + ("Local base — fast, ~74 MB", "", "", "local", "base", 30), + ("Local small — balanced, ~244 MB", "", "", "local", "small", 30), + ("Local medium — accurate, ~769 MB","", "", "local", "medium", 30), + ("Local large-v3 — best, ~1.5 GB", "", "", "local", "large-v3", 30), +] + +_WW_TEMPLATES: list[tuple[str, str, str]] = [ + # (name, uri, model) + ("Local :10400 — okay_computer", "tcp://127.0.0.1:10400", "okay_computer"), + ("Local :10401 — okay_computer", "tcp://127.0.0.1:10401", "okay_computer"), + ("Local :10402 — okay_computer", "tcp://127.0.0.1:10402", "okay_computer"), + ("Local :10403 — okay_computer", "tcp://127.0.0.1:10403", "okay_computer"), + ("Local :10400 — hey_jarvis", "tcp://127.0.0.1:10400", "hey_jarvis"), + ("Local :10400 — alexa", "tcp://127.0.0.1:10400", "alexa"), + ("Home Assistant wyoming", "tcp://homeassistant.local:10400", "okay_nabu"), +] + +# (cat_emoji, label, [emojis]) — standard Unicode categories, WhatsApp-style +_EMOJI_CATEGORIES: list[tuple[str, str, list[str]]] = [ + ("😀", "Smileys", [ + "😀","😃","😄","😁","😆","😅","🤣","😂","🙂","🙃","😉","😊","😇", + "🥰","😍","🤩","😘","😗","😚","😙","🥲","😋","😛","😜","🤪","😝", + "🤑","🤗","🤭","🤫","🤔","🤐","🤨","😐","😑","😶","😏","😒","🙄", + "😬","🤥","😌","😔","😪","🤤","😴","😷","🤒","🤕","🤢","🤮","🤧", + "🥵","🥶","🥴","😵","🤯","🤠","🥳","🥸","😎","🤓","🧐","😕","😟", + "🙁","😮","😯","😲","😳","🥺","😦","😧","😨","😰","😥","😢","😭", + "😱","😖","😣","😞","😓","😩","😫","🥱","😤","😡","😠","🤬","😈", + "👿","💀","☠️","💩","🤡","👹","👺","👻","👽","👾","🤖", + ]), + ("👋", "People", [ + "👋","🤚","🖐️","✋","🖖","👌","🤌","🤏","✌️","🤞","🤟","🤘","🤙", + "👈","👉","👆","🖕","👇","☝️","👍","👎","✊","👊","🤛","🤜","👏", + "🙌","👐","🤲","🤝","🙏","✍️","💅","💪","🦾","👂","👃","👀","👅", + "👄","💋","👶","🧒","👦","👧","🧑","👱","👨","🧔","👩","🧓","👴", + "👵","🙍","🙎","🙅","🙆","💁","🙋","🧏","🙇","🤦","🤷","💆","💇", + "🚶","🏃","💃","🕺","🧖","🧗","🤸","⛹️","🏋️","🤼","🧘","🛀","🛌", + "👮","🕵️","💂","🥷","👷","🫅","🤴","👸","🧙","🧝","🧛","🧟","🧞", + "🧜","🧚","👼","🤶","🎅","🦸","🦹","🧑‍🍳","🧑‍🎓","🧑‍🏫","🧑‍⚕️","🧑‍💼","🧑‍🔧","🧑‍💻", + ]), + ("🐶", "Animals", [ + "🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷", + "🐸","🐵","🙈","🙉","🙊","🐒","🐔","🐧","🐦","🐤","🦆","🦅","🦉", + "🦇","🐺","🐗","🐴","🦄","🐝","🐛","🦋","🐌","🐞","🐜","🦟","🦗", + "🕷️","🦂","🐢","🐍","🦎","🐙","🦑","🦐","🦀","🐡","🐠","🐟","🐬", + "🐳","🐋","🦈","🐊","🐅","🐆","🦓","🦍","🐘","🦏","🐪","🦒","🦘", + "🐃","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐈", + "🐓","🦃","🦚","🦜","🦢","🦩","🕊️","🐇","🦝","🦨","🦡","🦦","🦥", + "🐁","🐀","🐿️","🦔","🐉","🐲","🌵","🌲","🌳","🌴","🌿","☘️","🍀", + "🎋","🎍","🍃","🍂","🍁","🌾","🌺","🌸","🌼","🌻","🌹","🥀","🌷", + ]), + ("🍎", "Food", [ + "🍏","🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🫐","🍒","🍑","🥭", + "🍍","🥥","🥝","🍅","🍆","🥑","🥦","🥬","🥒","🌶️","🧄","🧅","🥔", + "🌽","🥕","🍞","🥐","🥖","🥨","🧀","🥚","🍳","🧈","🥞","🥓","🥩", + "🍗","🍖","🌭","🍔","🍟","🍕","🌮","🌯","🥙","🥚","🍲","🥗","🍿", + "🧂","🍱","🍣","🍛","🍜","🍝","🍢","🥟","🥠","🥡","🍦","🍧","🍨", + "🍩","🍪","🎂","🍰","🧁","🍫","🍬","🍭","🍮","🍯","🥛","☕","🍵", + "🧃","🥤","🧋","🍺","🍻","🥂","🍷","🥃","🍸","🍹","🍾","🧊","🍴", + ]), + ("🏠", "Travel", [ + "🚗","🚕","🚙","🚌","🏎️","🚓","🚑","🚒","🚐","🛻","🚚","🚛","🚜", + "🏍️","🛵","🚲","🛴","🛹","⚓","⛵","🚤","🛥️","🚢","✈️","🛩️","🚁", + "🚀","🛸","🪐","🌍","🌎","🌏","🗺️","🧭","🏔️","⛰️","🌋","🏕️","🏖️", + "🏜️","🏝️","🏟️","🏛️","🏗️","🏘️","🏠","🏡","🏢","🏣","🏤","🏥","🏦", + "🏨","🏪","🏫","🏬","🏭","🏯","🏰","💒","🗼","🗽","⛪","🕌","⛩️", + "🕋","⛲","⛺","🌁","🌃","🏙️","🌄","🌅","🌆","🌇","🌉","🌌","🌠", + "🎇","🎆","🎑","🗾", + ]), + ("⚽", "Activities", [ + "⚽","🏀","🏈","⚾","🥎","🎾","🏐","🏉","🥏","🎱","🏓","🏸","🏒", + "🥍","🏑","🏏","⛳","🎣","🤿","🎽","🎿","🛷","🥌","🎯","🎮","🎰", + "🎲","♟️","🧩","🪄","🎭","🎨","🎪","🎤","🎧","🎼","🎹","🥁","🎷", + "🎺","🎸","🎻","🎬","🎥","📽️","🎞️","🎠","🎡","🎢","🎟️","🎫","🏆", + "🥇","🥈","🥉","🏅","🎖️","🎗️","🎀","🎁","🎊","🎉", + ]), + ("💡", "Objects", [ + "📱","📲","💻","⌨️","🖥️","🖨️","🖱️","💾","💿","📀","🧮","🎥","📷", + "📹","📼","🔍","🔎","💡","🔦","🏮","🪔","📔","📒","📓","📕","📗", + "📘","📙","📚","📖","🔖","🏷️","💰","💳","🪙","📈","📉","📊","📋", + "📌","📍","✂️","🔒","🔓","🔏","🔐","🔑","🗝️","🔨","⛏️","⚒️","🛠️", + "🔧","🪛","🔩","⚙️","⚖️","🔗","🧲","🪜","🧪","🔭","🔬","💊","💉", + "🩹","🩺","🧴","🧷","🧹","🧺","🧻","🪣","🧼","🪒","🛒","🚪","🪞", + "🛏️","🛋️","🪑","🚽","🚿","🛁","🧸","🪆","👓","🕶️","🥽","👒","🎩", + "🧢","⛑️","📿","💍","💎","🌂","🧵","🧶","🪢","🎒","👜","👝","👛", + "💼","🧳","🌂","☂️","🛡️","🗡️","⚔️","🪃","🏹","🪤","🪬","🧿","🔮", + ]), + ("🔣", "Symbols", [ + "❤️","🧡","💛","💚","💙","💜","🖤","🤍","🤎","💔","❣️","💕","💞", + "💓","💗","💖","💘","💝","☮️","✝️","☪️","🕉️","☸️","✡️","☯️","☦️","🛐", + "♈","♉","♊","♋","♌","♍","♎","♏","♐","♑","♒","♓","⛎", + "🔀","🔁","🔂","▶️","⏩","⏭️","⏯️","◀️","⏪","⏮️","🔼","⏫","🔽", + "⏬","⏸️","⏹️","⏺️","🎦","🔅","🔆","📶","🔇","🔈","🔉","🔊","📢", + "📣","🔔","🔕","⚠️","🚸","⛔","🚫","🚳","🚭","🚯","🚱","🚷","📵", + "🔞","💯","♻️","✅","❌","❎","➕","➖","➗","✖️","💲","™️","©️","®️", + "〰️","➰","➿","🔚","🔙","🔛","🔜","🔝","🔴","🟠","🟡","🟢","🔵", + "🟣","⚫","⚪","🟤","🔶","🔷","🔸","🔹","🔺","🔻","💠","🔘","⭕", + "⭐","🌟","💫","✨","🔥","💧","🌊","🌀","⚡","❄️","🌈","🎵","🎶", + "💬","💭","💤","❓","❗","⁉️","🔑","🎯","🏁","🚩","🎌","🏴","🏳️", + ]), +] + def _dot(color: str) -> str: return f'' @@ -69,12 +220,15 @@ def _format_combo(tokens: list[str]) -> str: return "+".join(parts) -def _labeled(parent: Gtk.Box, label: str, widget: Gtk.Widget, width: int = 130, tooltip: str = "") -> Gtk.Widget: - row = Gtk.Box(spacing=10) - row.set_margin_top(3); row.set_margin_bottom(3) +def _labeled(parent, label: str, widget: Gtk.Widget, width: int = 130, tooltip: str = "") -> Gtk.Widget: + is_lb = isinstance(parent, Gtk.ListBox) + row_box = Gtk.Box(spacing=10) + mt, mb, ms, me = (6, 6, 12, 8) if is_lb else (3, 3, 0, 0) + row_box.set_margin_top(mt); row_box.set_margin_bottom(mb) + if is_lb: + row_box.set_margin_start(ms); row_box.set_margin_end(me) + lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) - - # ATK Accessibility linking if hasattr(lbl, "set_mnemonic_widget"): lbl.set_mnemonic_widget(widget) atk = widget.get_accessible() @@ -85,21 +239,28 @@ def _labeled(parent: Gtk.Box, label: str, widget: Gtk.Widget, width: int = 130, lbl.set_tooltip_text(tooltip) if atk: atk.set_description(tooltip) - - row.pack_start(lbl, False, False, 0) - row.pack_start(widget, True, True, 0) - parent.pack_start(row, False, False, 0) + + row_box.pack_start(lbl, False, False, 0) + row_box.pack_start(widget, True, True, 0) + if tooltip: + row_box.pack_start(_info_btn(tooltip), False, False, 0) + + if is_lb: + _lb_add(parent, row_box) + else: + parent.pack_start(row_box, False, False, 0) return widget -def _switch_row(parent: Gtk.Box, label: str, switch: Gtk.Switch, description: str = "", +def _switch_row(parent, label: str, switch: Gtk.Switch, description: str = "", width: int = 130) -> Gtk.Switch: - """A settings row: [label] [grey description ……………] [switch, far right]. - - The description tells the user what the switch does without hovering. - """ + """A settings row: [label] [grey description ……………] [ⓘ] [switch].""" + is_lb = isinstance(parent, Gtk.ListBox) row = Gtk.Box(spacing=10) - row.set_margin_top(3); row.set_margin_bottom(3) + mt, mb = (6, 6) if is_lb else (3, 3) + row.set_margin_top(mt); row.set_margin_bottom(mb) + if is_lb: + row.set_margin_start(12); row.set_margin_end(8) lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) row.pack_start(lbl, False, False, 0) @@ -109,11 +270,11 @@ def _switch_row(parent: Gtk.Box, label: str, switch: Gtk.Switch, description: st desc.get_style_context().add_class("dim-label") row.pack_start(desc, True, True, 0) - switch.set_halign(Gtk.Align.END) - switch.set_valign(Gtk.Align.CENTER) + switch.set_halign(Gtk.Align.END); switch.set_valign(Gtk.Align.CENTER) row.pack_end(switch, False, False, 0) + if description: + row.pack_end(_info_btn(description), False, False, 0) - # ATK accessibility + tooltips if hasattr(lbl, "set_mnemonic_widget"): lbl.set_mnemonic_widget(switch) atk = switch.get_accessible() @@ -126,19 +287,26 @@ def _switch_row(parent: Gtk.Box, label: str, switch: Gtk.Switch, description: st lbl.set_tooltip_text(description) desc.set_tooltip_text(description) - parent.pack_start(row, False, False, 0) + if is_lb: + _lb_add(parent, row) + else: + parent.pack_start(row, False, False, 0) return switch def _infobox(parent: Gtk.Box, text: str) -> Gtk.Box: - """A plain-language help box at the top of a tab (read by screen readers).""" - box = Gtk.Box(spacing=8) - box.set_margin_top(2); box.set_margin_bottom(10) + """A styled banner at the top of each settings tab.""" + box = Gtk.Box(spacing=10) + box.set_margin_top(0); box.set_margin_bottom(12) + box.set_margin_start(4); box.set_margin_end(4) + box.get_style_context().add_class("bt-infobox") icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.BUTTON) - icon.set_valign(Gtk.Align.START) + icon.set_valign(Gtk.Align.CENTER) + icon.set_margin_start(6); icon.set_margin_end(2) box.pack_start(icon, False, False, 0) lbl = Gtk.Label(label=text, xalign=0.0) lbl.set_line_wrap(True); lbl.set_xalign(0.0); lbl.set_max_width_chars(72) + lbl.set_margin_top(8); lbl.set_margin_bottom(8); lbl.set_margin_end(8) box.pack_start(lbl, True, True, 0) acc = box.get_accessible() if acc: @@ -148,6 +316,45 @@ def _infobox(parent: Gtk.Box, text: str) -> Gtk.Box: return box +def _section_title(parent: Gtk.Box, text: str, margin_top: int = 16, + icon: str = "") -> None: + """A small bold uppercase section header, with optional symbolic icon.""" + row = Gtk.Box(spacing=4) + row.set_margin_top(margin_top); row.set_margin_bottom(3) + row.get_style_context().add_class("bt-section") + if icon: + img = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU) + img.set_pixel_size(14) + img.set_valign(Gtk.Align.CENTER) + row.pack_start(img, False, False, 0) + lbl = Gtk.Label(xalign=0.0) + lbl.set_valign(Gtk.Align.CENTER) + lbl.set_markup(f"{GLib.markup_escape_text(text.upper())}") + row.pack_start(lbl, False, False, 0) + parent.pack_start(row, False, False, 0) + + +def _card_section(parent: Gtk.Box, title: str = "", margin_top: int = 16, + icon: str = "") -> Gtk.ListBox: + """A titled card group. Returns a ListBox styled as a card for packing rows into.""" + if title: + _section_title(parent, title, margin_top, icon=icon) + lb = Gtk.ListBox() + lb.set_selection_mode(Gtk.SelectionMode.NONE) + lb.get_style_context().add_class("boxed-list") + lb.get_style_context().add_class("bt-card") + parent.pack_start(lb, False, False, 0) + return lb + + +def _lb_add(lb: Gtk.ListBox, widget: Gtk.Widget) -> None: + """Add any widget as a non-interactive row inside a card ListBox.""" + row = Gtk.ListBoxRow() + row.set_activatable(False); row.set_selectable(False) + row.add(widget) + lb.add(row) + + def _entry(text="", placeholder="") -> Gtk.Entry: e = Gtk.Entry(); e.set_text(str(text)); e.set_hexpand(True) if placeholder: @@ -155,8 +362,14 @@ def _entry(text="", placeholder="") -> Gtk.Entry: return e +def _block_scroll(combo: Gtk.ComboBoxText) -> Gtk.ComboBoxText: + """Prevent accidental value changes from mouse-wheel hover-scrolling.""" + combo.connect("scroll-event", lambda _w, _e: True) + return combo + + def _combo(options, active=None) -> Gtk.ComboBoxText: - c = Gtk.ComboBoxText() + c = _block_scroll(Gtk.ComboBoxText()) for o in options: c.append_text(o) if active in options: @@ -166,6 +379,42 @@ def _combo(options, active=None) -> Gtk.ComboBoxText: return c +def _info_btn(text: str) -> Gtk.Button: + """Small ⓘ button that shows a help popover when clicked.""" + btn = Gtk.Button() + icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.SMALL_TOOLBAR) + btn.add(icon) + btn.set_relief(Gtk.ReliefStyle.NONE) + btn.set_tooltip_text(text) + def _show(_b): + pop = Gtk.Popover(relative_to=btn) + lbl = Gtk.Label(label=text, xalign=0.0) + lbl.set_line_wrap(True) + lbl.set_max_width_chars(46) + lbl.set_margin_top(10); lbl.set_margin_bottom(10) + lbl.set_margin_start(12); lbl.set_margin_end(12) + pop.add(lbl) + pop.show_all() + btn.connect("clicked", _show) + return btn + + +def _type_combo(types: list[tuple[str, str]], stored: str = "") -> Gtk.ComboBoxText: + """Combo that shows human-readable labels but maps to/from internal key values via index.""" + c = _block_scroll(Gtk.ComboBoxText()) + for _, label in types: + c.append_text(label) + idx = next((i for i, (k, _) in enumerate(types) if k == stored), 0) + c.set_active(idx) + return c + + +def _type_key(combo: Gtk.ComboBoxText, types: list[tuple[str, str]], fallback: str = "") -> str: + """Read the stored key for a combo built with _type_combo.""" + i = combo.get_active() + return types[i][0] if 0 <= i < len(types) else fallback + + class ModelPicker(Gtk.Box): """An editable model field + a ▾ button opening a popover with a search bar on top and a scrollable, filtered list of models.""" @@ -278,12 +527,25 @@ def _url_field(parent: Gtk.Box, label: str, placeholder: str, on_reload, return e -def _page(nb: Gtk.Notebook, title: str) -> Gtk.Box: +def _url_field_lb(lb: Gtk.ListBox, label: str, placeholder: str, on_reload, + dot: Gtk.Label | None = None, width: int = 130, + tooltip: str = "") -> Gtk.Entry: + """_url_field variant that adds the row as a ListBox card row.""" box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - for m in ("top", "bottom", "start", "end"): - getattr(box, f"set_margin_{m}")(12) - nb.append_page(box, Gtk.Label(label=title)) - return box + _url_field(box, label, placeholder, on_reload, dot=dot, width=width) + row_box = box.get_children()[0] # the row box built by _url_field + box.remove(row_box) + row_box.set_margin_top(6); row_box.set_margin_bottom(6) + row_box.set_margin_start(12); row_box.set_margin_end(8) + _lb_add(lb, row_box) + # return the Entry widget (second child of row_box after label [and optional dot]) + for child in row_box.get_children(): + if isinstance(child, Gtk.Entry): + if tooltip: + child.set_tooltip_text(tooltip) + return child + return row_box.get_children()[-2] # fallback: second-to-last (before refresh btn) + def _read_first(paths: list[Path], fallback: str = "Not available in this install.") -> str: @@ -303,9 +565,150 @@ def _app_paths() -> dict[str, list[Path]]: return { "changelog": [linux_dir / "CHANGELOG.md", Path("/opt/blitztext/CHANGELOG.md")], "license": [repo_dir / "LICENSE", Path("/usr/share/doc/blitztext/copyright")], + "manual": [pkg_dir / "MANUAL.md", repo_dir / "MANUAL.md", Path("/opt/blitztext/MANUAL.md")], } +def _md_panel(text: str, height: int = 420) -> Gtk.ScrolledWindow: + """Render a Markdown string into a read-only styled Gtk.TextView. + + Handles: # h1/h2/h3, **bold**, *italic*, `inline code`, + > blockquotes, --- rules, bullet/numbered lists, and | tables |. + """ + import re + + sw = Gtk.ScrolledWindow() + sw.set_min_content_height(height) + sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + view = Gtk.TextView() + view.set_editable(False) + view.set_cursor_visible(False) + view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + view.set_left_margin(14) + view.set_right_margin(14) + view.set_top_margin(10) + view.set_bottom_margin(10) + + buf = view.get_buffer() + + t_h1 = buf.create_tag("h1", weight=Pango.Weight.BOLD, size_points=17.0, + pixels_above_lines=14, pixels_below_lines=4) + t_h2 = buf.create_tag("h2", weight=Pango.Weight.BOLD, size_points=13.0, + pixels_above_lines=10, pixels_below_lines=3) + t_h3 = buf.create_tag("h3", weight=Pango.Weight.BOLD, size_points=11.0, + pixels_above_lines=8, pixels_below_lines=2) + t_bold = buf.create_tag("bold", weight=Pango.Weight.BOLD) + t_ital = buf.create_tag("ital", style=Pango.Style.ITALIC) + t_code = buf.create_tag("code", family="Monospace", size_points=10.0, + background="#f0f0f4", foreground="#c7254e") + t_quote = buf.create_tag("quote", left_margin=20, foreground="#666", + style=Pango.Style.ITALIC, + pixels_above_lines=2, pixels_below_lines=2) + t_hr = buf.create_tag("hr", foreground="#aaa", + pixels_above_lines=6, pixels_below_lines=6) + t_tbl = buf.create_tag("tbl", family="Monospace", size_points=10.0, + pixels_above_lines=1, pixels_below_lines=1) + t_thdr = buf.create_tag("thdr", family="Monospace", size_points=10.0, + weight=Pango.Weight.BOLD, + pixels_above_lines=2, pixels_below_lines=1) + + _inline_re = re.compile(r'(\*\*.*?\*\*|\*.*?\*|`.*?`)') + + def _insert_inline(line: str, end_iter, *extra_tags) -> None: + for part in _inline_re.split(line): + if part.startswith("**") and part.endswith("**") and len(part) > 4: + buf.insert_with_tags(end_iter, part[2:-2], t_bold, *extra_tags) + elif part.startswith("*") and part.endswith("*") and len(part) > 2: + buf.insert_with_tags(end_iter, part[1:-1], t_ital, *extra_tags) + elif part.startswith("`") and part.endswith("`") and len(part) > 2: + buf.insert_with_tags(end_iter, part[1:-1], t_code, *extra_tags) + elif extra_tags: + buf.insert_with_tags(end_iter, part, *extra_tags) + else: + buf.insert(end_iter, part) + + # Collect lines, detect table blocks so we can measure column widths + lines = text.splitlines() + # Pre-scan: mark which lines are table rows vs separators + _tbl_sep = re.compile(r'^\|[-| :]+\|$') + + def _is_table_row(s: str) -> bool: + return s.strip().startswith("|") and s.strip().endswith("|") + + first_line = True + i = 0 + while i < len(lines): + raw = lines[i].rstrip() + end = buf.get_end_iter() + + if not first_line: + buf.insert(end, "\n") + end = buf.get_end_iter() + first_line = False + + # --- Table block: gather all consecutive table rows --- + if _is_table_row(raw): + tbl_rows: list[list[str]] = [] + while i < len(lines) and (_is_table_row(lines[i].rstrip()) or _tbl_sep.match(lines[i].rstrip())): + row_line = lines[i].rstrip() + if not _tbl_sep.match(row_line): + cells = [c.strip() for c in row_line.strip("|").split("|")] + tbl_rows.append(cells) + i += 1 + + if tbl_rows: + # Column widths: max of each column (plain text length, stripped of markup) + n_cols = max(len(r) for r in tbl_rows) + widths = [0] * n_cols + for row in tbl_rows: + for ci, cell in enumerate(row): + plain = re.sub(r'\*\*|`|\*', '', cell) + widths[ci] = max(widths[ci], len(plain)) + + for ri, row in enumerate(tbl_rows): + end = buf.get_end_iter() + tag = t_thdr if ri == 0 else t_tbl + line_text = "" + for ci in range(n_cols): + cell = row[ci] if ci < len(row) else "" + plain = re.sub(r'\*\*|`|\*', '', cell) + line_text += plain.ljust(widths[ci] + 2) + buf.insert_with_tags(end, line_text.rstrip(), tag) + if ri < len(tbl_rows) - 1: + end = buf.get_end_iter() + buf.insert(end, "\n") + continue # i already advanced + + # --- Normal lines --- + if raw.startswith("### "): + buf.insert_with_tags(end, raw[4:], t_h3) + elif raw.startswith("## "): + buf.insert_with_tags(end, raw[3:], t_h2) + elif raw.startswith("# "): + buf.insert_with_tags(end, raw[2:], t_h1) + elif raw.startswith("> "): + _insert_inline(raw[2:], end, t_quote) + elif re.match(r'^-{3,}$', raw): + buf.insert_with_tags(end, "─" * 64, t_hr) + elif raw.startswith("- ") or raw.startswith("* "): + buf.insert(end, " • ") + end = buf.get_end_iter() + _insert_inline(raw[2:], end) + elif re.match(r'^\d+\. ', raw): + m = re.match(r'^(\d+\. )(.*)', raw) + if m: + buf.insert(end, " " + m.group(1)) + end = buf.get_end_iter() + _insert_inline(m.group(2), end) + else: + _insert_inline(raw, end) + + i += 1 + + sw.add(view) + return sw + + def _text_panel(text: str, *, monospace: bool = True, height: int = 180) -> Gtk.ScrolledWindow: sw = Gtk.ScrolledWindow() sw.set_min_content_height(height) @@ -332,30 +735,191 @@ class SettingsDialog: self._tr_cache: dict = {} self._wf_idx = self._stt_idx = self._llm_idx = 0 - self.dlg = Gtk.Dialog(title="Blitztext — Settings", transient_for=parent, modal=True) - self.dlg.set_default_size(620, 600) - self.dlg.add_button("Close", Gtk.ResponseType.CLOSE) - self.dlg.add_button("Save", RESP_SAVE) - self.dlg.add_button("Save & Restart", RESP_SAVE_RESTART) + self.dlg = Gtk.Dialog(transient_for=parent, modal=True) - nb = Gtk.Notebook() - self.dlg.get_content_area().pack_start(nb, True, True, 0) - # Build each tab lazily on first view so the dialog opens instantly. The - # heavy bits are the Gtk.FileChooserButton pickers in Input/Benchmark - # (~0.2s each to construct); deferring them until you visit that tab keeps - # the initial open near-instant. _collect() force-builds any unvisited tab - # before saving, so no field is ever missed. - self._pending_tabs: dict = {} - for title, builder in (("Presets", self._build_presets), - ("Engines", self._build_engines), - ("Input", self._build_input), - ("General", self._build_general), - ("Benchmark", self._build_benchmark), - ("Log", self._build_log), - ("About", self._build_about)): - self._pending_tabs[_page(nb, title)] = builder - self._build_tab(nb.get_nth_page(0)) # the visible tab, eagerly - nb.connect("switch-page", lambda _nb, page, _n: self._build_tab(page)) + _header = Gtk.HeaderBar() + _header.set_show_close_button(True) + _header.set_title("Blitztext — Settings") + self._save_restart_btn = Gtk.Button(label="Save & Restart") + self._save_restart_btn.get_style_context().add_class("suggested-action") + self._save_restart_btn.connect("clicked", lambda _b: self.dlg.response(RESP_SAVE_RESTART)) + self._save_restart_btn.set_tooltip_text( + "Save and restart the daemon — required when changing STT engine, hotkeys, " + "microphone, or wakeword server.") + _header.pack_end(self._save_restart_btn) + _wizard_btn = Gtk.Button(label="Setup Wizard…") + _wizard_btn.set_tooltip_text("Re-run the first-run setup wizard.") + _wizard_btn.connect("clicked", self._open_wizard) + _header.pack_start(_wizard_btn) + _save = Gtk.Button(label="Save") + _save.connect("clicked", lambda _b: self.dlg.response(RESP_SAVE)) + _save.set_tooltip_text("Save settings and apply what can be applied without restarting.") + _header.pack_end(_save) + # Status label shown after Save — inline, no modal popup + self._status_lbl = Gtk.Label() + self._status_lbl.set_margin_start(8); self._status_lbl.set_margin_end(4) + _header.set_custom_title(self._status_lbl) + _header.show_all() + self._status_lbl.hide() # hidden until first save + self.dlg.set_titlebar(_header) + + _prov = Gtk.CssProvider() + _prov.load_from_data(b""" +.bt-section { + font-weight: bold; + font-size: small; + letter-spacing: 1px; + color: mix(@theme_fg_color, @theme_bg_color, 0.45); +} +.bt-section image { + margin: 0; +} +.bt-card { + border-radius: 8px; + border: 1px solid mix(@theme_fg_color, @theme_bg_color, 0.8); + padding: 2px 0px; + margin-bottom: 4px; +} +.bt-card row { + padding: 0px; +} +.bt-infobox { + border-radius: 6px; + border: 1px solid rgba(66, 133, 244, 0.50); + background-color: rgba(66, 133, 244, 0.09); + padding: 4px; + margin-bottom: 10px; +} +.bt-infobox label { + color: @theme_fg_color; +} +.bt-sidebar-active { + background-color: alpha(@theme_selected_bg_color, 0.15); + border-radius: 6px; +} +.sidebar { + background-color: mix(@theme_bg_color, @theme_fg_color, 0.03); +} +.bt-emoji-btn { + font-size: 20px; + min-width: 38px; + min-height: 38px; + padding: 2px; +} +.bt-emoji-cat-btn { + font-size: 18px; + min-width: 34px; + min-height: 34px; + padding: 2px; +} +""") + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), _prov, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + # Widen dialog for sidebar + self.dlg.set_default_size(860, 700) + + # Outer horizontal container + _body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.dlg.get_content_area().pack_start(_body, True, True, 0) + + # Left sidebar + _sidebar_scroll = Gtk.ScrolledWindow() + _sidebar_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + _sidebar_scroll.set_size_request(170, -1) + _sidebar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + _sidebar_scroll.add(_sidebar_box) + _sidebar_scroll.get_style_context().add_class("sidebar") + _body.pack_start(_sidebar_scroll, False, False, 0) + + _vsep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + _body.pack_start(_vsep, False, False, 0) + + # Right stack + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.NONE) + _body.pack_start(self._stack, True, True, 0) + + # Resize-grip strip — sits below the body, above the button row, so + # users notice the window is resizable. + _grip_strip = Gtk.Box() + _grip = Gtk.DrawingArea() + _grip.set_size_request(18, 10) + _grip.set_halign(Gtk.Align.END) + _grip.set_can_focus(False) + _grip.connect("draw", self._draw_resize_grip) + _grip_strip.pack_end(_grip, False, False, 0) + self.dlg.get_content_area().pack_end(_grip_strip, False, False, 0) + + # Sidebar helpers + def _add_sidebar_section(label: str) -> None: + lbl = Gtk.Label(xalign=0.0) + lbl.set_markup(f'{GLib.markup_escape_text(label.upper())}') + lbl.set_margin_top(14); lbl.set_margin_bottom(2) + lbl.set_margin_start(12); lbl.set_margin_end(8) + _sidebar_box.pack_start(lbl, False, False, 0) + + def _add_sidebar_row(label: str, icon: str, page_widget) -> Gtk.Button: + btn = Gtk.Button() + btn.get_style_context().add_class("flat") + btn_box = Gtk.Box(spacing=8) + btn_box.set_margin_top(3); btn_box.set_margin_bottom(3) + btn_box.set_margin_start(8); btn_box.set_margin_end(8) + if icon: + img = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU) + img.set_pixel_size(16) + btn_box.pack_start(img, False, False, 0) + lbl = Gtk.Label(label=label, xalign=0.0) + btn_box.pack_start(lbl, True, True, 0) + btn.add(btn_box) + btn.connect("clicked", lambda _b, pw=page_widget: self._show_page(pw)) + _sidebar_box.pack_start(btn, False, False, 0) + return btn + + def _stack_page(title: str) -> Gtk.Box: + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + sw = Gtk.ScrolledWindow() + sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.ALWAYS) + sw.set_overlay_scrolling(False) + outer.pack_start(sw, True, True, 0) + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + for m in ("top", "bottom", "start", "end"): + getattr(inner, f"set_margin_{m}")(15) + sw.add(inner) + outer._bt_inner = inner + self._stack.add_named(outer, title) + return outer + + # Build pages lazily on first visit — heavy bits (FileChooserButton etc.) + # are deferred so the dialog opens instantly. + self._pending_pages: dict = {} # page widget → builder + self._sidebar_btns: dict = {} # page widget → sidebar button + + def _reg(section_label, sidebar_label, icon, builder): + if section_label: + _add_sidebar_section(section_label) + pg = _stack_page(sidebar_label) + btn = _add_sidebar_row(sidebar_label, icon, pg) + self._pending_pages[pg] = builder + self._sidebar_btns[pg] = btn + return pg + + _reg(None, "Presets", "document-properties-symbolic", self._build_presets) + _reg("General", "General", "preferences-system-symbolic", self._build_general) + _reg("Input", "Keyboard", "input-keyboard-symbolic", self._build_keyboard) + _reg(None, "Wakeword", "audio-input-microphone-symbolic", self._build_wakeword) + _reg("Engines", "STT Engines", "network-server-symbolic", self._build_engines) + _reg("Benchmark", "Benchmark — STT", "utilities-system-monitor-symbolic", self._build_benchmark_stt) + _reg(None, "Benchmark — Wakeword", "audio-input-microphone-symbolic", self._build_benchmark_ww) + _reg("System", "Log", "text-x-generic-symbolic", self._build_log) + _reg(None, "Manual", "help-contents-symbolic", self._build_manual) + _reg(None, "About", "help-about-symbolic", self._build_about) + + # Show the first page on open + first_page = list(self._pending_pages)[0] + self._show_page(first_page) self._bind_entry = None self._bind_pressed: list[str] = [] @@ -364,17 +928,29 @@ class SettingsDialog: self.dlg.connect("response", self._on_response) self.dlg.connect("destroy", lambda *_: self._cleanup()) - def _build_tab(self, page: Gtk.Box) -> None: - """Build a notebook tab's contents the first time it's shown (lazy).""" - builder = self._pending_tabs.pop(page, None) + def _show_page(self, page) -> None: + """Switch to page, build it on first visit, update sidebar highlight.""" + self._build_page(page) + self._stack.set_visible_child(page) + for pw, btn in self._sidebar_btns.items(): + ctx = btn.get_style_context() + if pw is page: + ctx.add_class("bt-sidebar-active") + else: + ctx.remove_class("bt-sidebar-active") + + def _build_page(self, page) -> None: + """Build a page's contents the first time it's shown (lazy).""" + builder = self._pending_pages.pop(page, None) if builder is not None: - builder(page) - page.show_all() # the dialog may already be on-screen + content = getattr(page, "_bt_inner", page) + builder(content) + page.show_all() def _force_build_tabs(self) -> None: - """Build any not-yet-visited tabs so _collect() sees every field.""" - for page in list(self._pending_tabs): - self._build_tab(page) + """Build any not-yet-visited pages so _collect() sees every field.""" + for page in list(self._pending_pages): + self._build_page(page) # ===== Presets ========================================================== def _build_presets(self, page: Gtk.Box) -> None: @@ -383,44 +959,58 @@ class SettingsDialog: "(for example into a polished email). Trigger it by speaking its " "keyword, or with an optional keyboard shortcut.") self._wf_idx = 0 + + # ── Selector bar ────────────────────────────────────────────────────── bar = Gtk.Box(spacing=8) - self.wf_combo = Gtk.ComboBoxText() + bar.set_margin_bottom(6) + self.wf_combo = _block_scroll(Gtk.ComboBoxText()) for wf in self.cfg.workflows: self.wf_combo.append_text(wf.name) self.wf_combo.set_active(0) self.wf_combo.connect("changed", self._wf_changed) bar.pack_start(self.wf_combo, True, True, 0) add = Gtk.Button(label="+ Add"); add.connect("clicked", self._wf_add) - rm = Gtk.Button(label="Delete"); rm.connect("clicked", self._wf_delete) + rm = Gtk.Button(label="Delete"); rm.connect("clicked", self._wf_delete) bar.pack_start(add, False, False, 0); bar.pack_start(rm, False, False, 0) - page.pack_start(bar, False, False, 4) + page.pack_start(bar, False, False, 0) - form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - page.pack_start(form, True, True, 6) - self.wf_name = _labeled(form, "Name", _entry(placeholder="Preset name"), + # ── Identity card ───────────────────────────────────────────────────── + id_card = _card_section(page, "Identity", margin_top=4, icon="emblem-system-symbolic") + self.wf_name = _labeled(id_card, "Name", _entry(placeholder="Preset name"), tooltip="A short name for this action, shown in the main panel.") - self.wf_icon = _labeled(form, "Icon (emoji)", _entry(placeholder="⚡ (shown in the ‘matched preset’ notification)"), - tooltip="An emoji shown next to this preset when a voice command matches it — " - "give each preset a distinct one so you can tell at a glance which fired.") - self.wf_desc = _labeled(form, "Description", _entry(placeholder="Short description shown in the panel"), + self.wf_icon = self._icon_field_lb(id_card, "Icon (emoji)") + self.wf_desc = _labeled(id_card, "Description", _entry(placeholder="Short description shown in the panel"), tooltip="One line explaining what this preset does.") - self.wf_keywords = _labeled(form, "Keywords (comma)", _entry(placeholder="nicer email, bessere email"), - tooltip="Spoken trigger words. Say one at the start or end of your speech to use this preset.") - self.wf_hotkey = self._key_field(form, "Hotkey (optional)", "", placeholder="click Set, or e.g. ++e", width=130) - self.wf_mode = _labeled(form, "Mode", _combo(["transcribe", "rewrite", "stream"]), - tooltip="‘transcribe’ types your words as-is. ‘rewrite’ sends them to the language model first. ‘stream’ shows live text from a realtime engine.") - self.wf_model = _labeled(form, "LLM model (opt.)", _entry(placeholder="blank = use the active LLM engine's model"), - tooltip="Override the language model just for this preset. Leave blank to use the active LLM engine.") - self.wf_temp = _labeled(form, "Temperature (opt.)", _entry(placeholder="blank = engine default (e.g. 0.3)"), - tooltip="Creativity of the rewrite, 0–1. Lower is more predictable. Blank uses the engine default.") - form.pack_start(Gtk.Label(label="Prompt sent to the LLM (rewrite mode):", xalign=0.0), False, False, 6) + # ── Trigger card ────────────────────────────────────────────────────── + trig_card = _card_section(page, "Trigger", icon="input-keyboard-symbolic") + self.wf_keywords = _labeled(trig_card, "Keywords", _entry(placeholder="nicer email, bessere email"), + tooltip="Spoken trigger words, comma-separated. Say one at the start or end of your speech to activate this preset.") + self.wf_hotkey = self._key_field_lb(trig_card, "Hotkey (optional)", "", placeholder="click Set, or e.g. ++e") + + # ── Behaviour card ──────────────────────────────────────────────────── + beh_card = _card_section(page, "Behaviour", icon="system-run-symbolic") + self.wf_mode = _labeled(beh_card, "Mode", _combo(["transcribe", "rewrite", "stream"]), + tooltip="’transcribe’ types your words as-is. ‘rewrite’ sends them to the language model first. ‘stream’ shows live text from a realtime engine.") + # Engine dropdown — "(active engine)" + one entry per configured LLM engine. + llm_names = [e.name for e in self.cfg.llm_engines] + self.wf_llm_engine = _labeled( + beh_card, "LLM engine", + _combo(["(active engine)"] + llm_names), + tooltip="Which LLM engine to use for the rewrite step. ‘(active engine)’ follows whatever is selected in the Engines tab.", + ) + self.wf_temp = _labeled(beh_card, "Temperature (opt.)", _entry(placeholder="blank = engine default (e.g. 0.3)"), + tooltip="Creativity of the rewrite, 0–1. Lower is more predictable. Blank uses the engine default.") + + # ── Prompt ──────────────────────────────────────────────────────────── + _section_title(page, "Prompt sent to the LLM (rewrite mode)", margin_top=14, icon="applications-science-symbolic") frame = Gtk.Frame(); frame.set_shadow_type(Gtk.ShadowType.IN) - sw = Gtk.ScrolledWindow(); sw.set_min_content_height(150) + sw = Gtk.ScrolledWindow(); sw.set_min_content_height(140) self.wf_prompt = Gtk.TextView(); self.wf_prompt.set_wrap_mode(Gtk.WrapMode.WORD) - self.wf_prompt.set_left_margin(6); self.wf_prompt.set_top_margin(6) + self.wf_prompt.set_left_margin(8); self.wf_prompt.set_top_margin(6) + self.wf_prompt.set_right_margin(8); self.wf_prompt.set_bottom_margin(6) sw.add(self.wf_prompt); frame.add(sw) - form.pack_start(frame, True, True, 0) + page.pack_start(frame, True, True, 0) self._wf_load(0) def _wf_load(self, idx: int) -> None: @@ -433,7 +1023,10 @@ class SettingsDialog: self.wf_keywords.set_text(", ".join(wf.keywords)) self.wf_hotkey.set_text(wf.hotkey) self.wf_mode.set_active(["transcribe", "rewrite", "stream"].index(wf.mode) if wf.mode in ("transcribe", "rewrite", "stream") else 0) - self.wf_model.set_text(wf.model or "") + llm_names = [e.name for e in self.cfg.llm_engines] + engine = getattr(wf, "llm_engine", "") or "" + idx_e = (llm_names.index(engine) + 1) if engine in llm_names else 0 + self.wf_llm_engine.set_active(idx_e) self.wf_temp.set_text("" if wf.temperature is None else str(wf.temperature)) self.wf_prompt.get_buffer().set_text(wf.prompt) self._wf_idx = idx @@ -449,7 +1042,8 @@ class SettingsDialog: wf.keywords = [k.strip() for k in self.wf_keywords.get_text().split(",") if k.strip()] wf.hotkey = self.wf_hotkey.get_text().strip() wf.mode = self.wf_mode.get_active_text() or "transcribe" - wf.model = self.wf_model.get_text().strip() or None + engine_text = self.wf_llm_engine.get_active_text() or "" + wf.llm_engine = "" if engine_text == "(active engine)" else engine_text t = self.wf_temp.get_text().strip() wf.temperature = float(t) if _isfloat(t) else None b = self.wf_prompt.get_buffer() @@ -483,23 +1077,204 @@ class SettingsDialog: self.wf_combo.set_active(0) self._wf_load(0) + def _icon_field(self, page: Gtk.Box, label: str, width: int = 130) -> Gtk.Entry: + TOOLTIP = ("An emoji shown next to this preset when a voice command matches it — " + "give each preset a distinct one so you can tell at a glance which fired.") + row = Gtk.Box(spacing=10); row.set_margin_top(3); row.set_margin_bottom(3) + lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) + lbl.set_tooltip_text(TOOLTIP) + row.pack_start(lbl, False, False, 0) + entry = Gtk.Entry(); entry.set_hexpand(True) + entry.set_placeholder_text("⚡") + entry.set_tooltip_text(TOOLTIP) + atk = entry.get_accessible() + if atk: + atk.set_name(label.replace("_", "")) + atk.set_description(TOOLTIP) + row.pack_start(entry, True, True, 0) + btn = Gtk.Button(label="😀") + btn.set_tooltip_text("Pick an emoji") + btn.connect("clicked", lambda _b: self._show_emoji_picker(btn, entry)) + row.pack_start(btn, False, False, 0) + page.pack_start(row, False, False, 0) + return entry + + def _icon_field_lb(self, lb: Gtk.ListBox, label: str, width: int = 130) -> Gtk.Entry: + """_icon_field variant for use inside a card ListBox.""" + TOOLTIP = ("An emoji shown next to this preset when a voice command matches it — " + "give each preset a distinct one so you can tell at a glance which fired.") + box = Gtk.Box(spacing=10) + box.set_margin_top(6); box.set_margin_bottom(6) + box.set_margin_start(12); box.set_margin_end(8) + lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) + lbl.set_tooltip_text(TOOLTIP) + box.pack_start(lbl, False, False, 0) + entry = Gtk.Entry(); entry.set_hexpand(True) + entry.set_placeholder_text("⚡"); entry.set_tooltip_text(TOOLTIP) + box.pack_start(entry, True, True, 0) + btn = Gtk.Button(label="😀"); btn.set_tooltip_text("Pick an emoji") + btn.connect("clicked", lambda _b: self._show_emoji_picker(btn, entry)) + box.pack_start(btn, False, False, 0) + box.pack_start(_info_btn(TOOLTIP), False, False, 0) + _lb_add(lb, box) + return entry + + def _key_field_lb(self, lb: Gtk.ListBox, label: str, value: str, + placeholder: str = "", width: int = 130) -> Gtk.Entry: + """_key_field variant for use inside a card ListBox.""" + box = Gtk.Box(spacing=10) + box.set_margin_top(6); box.set_margin_bottom(6) + box.set_margin_start(12); box.set_margin_end(8) + lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) + box.pack_start(lbl, False, False, 0) + entry = Gtk.Entry(); entry.set_text(value); entry.set_hexpand(True) + if placeholder: + entry.set_placeholder_text(placeholder) + box.pack_start(entry, True, True, 0) + btn = Gtk.Button(label="Set") + btn.connect("clicked", lambda _b, e=entry: self._bind_key(e)) + box.pack_start(btn, False, False, 0) + _lb_add(lb, box) + return entry + + def _show_emoji_picker(self, anchor: Gtk.Widget, entry: Gtk.Entry) -> None: + pop = Gtk.Popover(relative_to=anchor) + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + # -- Search bar ------------------------------------------------------- + search = Gtk.SearchEntry() + search.set_placeholder_text("Search emoji…") + search.set_margin_top(6); search.set_margin_bottom(4) + search.set_margin_start(6); search.set_margin_end(6) + vbox.pack_start(search, False, False, 0) + vbox.pack_start(Gtk.Separator(), False, False, 0) + + # Flat emoji list (all categories) used by search + all_emojis: list[str] = [e for _, _, es in _EMOJI_CATEGORIES for e in es] + + def _keywords(e: str) -> str: + try: + return unicodedata.name(e[0], "").lower() + except Exception: + return "" + + # -- Category view ---------------------------------------------------- + cat_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + stack = Gtk.Stack() + stack.set_transition_type(Gtk.StackTransitionType.NONE) + stack.set_size_request(420, 230) + + cat_bar = Gtk.Box(spacing=0) + cat_bar.set_margin_top(4); cat_bar.set_margin_bottom(4) + cat_bar.set_margin_start(4); cat_bar.set_margin_end(4) + + first_name: str | None = None + active_btn: list[Gtk.Button] = [None] + + def _select(name: str, btn: Gtk.Button) -> None: + stack.set_visible_child_name(name) + if active_btn[0]: + active_btn[0].get_style_context().remove_class("suggested-action") + btn.get_style_context().add_class("suggested-action") + active_btn[0] = btn + + for cat_emoji, cat_name, emojis in _EMOJI_CATEGORIES: + flow = Gtk.FlowBox() + flow.set_max_children_per_line(10) + flow.set_selection_mode(Gtk.SelectionMode.NONE) + flow.set_margin_top(4); flow.set_margin_bottom(4) + flow.set_margin_start(4); flow.set_margin_end(4) + for emoji in emojis: + eb = Gtk.Button(label=emoji) + eb.set_relief(Gtk.ReliefStyle.NONE) + eb.get_style_context().add_class("bt-emoji-btn") + eb.connect("clicked", lambda _b, e=emoji: (entry.set_text(e), pop.popdown())) + flow.add(eb) + sw = Gtk.ScrolledWindow() + sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + sw.add(flow) + stack.add_named(sw, cat_name) + + cb = Gtk.Button(label=cat_emoji) + cb.set_relief(Gtk.ReliefStyle.NONE) + cb.get_style_context().add_class("bt-emoji-cat-btn") + cb.set_tooltip_text(cat_name) + cb.connect("clicked", lambda _b, n=cat_name, b=cb: _select(n, b)) + cat_bar.pack_start(cb, True, True, 0) + + if first_name is None: + first_name = cat_name + active_btn[0] = cb + cb.get_style_context().add_class("suggested-action") + + if first_name: + stack.set_visible_child_name(first_name) + + cat_scroll = Gtk.ScrolledWindow() + cat_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) + cat_scroll.set_min_content_height(44) + cat_scroll.add(cat_bar) + cat_container.pack_start(cat_scroll, False, False, 0) + cat_container.pack_start(Gtk.Separator(), False, False, 0) + cat_container.pack_start(stack, True, True, 0) + + # -- Search results view ---------------------------------------------- + search_sw = Gtk.ScrolledWindow() + search_sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + search_sw.set_size_request(420, 274) + search_flow = Gtk.FlowBox() + search_flow.set_max_children_per_line(10) + search_flow.set_selection_mode(Gtk.SelectionMode.NONE) + search_flow.set_margin_top(4); search_flow.set_margin_bottom(4) + search_flow.set_margin_start(4); search_flow.set_margin_end(4) + search_sw.add(search_flow) + + def _on_search(_e: Gtk.SearchEntry) -> None: + query = search.get_text().strip().lower() + for child in search_flow.get_children(): + search_flow.remove(child) + if query: + cat_container.set_visible(False) + search_sw.set_visible(True) + for emoji in all_emojis: + if query in _keywords(emoji) or query in emoji: + btn = Gtk.Button(label=emoji) + btn.set_relief(Gtk.ReliefStyle.NONE) + btn.get_style_context().add_class("bt-emoji-btn") + btn.connect("clicked", lambda _b, e=emoji: (entry.set_text(e), pop.popdown())) + search_flow.add(btn) + search_flow.show_all() + else: + search_sw.set_visible(False) + cat_container.set_visible(True) + + search.connect("search-changed", _on_search) + + vbox.pack_start(cat_container, True, True, 0) + vbox.pack_start(search_sw, True, True, 0) + pop.add(vbox) + pop.show_all() + search_sw.set_visible(False) # hide search results until user types + # ===== Engines ========================================================== def _build_engines(self, page: Gtk.Box) -> None: _infobox(page, "Engines do the work. The speech-to-text engine turns your voice into " - "text; the language model rewrites it. Each can run locally on this " - "computer or on a server you enter. A green dot means it is reachable, " - "red means offline. Use Test to try the speech engine.") - page.pack_start(self._stt_section(), False, False, 4) - page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(self._llm_section(), False, False, 4) + "text; the language model rewrites it. Each can run locally or on " + "a server you enter. A green dot means it is reachable, red means offline.") + _section_title(page, "Speech-to-text engine", margin_top=4, icon="audio-input-microphone-symbolic") + page.pack_start(self._stt_section(), False, False, 0) + _section_title(page, "Language model (rewrite)", icon="applications-science-symbolic") + page.pack_start(self._llm_section(), False, False, 0) self._refresh_status() def _stt_section(self) -> Gtk.Box: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.pack_start(Gtk.Label(label="Speech-to-text engine", xalign=0.0), False, False, 2) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) self._stt_idx = 0 - bar = Gtk.Box(spacing=8) - self.stt_combo = Gtk.ComboBoxText() + + # ── Selector bar ────────────────────────────────────────────────────── + bar = Gtk.Box(spacing=6) + self.stt_combo = _block_scroll(Gtk.ComboBoxText()) for e in self.cfg.stt_engines: self.stt_combo.append_text(e.name) self.stt_combo.set_active(self._index_of(self.cfg.stt_engines, self.cfg.stt_active)) @@ -507,39 +1282,89 @@ class SettingsDialog: self.stt_dot = Gtk.Label(); self.stt_dot.set_markup(_dot(GREY)) bar.pack_start(self.stt_dot, False, False, 0) bar.pack_start(self.stt_combo, True, True, 0) - for label, cb in (("+ Add", self._stt_add), ("+ Stream", self._stt_add_stream), - ("Delete", self._stt_delete), ("Test", self._stt_test), - ("Refresh", lambda _b: self._refresh_status())): - b = Gtk.Button(label=label); b.connect("clicked", cb); bar.pack_start(b, False, False, 0) + # Left: creation + for label, cb in (("+ Add", self._stt_add), ("+ Stream", self._stt_add_stream)): + b = Gtk.Button(label=label); b.connect("clicked", cb) + bar.pack_start(b, False, False, 0) + qs = Gtk.Button(label="Quickstart ▾") + qs.set_tooltip_text("Fill the form from a common service template") + qs.connect("clicked", self._show_stt_templates) + bar.pack_start(qs, False, False, 0) + # Right: actions (Test moved to its own row below the config) + for label, cb, tip in ( + ("Delete", self._stt_delete, "Remove this engine preset"), + ("⟳", lambda _b: self._refresh_status(), "Re-check connection status"), + ): + b = Gtk.Button(label=label); b.set_tooltip_text(tip); b.connect("clicked", cb) + bar.pack_end(b, False, False, 0) box.pack_start(bar, False, False, 2) - form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2) - self.stt_name = _labeled(form, "Name", _entry(placeholder="e.g. faster-whisper GPU")) - self.stt_type = _labeled(form, "Type", _combo(["local", "openai", "riva_realtime"])) - self.stt_url = _url_field(form, "URL", "http://localhost:8010/v1 · realtime: http://localhost:8006/v1", - lambda: self._populate_models(self.stt_model, self.stt_url.get_text().strip(), self.stt_key.get_text().strip())) - self.stt_model = _labeled(form, "Model", _model_combo("blank = server default · tiny/base/small… for local")) - self.stt_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. GROQ_API_KEY (optional)")) + # ── Engine config card ──────────────────────────────────────────────── + cfg_card = _card_section(box, "", margin_top=6) + self.stt_name = _labeled(cfg_card, "Name", _entry(placeholder="e.g. faster-whisper GPU"), + tooltip="A label for this engine, shown in the dropdown.") + self.stt_type = _labeled(cfg_card, "Type", _type_combo(_STT_TYPES, "local"), + tooltip="Internal: faster-whisper runs inside Blitztext, no server needed. " + "Server: any OpenAI-compatible /v1 endpoint on your LAN or a cloud API. " + "Realtime: live streaming via NVIDIA Riva or NIM.") + self.stt_url = _url_field_lb(cfg_card, "URL", + "http://localhost:8010/v1 · realtime: http://localhost:8006/v1", + lambda: self._populate_models(self.stt_model, self.stt_url.get_text().strip(), self.stt_key.get_text().strip()), + tooltip="Base URL of the server including /v1 for OpenAI-compatible servers " + "(e.g. http://host:8010/v1). For WhisperX or other non-standard servers, " + "enter the full transcription endpoint directly " + "(e.g. http://host:8081/transcribe).") + self.stt_model = _labeled(cfg_card, "Model", _model_combo("tiny/base/small… or server model"), + tooltip="Which Whisper model to load. For Internal: tiny (fastest) → large-v3 (most accurate). " + "For a server, press ⟳ to fetch available models from its URL.") + self.stt_key = _labeled(cfg_card, "API key env", _entry(placeholder="e.g. GROQ_API_KEY (optional)"), + tooltip="Name of the environment variable that holds your API key. Leave blank for local servers.") + self.stt_timeout = _labeled(cfg_card, "Timeout (s)", _entry(placeholder="30"), + tooltip="Seconds to wait for a transcription response. " + "Increase for slow servers (e.g. WhisperX with diarization can take 60–120 s). " + "Default: 30.") self.stt_url.connect("changed", lambda _e: self._schedule_models("stt")) self.stt_key.connect("changed", lambda _e: self._schedule_models("stt")) self.stt_type.connect("changed", self._stt_type_changed) - sep = Gtk.Separator(); sep.set_margin_top(4); form.pack_start(sep, False, False, 4) - form.pack_start(Gtk.Label(label="Local engine (faster-whisper) — device & precision", xalign=0.0), False, False, 0) - self.stt_device = _labeled(form, "Device", _combo(["auto", "cpu", "cuda"], self.cfg.device)) - self.stt_compute = _labeled(form, "Compute type", _combo(["auto", "int8", "float16", "int8_float16"], self.cfg.compute_type)) + # ── Device & precision card (local engines only) ───────────────────── + self.stt_dev_card = _card_section(box, "", margin_top=4) + self.stt_device = _labeled(self.stt_dev_card, "Device", _type_combo(_DEVICE_OPTIONS, self.cfg.device), + tooltip="Which processor runs the speech model. " + "Auto tries your GPU first. GPU (CUDA) requires NVIDIA — much faster than CPU.") + self.stt_compute = _labeled(self.stt_dev_card, "Compute type", _type_combo(_COMPUTE_OPTIONS, self.cfg.compute_type), + tooltip="Precision of the model. int8 is fastest and uses least memory. " + "float16 is most accurate. Auto lets Blitztext decide.") + + test_row = Gtk.Box(spacing=10) + test_row.set_margin_top(6) + stt_test_btn = Gtk.Button(label="Test") + stt_test_btn.set_tooltip_text("Record 4 s and transcribe to test this engine") + stt_test_btn.connect("clicked", self._stt_test) + stt_test_btn.set_valign(Gtk.Align.START) + test_row.pack_start(stt_test_btn, False, False, 0) + self.stt_result = Gtk.Label(xalign=0.0) + self.stt_result.set_line_wrap(True) + self.stt_result.set_selectable(True) + self.stt_result.set_valign(Gtk.Align.START) + test_row.pack_start(self.stt_result, True, True, 0) + box.pack_start(test_row, False, False, 2) + + self.stt_bench_info = Gtk.Label(xalign=0.0) + self.stt_bench_info.set_use_markup(True) + self.stt_bench_info.set_margin_top(2) + box.pack_start(self.stt_bench_info, False, False, 0) - self.stt_result = Gtk.Label(xalign=0.0); self.stt_result.set_line_wrap(True) - box.pack_start(self.stt_result, False, False, 2) self._stt_load(self.stt_combo.get_active()) return box def _llm_section(self) -> Gtk.Box: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.pack_start(Gtk.Label(label="Language model (rewrite)", xalign=0.0), False, False, 2) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) self._llm_idx = 0 - bar = Gtk.Box(spacing=8) - self.llm_combo = Gtk.ComboBoxText() + + # ── Selector bar ────────────────────────────────────────────────────── + bar = Gtk.Box(spacing=6) + self.llm_combo = _block_scroll(Gtk.ComboBoxText()) for e in self.cfg.llm_engines: self.llm_combo.append_text(e.name) self.llm_combo.set_active(self._index_of(self.cfg.llm_engines, self.cfg.llm_active)) @@ -547,19 +1372,36 @@ class SettingsDialog: self.llm_dot = Gtk.Label(); self.llm_dot.set_markup(_dot(GREY)) bar.pack_start(self.llm_dot, False, False, 0) bar.pack_start(self.llm_combo, True, True, 0) - for label, cb in (("+ Add", self._llm_add), ("Delete", self._llm_delete), - ("Refresh", lambda _b: self._refresh_status())): - b = Gtk.Button(label=label); b.connect("clicked", cb); bar.pack_start(b, False, False, 0) + add = Gtk.Button(label="+ Add"); add.connect("clicked", self._llm_add) + bar.pack_start(add, False, False, 0) + qs = Gtk.Button(label="Quickstart ▾") + qs.set_tooltip_text("Fill the form from a common service template") + qs.connect("clicked", self._show_llm_templates) + bar.pack_start(qs, False, False, 0) + for label, cb, tip in ( + ("Delete", self._llm_delete, "Remove this engine preset"), + ("⟳", lambda _b: self._refresh_status(), "Re-check connection status"), + ): + b = Gtk.Button(label=label); b.set_tooltip_text(tip); b.connect("clicked", cb) + bar.pack_end(b, False, False, 0) box.pack_start(bar, False, False, 2) - form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2) - self.llm_name = _labeled(form, "Name", _entry(placeholder="e.g. Local Qwen")) - self.llm_type = _labeled(form, "Type", _combo(["local", "cloud"])) - self.llm_url = _url_field(form, "Base URL", "http://localhost:28080/v1 · https://api.openai.com/v1", - lambda: self._populate_models(self.llm_model, self.llm_url.get_text().strip(), self.llm_key.get_text().strip())) - self.llm_model = _labeled(form, "Model", _model_combo("pick after entering URL")) - self.llm_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. OPENAI_API_KEY (blank for local)")) - self.llm_temp = _labeled(form, "Temperature", _entry(placeholder="0.3")) + # ── Engine config card ──────────────────────────────────────────────── + cfg_card = _card_section(box, "", margin_top=6) + self.llm_name = _labeled(cfg_card, "Name", _entry(placeholder="e.g. Local Qwen"), + tooltip="A label for this language model engine, shown in the dropdown.") + self.llm_type = _labeled(cfg_card, "Type", _type_combo(_LLM_TYPES, "cloud"), + tooltip="LAN server: a model on your own machine or network (Ollama, vLLM, LM Studio, …). " + "Cloud service: a remote paid API like OpenAI or Groq.") + self.llm_url = _url_field_lb(cfg_card, "Base URL", + "http://localhost:28080/v1 · https://api.openai.com/v1", + lambda: self._populate_models(self.llm_model, self.llm_url.get_text().strip(), self.llm_key.get_text().strip())) + self.llm_model = _labeled(cfg_card, "Model", _model_combo("pick after entering URL"), + tooltip="Which language model to use for rewriting. Press ⟳ on the URL field to fetch available models.") + self.llm_key = _labeled(cfg_card, "API key env", _entry(placeholder="e.g. OPENAI_API_KEY (blank for local)"), + tooltip="Name of the environment variable holding your API key. Leave blank for local servers.") + self.llm_temp = _labeled(cfg_card, "Temperature", _entry(placeholder="0.3"), + tooltip="How creative the rewrite is, 0.0–1.0. 0.3 is a good default.") self.llm_url.connect("changed", lambda _e: self._schedule_models("llm")) self.llm_key.connect("changed", lambda _e: self._schedule_models("llm")) self._llm_load(self.llm_combo.get_active()) @@ -571,8 +1413,9 @@ class SettingsDialog: return e = self.cfg.stt_engines[idx] self.stt_name.set_text(e.name) - self.stt_type.set_active(["local", "openai", "riva_realtime"].index(e.type) if e.type in ("local", "openai", "riva_realtime") else 0) + self.stt_type.set_active(next((i for i, (k, _) in enumerate(_STT_TYPES) if k == e.type), 0)) self.stt_url.set_text(e.url); self.stt_key.set_text(e.api_key_env) + self.stt_timeout.set_text("" if e.timeout == 30 else str(e.timeout)) if e.type == "local": _fill_combo(self.stt_model, ["tiny", "base", "small", "medium", "large-v3"], e.model or self.cfg.model) elif e.type == "openai": @@ -581,6 +1424,24 @@ class SettingsDialog: else: _fill_combo(self.stt_model, [], e.model) self._stt_idx = idx + if hasattr(self, "stt_dev_card"): + self.stt_dev_card.set_visible(e.type == "local") + self._stt_update_bench_info(e.name) + + def _stt_update_bench_info(self, engine_name: str) -> None: + if not hasattr(self, "stt_bench_info"): + return + last = self.cfg.bench_last.get(engine_name) + if not last: + self.stt_bench_info.set_markup("") + return + if last.get("ok"): + self.stt_bench_info.set_markup( + f'Last benchmark: ' + f'{last["seconds"]:.2f}s · {last["accuracy"]:.1f}% accuracy') + else: + self.stt_bench_info.set_markup( + 'Last benchmark: failed') def _stt_commit(self) -> None: idx = self._stt_idx @@ -589,10 +1450,14 @@ class SettingsDialog: e = self.cfg.stt_engines[idx] new_name = self.stt_name.get_text().strip() or e.name e.name = new_name - e.type = self.stt_type.get_active_text() or "local" + e.type = _type_key(self.stt_type, _STT_TYPES, "local") e.url = self.stt_url.get_text().strip().rstrip("/") e.model = _combo_text(self.stt_model) e.api_key_env = self.stt_key.get_text().strip() + try: + e.timeout = max(5, int(self.stt_timeout.get_text().strip() or "30")) + except ValueError: + e.timeout = 30 if self.stt_combo.get_active_text() != new_name: self.stt_combo.remove(idx); self.stt_combo.insert_text(idx, new_name); self.stt_combo.set_active(idx) @@ -672,7 +1537,7 @@ class SettingsDialog: return e = self.cfg.llm_engines[idx] self.llm_name.set_text(e.name) - self.llm_type.set_active(["local", "cloud"].index(e.type) if e.type in ("local", "cloud") else 1) + self.llm_type.set_active(next((i for i, (k, _) in enumerate(_LLM_TYPES) if k == e.type), 0)) self.llm_url.set_text(e.url) self.llm_key.set_text(e.api_key_env); self.llm_temp.set_text(str(e.temperature)) _fill_combo(self.llm_model, [], e.model) @@ -686,7 +1551,7 @@ class SettingsDialog: e = self.cfg.llm_engines[idx] new_name = self.llm_name.get_text().strip() or e.name e.name = new_name - e.type = self.llm_type.get_active_text() or "cloud" + e.type = _type_key(self.llm_type, _LLM_TYPES, "cloud") e.url = self.llm_url.get_text().strip().rstrip("/") e.model = _combo_text(self.llm_model) e.api_key_env = self.llm_key.get_text().strip() @@ -715,14 +1580,57 @@ class SettingsDialog: self.llm_combo.remove(self._llm_idx); self._llm_idx = -1 self.llm_combo.set_active(0); self._llm_load(0) + def _show_stt_templates(self, btn: Gtk.Button) -> None: + menu = Gtk.Menu() + for tpl in _STT_TEMPLATES: + item = Gtk.MenuItem(label=tpl[0]) + item.connect("activate", lambda _i, t=tpl: self._stt_apply_template(t)) + menu.append(item) + menu.show_all() + menu.popup_at_widget(btn, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, None) + + def _stt_apply_template(self, tpl: tuple) -> None: + name, url, key, type_key, model, timeout = tpl + self.stt_name.set_text(name) + self.stt_url.set_text(url) + self.stt_key.set_text(key) + self.stt_type.set_active(next((i for i, (k, _) in enumerate(_STT_TYPES) if k == type_key), 0)) + self.stt_timeout.set_text("" if timeout == 30 else str(timeout)) + if model: + _fill_combo(self.stt_model, [model], model) + elif url: + self._populate_models(self.stt_model, url, key) + + def _show_llm_templates(self, btn: Gtk.Button) -> None: + menu = Gtk.Menu() + for tpl in _LLM_TEMPLATES: + item = Gtk.MenuItem(label=tpl[0]) + item.connect("activate", lambda _i, t=tpl: self._llm_apply_template(t)) + menu.append(item) + menu.show_all() + menu.popup_at_widget(btn, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, None) + + def _llm_apply_template(self, tpl: tuple) -> None: + name, url, key, type_key, model = tpl + self.llm_name.set_text(name) + self.llm_url.set_text(url) + self.llm_key.set_text(key) + self.llm_type.set_active(next((i for i, (k, _) in enumerate(_LLM_TYPES) if k == type_key), 0)) + if model: + _fill_combo(self.llm_model, [model], model) + elif url: + self._populate_models(self.llm_model, url, key) + def _stt_type_changed(self, _c) -> None: - typ = self.stt_type.get_active_text() + typ = _type_key(self.stt_type, _STT_TYPES, "local") if typ == "local": _fill_combo(self.stt_model, ["tiny", "base", "small", "medium", "large-v3"], _combo_text(self.stt_model)) elif typ == "openai": self._schedule_models("stt") else: _fill_combo(self.stt_model, [], _combo_text(self.stt_model)) + if hasattr(self, "stt_dev_card"): + self.stt_dev_card.set_visible(typ == "local") # -- model dropdowns (fetched from {url}/models) --- def _populate_models(self, combo, url: str, key_env: str) -> None: @@ -732,6 +1640,8 @@ class SettingsDialog: def apply(): cur = _combo_text(combo) _fill_combo(combo, models, cur) + if not models and not cur: + combo.entry.set_placeholder_text("type model name manually") return False GLib.idle_add(apply) threading.Thread(target=work, daemon=True).start() @@ -744,7 +1654,7 @@ class SettingsDialog: def fire(): setattr(self, attr, 0) - if which == "stt" and self.stt_type.get_active_text() == "openai": + if which == "stt" and _type_key(self.stt_type, _STT_TYPES) == "openai": self._populate_models(self.stt_model, self.stt_url.get_text().strip(), self.stt_key.get_text().strip()) elif which == "llm": @@ -802,6 +1712,51 @@ class SettingsDialog: return False # -- key binding ("Set" button captures the next keypress) --- + def _kw_shortcut_row(self, lb, label: str, kw_value: str, kw_placeholder: str, + key_value: str, *, tooltip_kw: str = "", tooltip_key: str = "", + width: int = 150): + """Combined row: [label][keywords entry] | [shortcut label][shortcut entry][Set].""" + row_box = Gtk.Box(spacing=8) + row_box.set_margin_top(6); row_box.set_margin_bottom(6) + row_box.set_margin_start(12); row_box.set_margin_end(8) + + lbl = Gtk.Label(label=label, xalign=0.0) + lbl.set_size_request(width, -1) + row_box.pack_start(lbl, False, False, 0) + + kw_entry = Gtk.Entry() + kw_entry.set_text(kw_value) + kw_entry.set_placeholder_text(kw_placeholder) + kw_entry.set_hexpand(True) + if tooltip_kw: + kw_entry.set_tooltip_text(tooltip_kw) + lbl.set_tooltip_text(tooltip_kw) + row_box.pack_start(kw_entry, True, True, 0) + + sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + sep.set_margin_start(4); sep.set_margin_end(4) + row_box.pack_start(sep, False, False, 0) + + key_lbl = Gtk.Label(label="Shortcut", xalign=0.0) + key_lbl.get_style_context().add_class("dim-label") + row_box.pack_start(key_lbl, False, False, 0) + + key_entry = Gtk.Entry() + key_entry.set_text(key_value) + key_entry.set_placeholder_text("") + key_entry.set_size_request(100, -1) + if tooltip_key: + key_entry.set_tooltip_text(tooltip_key) + key_lbl.set_tooltip_text(tooltip_key) + row_box.pack_start(key_entry, False, False, 0) + + set_btn = Gtk.Button(label="Set") + set_btn.connect("clicked", lambda _b, e=key_entry: self._bind_key(e)) + row_box.pack_start(set_btn, False, False, 0) + + _lb_add(lb, row_box) + return kw_entry, key_entry + def _key_field(self, page: Gtk.Box, label: str, value: str, placeholder: str = "", width: int = 150) -> Gtk.Entry: row = Gtk.Box(spacing=10); row.set_margin_top(3); row.set_margin_bottom(3) lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) @@ -839,190 +1794,311 @@ class SettingsDialog: self._bind_entry = None return True - # ===== Input ============================================================ - def _build_input(self, page: Gtk.Box) -> None: + # ===== Keyboard ========================================================= + def _build_keyboard(self, page: Gtk.Box) -> None: _infobox(page, "Choose how you start and stop dictating. With the default " - "‘modifiers’ mode: hold Ctrl and the Windows key to talk, then press " - "Ctrl to stop and paste. Below you can tune the noise filter, set up " - "hands-free wakeword, and pick sounds that confirm start and stop.") - LW = 175 # uniform label width for all rows in this tab - self.in_mode = _labeled(page, "Input mode", _combo(["modifiers", "hotkeys"], self.cfg.input_mode), + "’modifiers’ mode: hold Ctrl and the Windows key to talk, then press " + "Ctrl to stop and paste. Below you can tune the noise filter and " + "pick sounds that confirm start and stop.") + LW = 170 + + # ── Input mode card ─────────────────────────────────────────────────── + mode_card = _card_section(page, "Input mode & keys", margin_top=4, icon="input-keyboard-symbolic") + self.in_mode = _labeled(mode_card, "Input mode", _combo(["modifiers", "hotkeys"], self.cfg.input_mode), width=LW, - tooltip="’modifiers’: hold/press the keys below. ‘hotkeys’: each preset has its own shortcut combo.") - self.in_ptt = Gtk.Switch(); self.in_ptt.set_active(self.cfg.push_to_talk); self.in_ptt.set_halign(Gtk.Align.START) - _labeled(page, "Push-to-talk", self.in_ptt, width=LW) - self.in_start = self._key_field(page, "Start", self.cfg.key_start, width=LW) - self.in_stop = self._key_field(page, "Stop + paste", self.cfg.key_stop, width=LW) - self.in_send = self._key_field(page, "Stop + paste + Enter", self.cfg.key_send, width=LW) - self.in_cancel = self._key_field(page, "Cancel", self.cfg.key_cancel, width=LW) - page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(Gtk.Label(label="Quality gate", xalign=0.0), False, False, 2) - self.q_min = _labeled(page, "Min seconds", _entry(self.cfg.min_speech_seconds), width=LW, - tooltip="Minimum audio length. Shorter clips are ignored.") - self.q_rms = _labeled(page, "Silence RMS", _entry(self.cfg.silence_rms), width=LW, - tooltip="Microphone volume threshold to ignore background noise.") - self.q_halluc = Gtk.Switch(); self.q_halluc.set_active(self.cfg.reject_hallucinations); self.q_halluc.set_halign(Gtk.Align.START) - _labeled(page, "Reject hallucinations", self.q_halluc, width=LW, - tooltip="Automatically drop STT ghost outputs like ‘Thank you.’ or ‘Bye.’") - self.q_strip = Gtk.Switch(); self.q_strip.set_active(self.cfg.strip_trailing_punctuation); self.q_strip.set_halign(Gtk.Align.START) - _labeled(page, "Strip trailing punctuation", self.q_strip, width=LW, - tooltip="Remove ending periods from pasted text, useful for code inserts.") - page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(Gtk.Label(label="Hands-free (Wakeword)", xalign=0.0), False, False, 2) - self.ww_enabled = Gtk.Switch(); self.ww_enabled.set_active(self.cfg.wakeword_enabled); self.ww_enabled.set_halign(Gtk.Align.START) - _labeled(page, "Enable wakeword", self.ww_enabled, width=LW) - self.ww_dot = Gtk.Label(); self.ww_dot.set_markup(_dot(GREY)) - self.ww_dot.set_tooltip_text("Wakeword (Wyoming / openWakeWord) server: " - "green = reachable, red = unreachable") - self.ww_uri = _url_field(page, "Wakeword engine", "tcp://127.0.0.1:10400", - self._ww_load, dot=self.ww_dot, width=LW) - self.ww_uri.set_text(self.cfg.wakeword_uri) - self.ww_uri.connect("focus-out-event", self._on_ww_uri_leave) - self._probe_dot(self.ww_dot, self.cfg.wakeword_uri, 10400) - self.ww_model = _labeled(page, "Model name", _model_combo("Search models…"), width=LW) - _fill_combo(self.ww_model, [], self.cfg.wakeword_model) + tooltip="’modifiers’: hold/press the keys below — easiest for most people. " + "’hotkeys’: each preset has its own shortcut combo (set per preset).") + self.in_ptt = Gtk.Switch(); self.in_ptt.set_active(self.cfg.push_to_talk) + _switch_row(mode_card, "Push-to-talk", self.in_ptt, width=LW, + description="Hold the Start key to record; release to stop. " + "Off = the key toggles recording on/off.") + self.in_start = self._key_field_lb(mode_card, "Start", self.cfg.key_start, width=LW) + self.in_stop = self._key_field_lb(mode_card, "Stop + paste", self.cfg.key_stop, width=LW) + self.in_send = self._key_field_lb(mode_card, "Stop + paste + Enter", self.cfg.key_send, width=LW) + self.in_cancel = self._key_field_lb(mode_card, "Cancel", self.cfg.key_cancel, width=LW) - self.ww_mic_level = Gtk.LevelBar(); self.ww_mic_level.set_min_value(0); self.ww_mic_level.set_max_value(1) - _labeled(page, "Input level", self.ww_mic_level, width=LW) + # ── Quality gate card ───────────────────────────────────────────────── + q_card = _card_section(page, "Quality gate", icon="security-high-symbolic") + self.q_min = _labeled(q_card, "Min seconds", _entry(str(self.cfg.min_speech_seconds)), width=LW, + tooltip="Minimum audio length. Shorter clips are silently ignored (avoids pasting nothing).") + self.q_rms = _labeled(q_card, "Silence RMS", _entry(str(self.cfg.silence_rms)), width=LW, + tooltip="Microphone volume threshold below which a clip is considered silent and dropped.") + self.q_halluc = Gtk.Switch(); self.q_halluc.set_active(self.cfg.reject_hallucinations) + _switch_row(q_card, "Reject hallucinations", self.q_halluc, width=LW, + description="Drop STT ghost outputs like ‘Thank you.’ or ‘Bye.’ that Whisper invents from silence.") + self.q_strip = Gtk.Switch(); self.q_strip.set_active(self.cfg.strip_trailing_punctuation) + _switch_row(q_card, "Strip trailing . / ,", self.q_strip, width=LW, + description="Remove ending punctuation from pasted text — handy for code insertion.") - self.ww_test_btn = Gtk.Button(label="Test Wakeword"); self.ww_test_btn.set_halign(Gtk.Align.START) + # ── Manual dictation sounds card ────────────────────────────────────── + snd_card = _card_section(page, "Audio cues (keyboard / hotkey dictation)", icon="audio-speakers-symbolic") + self.snd_enabled = Gtk.Switch(); self.snd_enabled.set_active(self.cfg.sounds_enabled) + _switch_row(snd_card, "Play audio cues", self.snd_enabled, width=LW, + description="On/off for the manual start/stop chimes below. Does not affect wakeword sounds.") + self.snd_before = self._sound_field(snd_card, "Start sound", self.cfg.sound_before, + "Plays when manual recording starts.", width=LW) + self.snd_after = self._sound_field(snd_card, "Stop sound", self.cfg.sound_after, + "Plays when manual recording stops (paste, paste+Enter, or auto-stop).", width=LW) + + # Start meter if not already running (covers the case where Keyboard is + # visited before General, which is where _start_meter() normally fires). + if self._meter is None: + self._start_meter() + + # ===== Wakeword ========================================================= + def _build_wakeword(self, page: Gtk.Box) -> None: + _infobox(page, + "Set up hands-free wakeword dictation. Each preset connects to one " + "wyoming-openwakeword server instance. Add one preset per server. " + "Press ⟳ to auto-detect the available wakeword models from that server.\n\n" + "To add new wakeword models: place your .onnx model files in the server’s " + "model directory and restart it, e.g.:\n" + " docker run … -v ~/wakewords:/data/models homeassistant/wyoming-openwakeword\n" + "Common built-in models: okay_computer · hey_jarvis · alexa · hey_mycroft · computer") + LW = 170 + + # ── Wakeword enable card ────────────────────────────────────────────── + ww_card = _card_section(page, "Hands-free wakeword", margin_top=4, icon="audio-input-microphone-symbolic") + self.ww_enabled = Gtk.Switch(); self.ww_enabled.set_active(self.cfg.wakeword_enabled) + _switch_row(ww_card, "Enable wakeword", self.ww_enabled, width=LW, + description="Start dictation with a spoken keyword via an external openWakeWord server.") + + self.ww_mic_level = Gtk.LevelBar() + self.ww_mic_level.set_min_value(0); self.ww_mic_level.set_max_value(1) + _labeled(ww_card, "Input level", self.ww_mic_level, width=LW, + tooltip="Live microphone level — the bar should move when you speak.") + + self.ww_test_btn = Gtk.Button(label="Test wakeword"); self.ww_test_btn.set_halign(Gtk.Align.START) self.ww_test_btn.connect("clicked", self._ww_test) self.ww_test_lbl = Gtk.Label(label=""); self.ww_test_lbl.set_xalign(0.0) - box = Gtk.Box(spacing=10); box.pack_start(self.ww_test_btn, False, False, 0); box.pack_start(self.ww_test_lbl, False, False, 0) - _labeled(page, "", box, width=LW) + test_box = Gtk.Box(spacing=10) + test_box.pack_start(self.ww_test_btn, False, False, 0) + test_box.pack_start(self.ww_test_lbl, False, False, 0) + _labeled(ww_card, "", test_box, width=LW) - self.ww_silence = _labeled(page, "Silence to stop (s)", _entry(self.cfg.wakeword_silence_seconds), width=LW, - tooltip="After the wakeword starts recording, end it this many seconds " - "after you stop speaking. Hands-free auto-stop — the wakeword " - "can’t be released like a key. Default 2.0.") - - self.cancel_keywords = _labeled( - page, "Cancel words (comma)", - _entry(", ".join(self.cfg.cancel_keywords), placeholder="abbrechen, cancel"), - width=LW, - tooltip="Say one of these at the start or end of a clip to DISCARD it — " - "nothing is transcribed onward, routed, rewritten, or typed. " - "Rescues an accidentally triggered dictation. Empty = off.") - - self.send_keywords = _labeled( - page, "Send words (comma)", - _entry(", ".join(self.cfg.send_keywords), placeholder="computer send, computer abschicken"), - width=LW, - tooltip="Say one of these at the start or end of a clip to SEND it: the word is " - "stripped and the rest is typed AND submitted with Enter (spoken " - "’stop+paste+Enter’). Because it presses Enter, use a distinctive " - "multi-word phrase (e.g. your wakeword + ‘send’). Empty = off.") - - self.ww_snd_detected = self._sound_field( - page, "Sound: detected", self.cfg.wakeword_sound_detected, - "HANDS-FREE ONLY. Plays the instant the wake word is recognised and recording starts " - "— your ‘speak now’ cue. (Keyboard/hotkey dictation ignores this and uses ‘Play before’.)", - empty_note="Leave empty for no sound. These wakeword cues are independent of the " - "’Play audio cues’ switch below.", - clear_tip="Clear — no sound", width=LW) - self.ww_snd_done = self._sound_field( - page, "Sound: captured", self.cfg.wakeword_sound_done, - "HANDS-FREE ONLY. Plays when your spoken command is captured and recording stops " - "(on silence or stop). (Keyboard/hotkey dictation ignores this and uses ‘Play after’.)", - empty_note="Leave empty for no sound.", - clear_tip="Clear — no sound", width=LW) - - page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(Gtk.Label(label="Audio cues (manual dictation)", xalign=0.0), False, False, 2) - self.snd_enabled = Gtk.Switch(); self.snd_enabled.set_active(self.cfg.sounds_enabled); self.snd_enabled.set_halign(Gtk.Align.START) - _labeled(page, "Play audio cues", self.snd_enabled, width=LW, - tooltip="On/off for the MANUAL start/stop chimes below (keyboard/hotkey dictation). " - "The hands-free wakeword sounds above are separate and always play when set.") - self.snd_before = self._sound_field( - page, "Play before", self.cfg.sound_before, - "MANUAL (keyboard/hotkey) dictation only. Plays when recording starts. " - "(Hands-free sessions use ‘Sound: detected’ instead.)", width=LW) - self.snd_after = self._sound_field( - page, "Play after", self.cfg.sound_after, - "MANUAL (keyboard/hotkey) dictation only. Plays when recording stops " - "(paste, paste+Enter, or auto-stop on silence). (Hands-free uses ‘Sound: captured’ instead.)", + self.ww_silence = _labeled(ww_card, "Silence to stop (s)", _entry(str(self.cfg.wakeword_silence_seconds)), width=LW, + tooltip="After the wakeword fires, stop recording this many seconds after you stop speaking. Default 2.0.") + self.cancel_keywords, self.ww_key_cancel = self._kw_shortcut_row( + ww_card, "Cancel words", ", ".join(self.cfg.cancel_keywords), + "abbrechen, cancel", self.cfg.key_cancel, + tooltip_kw="Say one of these at the start or end of a clip to DISCARD it — nothing is typed. Empty = off.", + tooltip_key="Keyboard shortcut that cancels dictation (works even during wakeword recording).", + width=LW) + self.send_keywords, self.ww_key_send = self._kw_shortcut_row( + ww_card, "Send words", ", ".join(self.cfg.send_keywords), + "computer send, computer abschicken", self.cfg.key_send, + tooltip_kw="Say one of these to type AND press Enter (spoken ‘submit’). Use a distinctive multi-word phrase. Empty = off.", + tooltip_key="Keyboard shortcut that stops recording and pastes with Enter.", width=LW) - def _sound_field(self, page: Gtk.Box, label: str, value: str, tooltip: str = "", + # ── Engine selector bar ─────────────────────────────────────────────── + ww_bar = Gtk.Box(spacing=6); ww_bar.set_margin_top(6) + self.ww_combo = _block_scroll(Gtk.ComboBoxText()); self.ww_combo.set_hexpand(True) + for e in self.cfg.wakeword_engines: + self.ww_combo.append_text(e.name) + active_idx = next((i for i, e in enumerate(self.cfg.wakeword_engines) + if e.name == self.cfg.wakeword_active), 0) + self.ww_combo.set_active(active_idx) + ww_bar.pack_start(self.ww_combo, True, True, 0) + for label, cb in (("+ Add", self._ww_add),): + b = Gtk.Button(label=label); b.connect("clicked", cb); ww_bar.pack_start(b, False, False, 0) + qs = Gtk.Button(label="Quickstart ▾") + qs.set_tooltip_text("Fill the form from a common wakeword server template") + qs.connect("clicked", self._show_ww_templates); ww_bar.pack_start(qs, False, False, 0) + for label, cb, tip in ( + ("Delete", self._ww_delete, "Remove this wakeword engine preset"), + ("⟳", lambda _b: self._ww_reload(), "Re-check connection and reload models from the server"), + ): + b = Gtk.Button(label=label); b.set_tooltip_text(tip); b.connect("clicked", cb) + ww_bar.pack_end(b, False, False, 0) + self.ww_status = Gtk.Label(xalign=0.0) + self.ww_status.get_style_context().add_class("dim-label") + ww_bar.pack_end(self.ww_status, False, False, 4) + page.pack_start(ww_bar, False, False, 2) + + # ── Engine config card ──────────────────────────────────────────────── + ww_cfg_card = _card_section(page, "", margin_top=6) + self.ww_name = _labeled(ww_cfg_card, "Name", _entry(placeholder="e.g. Home server"), + tooltip="A label for this wakeword server preset.", width=LW) + self.ww_dot = Gtk.Label(); self.ww_dot.set_markup(_dot(GREY)) + self.ww_dot.set_tooltip_text("openWakeWord server: green = reachable, red = unreachable") + self.ww_uri = _url_field_lb(ww_cfg_card, "Wakeword server", "tcp://127.0.0.1:10400", + self._ww_reload, dot=self.ww_dot, width=LW) + self.ww_uri.connect("focus-out-event", self._on_ww_uri_leave) + self.ww_model = _labeled(ww_cfg_card, "Model name", _model_combo("Search models…"), width=LW, + tooltip="Which wake model to listen for (e.g. okay_computer, hey_jarvis). Press ⟳ on the URI field to load models from the server.") + self.ww_cancel_model = _labeled(ww_cfg_card, "Cancel model", _model_combo("none — use text keywords"), width=LW, + tooltip="Optional: a wakeword model that immediately cancels the recording when heard (e.g. a ‘stop’ model). Faster than the Whisper-based text cancel.") + self.ww_send_model = _labeled(ww_cfg_card, "Send model", _model_combo("none — use text keywords"), width=LW, + tooltip="Optional: a wakeword model that finishes recording and presses Enter (e.g. a ‘send it’ model).") + self.ww_combo.connect("changed", self._ww_changed) + + self._ww_load_idx(active_idx) + + # ── Wakeword sound cues ─────────────────────────────────────────────── + ww_snd_card = _card_section(page, "Wakeword sound cues (hands-free only)", icon="audio-speakers-symbolic") + self.ww_snd_detected = self._sound_field(ww_snd_card, "Wakeword detected", self.cfg.wakeword_sound_detected, + "Plays the instant the wake word fires — your ‘speak now’ cue. Leave empty for no sound.", width=LW) + self.ww_snd_done = self._sound_field(ww_snd_card, "Speech captured", self.cfg.wakeword_sound_done, + "Plays when your spoken command is captured and recording stops.", width=LW) + + # Start meter if not already running (covers the case where Wakeword is + # visited before General, which is where _start_meter() normally fires). + if self._meter is None: + self._start_meter() + + def _sound_field(self, page, label: str, value: str, tooltip: str = "", empty_note: str = "Leave empty to use the built-in system sound.", clear_tip: str = "Clear — use the built-in system sound", - width: int = 150) -> Gtk.FileChooserButton: - row = Gtk.Box(spacing=10); row.set_margin_top(3); row.set_margin_bottom(3) + width: int = 150): + """Sound file picker row. Returns an object with get_filename().""" + row = Gtk.Box(spacing=6); row.set_margin_top(3); row.set_margin_bottom(3) lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) if tooltip: - lbl.set_tooltip_text(tooltip) + lbl.set_tooltip_text(f"{tooltip} {empty_note}") row.pack_start(lbl, False, False, 0) - chooser = Gtk.FileChooserButton(title=label, action=Gtk.FileChooserAction.OPEN) - af = Gtk.FileFilter(); af.set_name("Audio") - for pat in ("*.wav", "*.oga", "*.ogg", "*.flac"): - af.add_pattern(pat) - chooser.add_filter(af) + + path_entry = Gtk.Entry() + path_entry.set_hexpand(True) + path_entry.set_editable(False) + path_entry.set_placeholder_text(empty_note) if value: - chooser.set_filename(value) - chooser.set_hexpand(True) - if tooltip: - chooser.set_tooltip_text(f"{tooltip} {empty_note}") - row.pack_start(chooser, True, True, 0) - play = Gtk.Button.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON) - play.set_tooltip_text("Play this sound now") - play.connect("clicked", lambda _b, c=chooser: self._play_sound_file(c.get_filename())) - row.pack_start(play, False, False, 0) + path_entry.set_text(value) + row.pack_start(path_entry, True, True, 0) + + browse = Gtk.Button.new_from_icon_name("document-open-symbolic", Gtk.IconSize.BUTTON) + browse.set_tooltip_text("Browse for audio file (WAV, MP3, OGG, FLAC, …)") + browse.connect("clicked", lambda _b, e=path_entry, t=label: self._browse_sound(e, t)) + row.pack_start(browse, False, False, 0) + + play_btn = Gtk.Button.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON) + play_btn.set_tooltip_text("Play this sound now") + play_btn.connect("clicked", lambda _b, e=path_entry: self._play_sound_file(e.get_text())) + row.pack_start(play_btn, False, False, 0) + clr = Gtk.Button.new_from_icon_name("edit-clear-symbolic", Gtk.IconSize.BUTTON) clr.set_tooltip_text(clear_tip) - clr.connect("clicked", lambda _b, c=chooser: c.unselect_all()) + clr.connect("clicked", lambda _b, e=path_entry: e.set_text("")) row.pack_start(clr, False, False, 0) - page.pack_start(row, False, False, 0) - return chooser + + if isinstance(page, Gtk.ListBox): + row.set_margin_start(12); row.set_margin_end(8) + row.set_margin_top(6); row.set_margin_bottom(6) + _lb_add(page, row) + else: + page.pack_start(row, False, False, 0) + + # Return a thin wrapper so callers use .get_filename() as before. + class _Picker: + def get_filename(self_): + t = path_entry.get_text().strip() + return t if t else None + return _Picker() + + def _browse_sound(self, entry: Gtk.Entry, title: str) -> None: + dlg = Gtk.FileChooserDialog( + title=f"Choose sound — {title}", + parent=self.dlg, + action=Gtk.FileChooserAction.OPEN, + ) + dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, + "_Select", Gtk.ResponseType.OK) + af = Gtk.FileFilter(); af.set_name("Audio files (WAV, MP3, OGG, FLAC, …)") + for pat in ("*.wav", "*.mp3", "*.ogg", "*.oga", "*.flac", "*.m4a", "*.aac", + "*.aif", "*.aiff", "*.opus"): + af.add_pattern(pat) + dlg.add_filter(af) + af_all = Gtk.FileFilter(); af_all.set_name("All files"); af_all.add_pattern("*") + dlg.add_filter(af_all) + cur = entry.get_text().strip() + if cur: + dlg.set_filename(cur) + + # Auto-preview each file as the selection changes. + def _on_selection(d): + fn = d.get_filename() + if fn and os.path.isfile(fn): + self._play_sound_file(fn) + dlg.connect("selection-changed", _on_selection) + + if dlg.run() == Gtk.ResponseType.OK: + fn = dlg.get_filename() + if fn: + entry.set_text(fn) + self._stop_preview() + dlg.destroy() def _play_sound_file(self, path) -> None: from . import sound - if path: - sound.play(path) + self._stop_preview() + if path and os.path.isfile(os.path.expanduser(path)): + self._preview_proc = sound.play(path) + + def _stop_preview(self) -> None: + proc = getattr(self, "_preview_proc", None) + if proc is not None: + try: + proc.terminate() + except Exception: + pass + self._preview_proc = None # ===== General ========================================================== def _build_general(self, page: Gtk.Box) -> None: - _infobox(page, "General options: choose your microphone and watch its level bar move " - "when you speak, set how the text is delivered, the spoken language, " - "desktop notifications, and whether Blitztext starts automatically " - "when you log in.") + _infobox(page, "Choose your microphone, how text is delivered, the spoken language, " + "and whether Blitztext starts automatically when you log in.") + + # ── Microphone card ─────────────────────────────────────────────────── + mic_card = _card_section(page, "Microphone", margin_top=4, icon="audio-input-microphone-symbolic") self._mics = audio.list_mics() names = [label for _, label in self._mics] - cur = next((lbl for nm, lbl in self._mics if nm == self.cfg.mic), names[0]) - self.gen_mic = _labeled(page, "Microphone", _combo(names, cur), + cur = next((lbl for nm, lbl in self._mics if nm == self.cfg.mic), names[0] if names else "") + self.gen_mic = _labeled(mic_card, "Device", _combo(names, cur), tooltip="Which microphone Blitztext records from.") - - self.mic_level = Gtk.LevelBar(); self.mic_level.set_min_value(0); self.mic_level.set_max_value(1) - _labeled(page, "Input level", self.mic_level, - tooltip="Live microphone level. The bar should move when you speak.") + self.mic_level = Gtk.LevelBar() + self.mic_level.set_min_value(0); self.mic_level.set_max_value(1) + _labeled(mic_card, "Input level", self.mic_level, + tooltip="Live microphone level — should move when you speak.") self.gen_mic.connect("changed", lambda _c: self._restart_meter()) - self.gen_output = _labeled(page, "Output", _combo(["type", "paste"], self.cfg.output), - tooltip="‘type’ types the text key by key. ‘paste’ copies it and presses Ctrl+V (faster for long text).") - self.gen_lang = _labeled(page, "Language hint", _entry(self.cfg.language, placeholder="de, en, … (blank = autodetect)"), + # ── Output card ─────────────────────────────────────────────────────── + out_card = _card_section(page, "Text output & language", icon="insert-text-symbolic") + self.gen_output = _labeled(out_card, "Output mode", _combo(["type", "paste"], self.cfg.output), + tooltip="’type’ types the text key by key via xdotool. " + "’paste’ copies it to the clipboard and presses Ctrl+V (faster for long text).") + self.gen_lang = _labeled(out_card, "Language hint", _entry(self.cfg.language, placeholder="de, en, … (blank = autodetect)"), tooltip="Spoken language code (de, en, …). Leave blank to auto-detect.") + + # ── Notifications card ──────────────────────────────────────────────── + notif_card = _card_section(page, "Notifications & overlay", icon="dialog-information-symbolic") self.gen_notify = Gtk.Switch(); self.gen_notify.set_active(self.cfg.notify) - _switch_row(page, "Notifications", self.gen_notify, + _switch_row(notif_card, "Notifications", self.gen_notify, "Desktop pop-ups for recording, transcription, and errors (manual dictation).") self.gen_notify_routing = Gtk.Switch(); self.gen_notify_routing.set_active(self.cfg.notify_routing) - _switch_row(page, "Announce matched preset", self.gen_notify_routing, + _switch_row(notif_card, "Announce preset", self.gen_notify_routing, "After a voice command, show which preset and keyword matched — even hands-free.") self.gen_overlay = Gtk.Switch(); self.gen_overlay.set_active(self.cfg.overlay_enabled) - _switch_row(page, "Visual overlay", self.gen_overlay, - "Show a microphone, a live waveform, and the recognised text in a bubble " - "at the cursor while you dictate (also gives hands-free sessions feedback).") + _switch_row(notif_card, "Visual overlay", self.gen_overlay, + "Show a microphone, live waveform, and recognised text in a bubble at the cursor while you dictate.") + + # ── Startup card ────────────────────────────────────────────────────── + start_card = _card_section(page, "Startup", icon="system-reboot-symbolic") self.gen_boot = Gtk.Switch(); self.gen_boot.set_active(autostart.is_enabled()) - _switch_row(page, "Launch on login", self.gen_boot, + _switch_row(start_card, "Launch on login", self.gen_boot, "Start Blitztext automatically when you log in.") self._start_meter() def _selected_mic_name(self) -> str: + if not hasattr(self, "gen_mic"): + return "" # General tab not yet built; use default device i = self.gen_mic.get_active() return self._mics[i][0] if 0 <= i < len(self._mics) else "" def _start_meter(self) -> None: self._stop_meter() def on_level(v): - GLib.idle_add(self.mic_level.set_value, v) + if hasattr(self, "mic_level"): + GLib.idle_add(self.mic_level.set_value, v) if hasattr(self, "ww_mic_level"): GLib.idle_add(self.ww_mic_level.set_value, v) self._meter = audio.LevelMeter(self._selected_mic_name(), on_level=on_level) @@ -1035,24 +2111,106 @@ class SettingsDialog: if self._meter is not None: self._meter.stop(); self._meter = None - def _ww_load(self) -> None: - self._probe_dot(self.ww_dot, self.ww_uri.get_text(), 10400) + # ── Wakeword engine CRUD ───────────────────────────────────────────────── + def _ww_load_idx(self, idx: int) -> None: + """Load engine at idx into the form fields and probe the server.""" + if not (0 <= idx < len(self.cfg.wakeword_engines)): + return + e = self.cfg.wakeword_engines[idx] + self._ww_idx = idx + self.ww_name.set_text(e.name) + self.ww_uri.set_text(e.uri) + _fill_combo(self.ww_model, [], e.model) + if hasattr(self, "ww_cancel_model"): + _fill_combo(self.ww_cancel_model, [], self.cfg.wakeword_cancel_model) + if hasattr(self, "ww_send_model"): + _fill_combo(self.ww_send_model, [], self.cfg.wakeword_send_model) + self._probe_dot(self.ww_dot, e.uri, 10400) + self._ww_fetch_models() + + def _ww_commit(self) -> None: + """Save current form fields back to the active WakewordEngine.""" + idx = getattr(self, "_ww_idx", 0) + if not (0 <= idx < len(self.cfg.wakeword_engines)): + return + e = self.cfg.wakeword_engines[idx] + new_name = self.ww_name.get_text().strip() or e.name + e.name = new_name + e.uri = self.ww_uri.get_text().strip() + e.model = _combo_text(self.ww_model) + if self.ww_combo.get_active_text() != new_name: + self.ww_combo.remove(idx) + self.ww_combo.insert_text(idx, new_name) + self.ww_combo.set_active(idx) + + def _ww_changed(self, combo) -> None: + new = combo.get_active() + if new < 0 or new == getattr(self, "_ww_idx", -1): + return + self._ww_commit() + self._ww_load_idx(new) + + def _ww_add(self, _b=None) -> None: + self._ww_commit() + from .config import WakewordEngine + e = WakewordEngine(name="New wakeword server", uri="tcp://127.0.0.1:10400", model="okay_computer") + self.cfg.wakeword_engines.append(e) + self.ww_combo.append_text(e.name) + new_idx = len(self.cfg.wakeword_engines) - 1 + self.ww_combo.set_active(new_idx) + self._ww_load_idx(new_idx) + + def _ww_delete(self, _b=None) -> None: + if len(self.cfg.wakeword_engines) <= 1: + return + idx = getattr(self, "_ww_idx", 0) + del self.cfg.wakeword_engines[idx] + self.ww_combo.remove(idx) + self._ww_idx = -1 + self.ww_combo.set_active(0) + self._ww_load_idx(0) + + def _show_ww_templates(self, btn: Gtk.Button) -> None: + menu = Gtk.Menu() + for tpl in _WW_TEMPLATES: + item = Gtk.MenuItem(label=tpl[0]) + item.connect("activate", lambda _i, t=tpl: self._ww_apply_template(t)) + menu.append(item) + menu.show_all() + menu.popup_at_widget(btn, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, None) + + def _ww_apply_template(self, tpl: tuple) -> None: + name, uri, model = tpl + self.ww_name.set_text(name) + self.ww_uri.set_text(uri) + _fill_combo(self.ww_model, [model], model) + self._probe_dot(self.ww_dot, uri, 10400) + + def _ww_reload(self) -> None: + """Probe the dot and fetch models from the server (⟳ button).""" + if hasattr(self, "ww_status"): + self.ww_status.set_text("Connecting…") + self._probe_dot(self.ww_dot, self.ww_uri.get_text(), 10400) + self._ww_fetch_models() + + def _ww_fetch_models(self) -> None: + """Background fetch of model list from wyoming server.""" + uri = self.ww_uri.get_text().strip() # capture on GTK thread def work(): import socket, json from urllib.parse import urlparse - uri = self.ww_uri.get_text().strip() parsed = urlparse(uri) host = parsed.hostname or "127.0.0.1" port = parsed.port or 10400 try: - with socket.create_connection((host, port), timeout=2.0) as s: + with socket.create_connection((host, port), timeout=3.0) as s: s.sendall(json.dumps({"type": "describe"}).encode() + b"\n") line = b"" while not line.endswith(b"\n"): - b = s.recv(1) - if not b: break - line += b + chunk = s.recv(1) + if not chunk: break + line += chunk info = json.loads(line.decode("utf-8")) payload_len = info.get("data_length", info.get("payload_length", 0)) payload = b"" @@ -1061,16 +2219,36 @@ class SettingsDialog: if not chunk: break payload += chunk data = json.loads(payload.decode("utf-8")) - models = [] - for w in data.get("wake", []): - for m in w.get("models", []): - models.append(m.get("name")) - def apply(): - _fill_combo(self.ww_model, models, models[0] if models else "") - self._info("Loaded models successfully.") + models = [m.get("name") for w in data.get("wake", []) + for m in w.get("models", []) if m.get("name")] + def apply(models=models): + if models: + cur = _combo_text(self.ww_model) + _fill_combo(self.ww_model, models, cur or models[0]) + # Also populate cancel/send model pickers with the same list. + # Preserve whatever the user already typed; empty stays empty. + if hasattr(self, "ww_cancel_model"): + cur_c = _combo_text(self.ww_cancel_model) + _fill_combo(self.ww_cancel_model, models, cur_c) + if hasattr(self, "ww_send_model"): + cur_s = _combo_text(self.ww_send_model) + _fill_combo(self.ww_send_model, models, cur_s) + if hasattr(self, "ww_status"): + self.ww_status.set_markup( + f'' + f'{len(models)} model{"s" if len(models) != 1 else ""} loaded: ' + f'{", ".join(models)}') + else: + if hasattr(self, "ww_status"): + self.ww_status.set_markup( + 'Connected — no models found') GLib.idle_add(apply) - except Exception as e: - GLib.idle_add(lambda e=e: self._error(f"Failed to load models:\n{e}")) + except Exception as exc: + def show_err(exc=exc): + if hasattr(self, "ww_status"): + self.ww_status.set_markup( + f'Unreachable: {exc}') + GLib.idle_add(show_err) threading.Thread(target=work, daemon=True).start() def _ww_test(self, _b) -> None: @@ -1110,53 +2288,223 @@ class SettingsDialog: threading.Thread(target=work, daemon=True).start() # ===== Benchmark ======================================================== - def _build_benchmark(self, page: Gtk.Box) -> None: + # ===== Benchmark — STT ================================================== + def _build_benchmark_stt(self, page: Gtk.Box) -> None: _infobox(page, "Compare your speech-to-text engines. Pick a recording (.wav) and a " "text file (.txt) containing exactly what is said, then press Run " "benchmark. The table shows how fast each engine is and how accurate, " "and names the fastest and most accurate. Add an engine preset for " "each model you want in the comparison.") - wavf = Gtk.FileChooserButton(title="WAV file", action=Gtk.FileChooserAction.OPEN) - fa = Gtk.FileFilter(); fa.set_name("Audio (.wav)"); fa.add_pattern("*.wav"); wavf.add_filter(fa) + wavf = Gtk.FileChooserButton(title="Audio file", action=Gtk.FileChooserAction.OPEN) + fa = Gtk.FileFilter(); fa.set_name("Audio (WAV, MP3, OGG, FLAC, …)") + for _p in ("*.wav", "*.mp3", "*.ogg", "*.oga", "*.flac", "*.m4a", "*.opus"): + fa.add_pattern(_p) + wavf.add_filter(fa) self.bench_wav = _labeled(page, "Audio (.wav)", wavf) reff = Gtk.FileChooserButton(title="Reference transcript", action=Gtk.FileChooserAction.OPEN) ft = Gtk.FileFilter(); ft.set_name("Text (.txt)"); ft.add_pattern("*.txt"); reff.add_filter(ft) self.bench_ref = _labeled(page, "Reference (.txt)", reff) + # Restore saved paths + if self.cfg.bench_wav and Path(self.cfg.bench_wav).exists(): + wavf.set_filename(self.cfg.bench_wav) + if self.cfg.bench_ref and Path(self.cfg.bench_ref).exists(): + reff.set_filename(self.cfg.bench_ref) + def _on_wav_set(_b): fn = wavf.get_filename() if not fn: return + self.cfg.bench_wav = fn p = Path(fn) for ext in (".reference.txt", ".txt"): cand = p.with_name(p.stem + ext) if cand.exists(): reff.set_filename(str(cand)) + self.cfg.bench_ref = str(cand) break + save(self.cfg) wavf.connect("file-set", _on_wav_set) + def _on_ref_set(_b): + fn = reff.get_filename() + if fn: + self.cfg.bench_ref = fn + save(self.cfg) + reff.connect("file-set", _on_ref_set) + + # ── Engine / model selector ─────────────────────────────────────────── + _section_title(page, "Engines to benchmark", margin_top=10, icon="utilities-system-monitor-symbolic") + + sel_hdr = Gtk.Box(spacing=6); sel_hdr.set_margin_bottom(4) + self._bench_filter = Gtk.SearchEntry() + self._bench_filter.set_placeholder_text("Filter by name, model or URL…") + sel_hdr.pack_start(self._bench_filter, True, True, 0) + all_b = Gtk.Button(label="All") + all_b.connect("clicked", lambda _: [cb.set_active(True) + for cb in self._bench_checks.values()]) + none_b = Gtk.Button(label="None") + none_b.connect("clicked", lambda _: [cb.set_active(False) + for cb in self._bench_checks.values()]) + sel_hdr.pack_start(all_b, False, False, 0) + sel_hdr.pack_start(none_b, False, False, 0) + page.pack_start(sel_hdr, False, False, 0) + + sel_sw = Gtk.ScrolledWindow() + sel_sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + sel_sw.set_min_content_height(80) + sel_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) + sel_list.set_margin_start(4); sel_list.set_margin_end(4) + sel_list.set_margin_top(2); sel_list.set_margin_bottom(2) + sel_sw.add(sel_list) + + self._bench_checks: dict[str, Gtk.CheckButton] = {} + self._bench_dots: dict[str, Gtk.Label] = {} + self._bench_lang_labels: dict[str, Gtk.Label] = {} + self._bench_sel_rows: list[tuple[Gtk.Box, str, list[str]]] = [] # (row, name, mutable_search) + + for e in self.cfg.stt_engines: + row = Gtk.Box(spacing=6) + dot = Gtk.Label(); dot.set_markup(_dot(GREY)) + self._bench_dots[e.name] = dot + row.pack_start(dot, False, False, 0) + label = e.name + if e.model: + label += f" [{e.model}]" + cb = Gtk.CheckButton(label=label) + cb.set_active(True) + self._bench_checks[e.name] = cb + row.pack_start(cb, True, True, 0) + lang_lbl = Gtk.Label(xalign=1.0) + lang_lbl.set_markup(f"") + lang_lbl.set_margin_end(4) + self._bench_lang_labels[e.name] = lang_lbl + row.pack_end(lang_lbl, False, False, 0) + sel_list.pack_start(row, False, False, 2) + search_parts = [e.name, e.model, e.url] + self._bench_sel_rows.append((row, e.name, search_parts)) + + def _filter_bench(_e=None): + q = self._bench_filter.get_text().lower() + for row, _name, parts in self._bench_sel_rows: + row.set_visible(not q or any(q in p.lower() for p in parts)) + self._bench_filter.connect("changed", _filter_bench) + + # Background: reachability + language metadata + def _check_bench_status(): + meta_cache: dict[str, list] = {} + for e in self.cfg.stt_engines: + ok = stt.status(e, timeout=2.0) + color = GREEN if ok else RED + dot = self._bench_dots.get(e.name) + if dot: + GLib.idle_add(dot.set_markup, _dot(color)) + # Fetch language metadata for remote engines + if not e.is_local and e.url: + if e.url not in meta_cache: + meta_cache[e.url] = stt.list_models_meta(e.url, e.api_key_env, timeout=4.0) + langs: list[str] = [] + for m in meta_cache[e.url]: + if not e.model or m.id == e.model or m.id.endswith("/" + e.model): + langs = m.languages + break + if not langs and meta_cache[e.url]: + langs = meta_cache[e.url][0].languages + if langs: + # Update search parts so filter works on language codes + for row, name, parts in self._bench_sel_rows: + if name == e.name: + parts.extend(langs) + lang_str = stt.fmt_languages(langs) + lbl = self._bench_lang_labels.get(e.name) + if lbl: + GLib.idle_add(lbl.set_markup, + f"{GLib.markup_escape_text(lang_str)}") + threading.Thread(target=_check_bench_status, daemon=True).start() + + # ── Run controls ────────────────────────────────────────────────────── + run_row = Gtk.Box(spacing=16) + run_row.set_margin_top(6); run_row.set_margin_bottom(4) run = Gtk.Button(label="Run benchmark"); run.connect("clicked", self._run_bench) run.set_halign(Gtk.Align.START) - page.pack_start(run, False, False, 6) + run_row.pack_start(run, False, False, 0) + self.bench_expand = Gtk.CheckButton(label="Test all models per engine") + self.bench_expand.set_tooltip_text( + "Fetch every available model from each remote engine and run a separate " + "benchmark row per model — useful to compare models on the same server.") + self.bench_expand.set_active(self.cfg.bench_expand_models) + self.bench_expand.connect("toggled", + lambda cb: setattr(self.cfg, "bench_expand_models", cb.get_active())) + run_row.pack_start(self.bench_expand, False, False, 0) + page.pack_start(run_row, False, False, 0) - self.bench_store = Gtk.ListStore(str, str, str, str, str, str) - tree = Gtk.TreeView(model=self.bench_store) - for title, i, expand in [("Engine", 0, False), ("Model", 1, False), ("Device", 2, False), - ("Time (s)", 3, False), ("Accuracy", 4, False), ("Output", 5, True)]: + # ── Resizable pane: engine list (top) ↕ results table (bottom) ─────── + # engine, url, model, device, best_for, lang, time, accuracy, ram, output, tooltip + self.bench_store = Gtk.ListStore(str, str, str, str, str, str, str, str, str, str, str) + bench_sort = Gtk.TreeModelSort(model=self.bench_store) + tree = Gtk.TreeView(model=bench_sort) + tree.set_has_tooltip(True) + tree.set_tooltip_column(10) + + # Numeric sort: strip %, "server", "—" then compare as float (non-numeric → inf) + def _num_cmp(model, a, b, col): + def _f(v): + try: + return float(v.rstrip("%")) + except (ValueError, AttributeError): + return float("inf") + va, vb = _f(model.get_value(a, col)), _f(model.get_value(b, col)) + return (va > vb) - (va < vb) + + for title, i, expand, max_w in [ + ("Engine", 0, False, 0), + ("URL", 1, False, 180), + ("Model", 2, False, 0), + ("Device", 3, False, 0), + ("Best for", 4, False, 0), + ("Lang", 5, False, 160), + ("Time (s)", 6, False, 0), + ("Accuracy", 7, False, 0), + ("RAM (MB)", 8, False, 0), + ("Output", 9, True, 0)]: r = Gtk.CellRendererText() - if i == 5: - r.set_property("ellipsize", Pango.EllipsizeMode.END) + r.set_property("ellipsize", Pango.EllipsizeMode.END) col = Gtk.TreeViewColumn(title, r, text=i); col.set_resizable(True) + col.set_sort_column_id(i) col.set_expand(expand) + if max_w: + col.set_max_width(max_w) + if i == 8: + col.set_widget(Gtk.Label(label="RAM (MB)", tooltip_text= + "RAM used by the engine during the benchmark.\n" + "Local engines: RSS delta measured on this machine.\n" + "Remote engines: current RSS read from the server's Prometheus /metrics endpoint.\n" + "'server' = server does not expose /metrics — RAM not measurable.\n" + "'—' = local model was already loaded, no delta to measure.")) + col.get_widget().show() tree.append_column(col) - sw = Gtk.ScrolledWindow(); sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - sw.add(tree); page.pack_start(sw, True, True, 4) + if i in (6, 7, 8): # Time, Accuracy, RAM — sort numerically + bench_sort.set_sort_func(i, _num_cmp, i) + tree_sw = Gtk.ScrolledWindow() + tree_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + tree_sw.add(tree) + # Wrap tree + summary in a box so summary stays below the table inside the pane + results_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + results_box.pack_start(tree_sw, True, True, 0) self.bench_summary = Gtk.Label(xalign=0.0); self.bench_summary.set_line_wrap(True) - page.pack_start(self.bench_summary, False, False, 4) + self.bench_summary.set_margin_top(4); self.bench_summary.set_margin_bottom(2) + results_box.pack_start(self.bench_summary, False, False, 0) - # --- Wakeword benchmark --------------------------------------------- - page.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 10) + paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) + paned.pack1(sel_sw, resize=True, shrink=False) + paned.pack2(results_box, resize=True, shrink=False) + paned.set_position(180) # default: engine list gets ~180px, rest goes to results + paned.set_size_request(-1, 320) # never collapse below 320px total + page.pack_start(paned, True, True, 4) + + # ===== Benchmark — Wakeword ============================================= + def _build_benchmark_ww(self, page: Gtk.Box) -> None: _infobox(page, "Stress-test the wakeword. It synthesizes short sentences with your " "wakeword spoken in random voices (plus filler with none), streams them to " "your wyoming-openwakeword server, and reports how reliably it fires " @@ -1164,26 +2512,27 @@ class SettingsDialog: "TTS server (Kokoro, XTTS, OpenAI …) — set its URL, model and voices, then " "Connect to test it.") - # URL with a ⟳ reload that loads models + voices from the server (like the - # Engines tab). Set the saved URL after building the field. + # ---- Controls pane (top half of the Paned) ---- + ctrl = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + # URL with a ⟳ reload that loads models + voices from the server self.tts_dot = Gtk.Label(); self.tts_dot.set_markup(_dot(GREY)) self.tts_dot.set_tooltip_text("TTS server: green = reachable, red = unreachable") - self.wwb_url = _url_field(page, "TTS URL", "http://localhost:8880/v1", + self.wwb_url = _url_field(ctrl, "TTS URL", "http://localhost:8880/v1", self._tts_reload, dot=self.tts_dot) self.wwb_url.set_text(self.cfg.tts_url) self.wwb_url.connect("focus-out-event", self._on_tts_url_leave) self._probe_dot(self.tts_dot, self.cfg.tts_url, 80) self.wwb_key = _labeled( - page, "API key env", _entry(self.cfg.tts_api_key_env, placeholder="(optional, e.g. OPENAI_API_KEY)"), + ctrl, "API key env", _entry(self.cfg.tts_api_key_env, placeholder="(optional, e.g. OPENAI_API_KEY)"), tooltip="Name of an environment variable holding a bearer token. Leave empty for " "no-auth local servers. The key itself is never stored in the config.") self.wwb_model = _model_combo("press ⟳ to load, or type a model id") self.wwb_model.set_text(self.cfg.tts_model) - _labeled(page, "TTS model", self.wwb_model, + _labeled(ctrl, "TTS model", self.wwb_model, tooltip="The TTS model id your endpoint serves (from {url}/models). Required.") self.wwb_voices = MultiPicker("press ⟳ to load voices, or type names") self.wwb_voices.set_text(", ".join(self.cfg.tts_voices)) - _labeled(page, "Voices (comma)", self.wwb_voices, + _labeled(ctrl, "Voices (comma)", self.wwb_voices, tooltip="Voices to cycle through at random so the test covers different timbres. " "⟳ fills these from the server; the ▾ dropdown appends one at a time.") @@ -1194,51 +2543,223 @@ class SettingsDialog: connect_box = Gtk.Box(spacing=10) connect_box.pack_start(self.wwb_test_btn, False, False, 0) connect_box.pack_start(self.wwb_test_lbl, False, False, 0) - _labeled(page, "", connect_box) + _labeled(ctrl, "", connect_box) + # ---- Wakeword engine checkboxes ---- + _section_title(ctrl, "Engines to test", margin_top=10, + icon="audio-input-microphone-symbolic") + self._wwb_checks: dict[str, Gtk.CheckButton] = {} + eng_row = Gtk.Box(spacing=16) + eng_row.set_margin_bottom(4) + for e in self.cfg.wakeword_engines: + cb = Gtk.CheckButton(label=e.name) + cb.set_active(True) + self._wwb_checks[e.name] = cb + eng_row.pack_start(cb, False, False, 0) + if not self.cfg.wakeword_engines: + eng_row.pack_start(Gtk.Label(label="(no wakeword engines configured — add one in the Wakeword page)"), + False, False, 0) + ctrl.pack_start(eng_row, False, False, 0) + + # ---- Wakeword model selector ---- + all_ww_models = list(dict.fromkeys( + e.model for e in self.cfg.wakeword_engines if e.model)) + self.wwb_wakeword = _model_combo("auto — each engine's own model") + self.wwb_wakeword.set_models(all_ww_models) + _labeled(ctrl, "Wakeword", self.wwb_wakeword, + tooltip="Which wakeword model to test. Leave empty to use each engine's own " + "configured model. Select a specific model to override all engines " + "and test that phrase (e.g. okay_computer on all servers).") + + # ---- Samples + run ---- adj = Gtk.Adjustment(value=12, lower=1, upper=200, step_increment=1, page_increment=10) self.wwb_count = Gtk.SpinButton(adjustment=adj, climb_rate=1, digits=0) self.wwb_count.set_halign(Gtk.Align.START); self.wwb_count.set_size_request(90, -1) - _labeled(page, "Wakeword samples", self.wwb_count, + _labeled(ctrl, "Wakeword samples", self.wwb_count, tooltip="How many wakeword utterances to synthesize and test (filler-only " "utterances for false-fire checking are added on top).") wrun = Gtk.Button(label="Run wakeword benchmark"); wrun.connect("clicked", self._run_wakeword_bench) wrun.set_halign(Gtk.Align.START) - page.pack_start(wrun, False, False, 6) + ctrl.pack_start(wrun, False, False, 6) + + # ---- Results pane (bottom half of the Paned) ---- + # col 8 = foreground colour (not displayed) + self.wwb_store = Gtk.ListStore(str, str, str, str, str, str, str, str, str) + wwb_sort = Gtk.TreeModelSort(model=self.wwb_store) + + def _num_cmp_wwb(model, a, b, col): + def _f(v): + try: + return float(str(v).rstrip("%")) + except (ValueError, AttributeError): + return float("inf") + va, vb = _f(model.get_value(a, col)), _f(model.get_value(b, col)) + return (va > vb) - (va < vb) + + self.wwb_tree = Gtk.TreeView(model=wwb_sort) + self.wwb_tree.set_grid_lines(Gtk.TreeViewGridLines.HORIZONTAL) + for title, i, expand in [ + ("Engine", 0, False), + ("Wakeword", 1, False), + ("Voice", 2, True), + ("Detected", 3, False), + ("Total", 4, False), + ("Recall %", 5, False), + ("False fires", 6, False), + ("Time (s)", 7, False)]: + r = Gtk.CellRendererText() + r.set_property("ellipsize", Pango.EllipsizeMode.END) + col = Gtk.TreeViewColumn(title, r, text=i, foreground=8) + col.set_resizable(True); col.set_expand(expand) + col.set_sort_column_id(i) + self.wwb_tree.append_column(col) + if i in (3, 4, 5, 6, 7): + wwb_sort.set_sort_func(i, _num_cmp_wwb, i) + + ww_sw = Gtk.ScrolledWindow() + ww_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + ww_sw.add(self.wwb_tree) + self.wwb_summary = Gtk.Label(xalign=0.0); self.wwb_summary.set_line_wrap(True) self.wwb_summary.set_selectable(True) - page.pack_start(self.wwb_summary, False, False, 4) + self.wwb_summary.set_margin_top(4); self.wwb_summary.set_margin_bottom(2) + + # Toolbar: Copy CSV + Save CSV + tb = Gtk.Box(spacing=6) + tb.set_margin_bottom(4) + _copy_btn = Gtk.Button(label="Copy as CSV") + _copy_btn.set_tooltip_text("Copy results to clipboard as comma-separated values") + _copy_btn.connect("clicked", self._wwbench_copy_csv) + _save_btn = Gtk.Button(label="Save CSV…") + _save_btn.set_tooltip_text("Save results to a .csv file") + _save_btn.connect("clicked", self._wwbench_save_csv) + tb.pack_start(_copy_btn, False, False, 0) + tb.pack_start(_save_btn, False, False, 0) + + results_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + results_box.pack_start(tb, False, False, 0) + results_box.pack_start(ww_sw, True, True, 0) + results_box.pack_start(self.wwb_summary, False, False, 0) + + # Wrap controls in a ScrolledWindow so the pane can shrink past its + # natural height when the user drags the divider upward. + ctrl_sw = Gtk.ScrolledWindow() + ctrl_sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + ctrl_sw.set_min_content_height(120) + ctrl_sw.add(ctrl) + + # ---- Paned: drag the divider to give more space to the table ---- + ww_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) + ww_paned.pack1(ctrl_sw, resize=True, shrink=True) + ww_paned.pack2(results_box, resize=True, shrink=False) + ww_paned.set_position(260) + page.pack_start(ww_paned, True, True, 4) def _run_bench(self, _b) -> None: wav = self.bench_wav.get_filename() refp = self.bench_ref.get_filename() + # Persist to disk so paths survive without clicking Save + changed = False + if wav and wav != self.cfg.bench_wav: + self.cfg.bench_wav = wav; changed = True + if refp and refp != self.cfg.bench_ref: + self.cfg.bench_ref = refp; changed = True + if changed: + save(self.cfg) if not wav or not refp: self._error("Pick a .wav file and a matching reference .txt file.") return reference = Path(refp).read_text(errors="replace") self.bench_store.clear() + if hasattr(self, "stt_name"): # only if Engines tab has been built + self._stt_commit() + + # Filter to checked engines (deduplicate by name as safety net) + checks = getattr(self, "_bench_checks", {}) + seen: set[str] = set() + engines: list = [] + for e in self.cfg.stt_engines: + if e.name in seen: + continue + seen.add(e.name) + if not checks or checks.get(e.name, None) is None or checks[e.name].get_active(): + engines.append(e) + + if not engines: + self.bench_summary.set_markup( + f'No engines selected — tick at least one above.') + return self.bench_summary.set_markup("Running… (local models load on first use)") - self._stt_commit() - engines = list(self.cfg.stt_engines) + + expand = self.bench_expand.get_active() def work(): def prog(row): GLib.idle_add(self._bench_add_row, row) rows = benchmark.run(engines, Path(wav), reference, language=self.cfg.language, - get_local_transcriber=self._transcriber_for, progress=prog) + get_local_transcriber=self._transcriber_for, progress=prog, + expand_models=expand) GLib.idle_add(self._bench_done, rows) threading.Thread(target=work, daemon=True).start() + @staticmethod + def _bench_friendly_error(err: str) -> str: + """Short human-readable reason from a raw STT error string.""" + e = err.lower() + if "bad model" in e or ("400" in e and "model" in e): + return "Wrong model name — clear the model field to use server default" + if "connection refused" in e or "errno 111" in e: + return "Server offline — check the URL and that the service is running" + if "timed out" in e or "time out" in e: + return "Timed out — server too slow or not responding (>60 s)" + if "500" in e or "internal server error" in e: + return "Server-side error — check the server logs" + if "401" in e or "unauthorized" in e: + return "Authentication failed — check the API key environment variable" + if "404" in e or "not found" in e: + return "Endpoint not found — check the URL (needs /v1 suffix?)" + if "cannot reach" in e: + return "Unreachable — check URL / firewall" + return err[:120] + def _bench_add_row(self, row) -> bool: acc = f"{row.accuracy:.1f}%" if row.ok else "—" - out = row.text if row.ok else f"⚠ {row.error}" - self.bench_store.append([row.engine, row.model, row.device, f"{row.seconds:.2f}", acc, out]) + if row.ok: + out_friendly = row.text + tooltip = row.text + else: + out_friendly = f"⚠ {self._bench_friendly_error(row.error)}" + tooltip = row.error + # Strip scheme from URL for display brevity (http://192.168.1.1:8080 → 192.168.1.1:8080) + url_display = row.url.removeprefix("https://").removeprefix("http://").rstrip("/") + lang_display = stt.fmt_languages(row.languages) + if row.ram_mb >= 1.0: + ram_display = f"{row.ram_mb:.0f}" + elif row.url: + if row.srv_ram_mb is not None: + ram_display = f"{row.srv_ram_mb:.0f}" # from server /metrics + else: + ram_display = "server" # metrics not exposed + else: + ram_display = "—" # local model already loaded, no delta + self.bench_store.append([row.engine, url_display, row.model, row.device, row.best_for, + lang_display, f"{row.seconds:.2f}", acc, ram_display, out_friendly, tooltip]) + # Persist result so Engines tab can show it + self.cfg.bench_last[row.engine] = { + "seconds": row.seconds, "accuracy": row.accuracy, "ok": row.ok, + } + save(self.cfg) + # Refresh info label if this engine is currently selected in Engines tab + if hasattr(self, "stt_name"): + cur = self.cfg.stt_engines[self._stt_idx].name if 0 <= self._stt_idx < len(self.cfg.stt_engines) else "" + if cur == row.engine: + self._stt_update_bench_info(row.engine) return False def _bench_done(self, rows) -> bool: fastest, acc = benchmark.best(rows) - if not fastest: + if not fastest or not acc: self.bench_summary.set_markup('All engines failed — check the Log tab.') return False self.bench_summary.set_markup( @@ -1270,15 +2791,22 @@ class SettingsDialog: def work(): models = stt.list_models(url, key) voices = wakeword_bench.list_voices(url, api_key_env=key) + # Kokoro and similar servers expose each voice as a /models entry. + # Skip populating the model combo when all "models" are also voices. + voices_set = set(voices) + real_models = [m for m in models if m not in voices_set] def apply(): - _fill_combo(self.wwb_model, models, self.wwb_model.get_text()) + if real_models: + _fill_combo(self.wwb_model, real_models, self.wwb_model.get_text()) self.wwb_voices.set_models(voices) if voices and not self.wwb_voices.get_text().strip(): self.wwb_voices.set_text(", ".join(voices)) # default: test them all - colour = "#34c759" if (models or voices) else "#ff9f0a" + colour = "#34c759" if (real_models or voices) else "#ff9f0a" + model_hint = (f"{len(real_models)} models, " if real_models + else "type model id manually, ") self.wwb_test_lbl.set_markup( - f"Loaded {len(models)} models, " + f"Loaded {model_hint}" f"{len(voices)} voices.") return False GLib.idle_add(apply) @@ -1316,54 +2844,172 @@ class SettingsDialog: if not voices: self._error("Add at least one voice (or press Connect to fetch them).") return + + # Filter engines by checkboxes + checks = getattr(self, "_wwb_checks", {}) + engines = [e for e in self.cfg.wakeword_engines + if not checks or checks.get(e.name, Gtk.CheckButton(active=True)).get_active()] + if not engines: + self._error("Select at least one wakeword engine to benchmark.") + return + + # Override wakeword model if the user picked a specific one + ww_override = self.wwb_wakeword.get_text().strip() if hasattr(self, "wwb_wakeword") else "" + if ww_override: + import copy as _copy + engines = [_copy.copy(e) for e in engines] + for e in engines: + e.model = ww_override + + if hasattr(self, "wwb_store"): + self.wwb_store.clear() self.wwb_summary.set_markup("Synthesizing and streaming… this talks to your TTS " "and wyoming-openwakeword servers.") def work(): - def prog(done, total, u): - GLib.idle_add(self._wwbench_progress, done, total, u) + def prog(ei, n_eng, eng_name, ui, total, u): + GLib.idle_add(self._wwbench_progress, ei, n_eng, eng_name, ui, total, u) try: - res = wakeword_bench.run( + runs = wakeword_bench.run( + engines, tts_url=url, tts_api_key_env=key, tts_model=model, voices=voices, - wakeword_model=self.cfg.wakeword_model, wakeword_uri=self.cfg.wakeword_uri, language=self.cfg.language, count=count, progress=prog) except Exception as exc: # noqa: BLE001 - surface setup errors GLib.idle_add(self._wwbench_error, str(exc)) return - GLib.idle_add(self._wwbench_done, res) + GLib.idle_add(self._wwbench_done, runs) threading.Thread(target=work, daemon=True).start() - def _wwbench_progress(self, done, total, u) -> bool: + def _wwbench_progress(self, ei, n_eng, eng_name, ui, total, u) -> bool: tag = "✓" if (u.ok and (u.detections > 0) == u.has_wakeword) else ("•" if u.ok else "⚠") kind = "wake" if u.has_wakeword else "filler" + eng_prefix = (f"[{ei}/{n_eng}] {GLib.markup_escape_text(eng_name)} " + if n_eng > 1 else "") self.wwb_summary.set_markup( - f"{done}/{total} {tag} {kind} · {GLib.markup_escape_text(u.voice)}" - + (f" · {GLib.markup_escape_text(u.error)}" if u.error else "")) + f"{eng_prefix}{ui}/{total} {tag} {kind} · {GLib.markup_escape_text(u.voice)}" + + (f" · {GLib.markup_escape_text(u.error)}" + if u.error else "")) return False def _wwbench_error(self, msg: str) -> bool: self.wwb_summary.set_markup(f"{GLib.markup_escape_text(msg)}") return False - def _wwbench_done(self, res) -> bool: - if res.expected == 0: - errs = {u.error for u in res.utterances if u.error} + def _wwbench_done(self, runs: list) -> bool: + self.wwb_store.clear() + if not runs: + self.wwb_summary.set_markup( + 'No engines configured — add one in the Wakeword page.') + return False + if all(er.result.expected == 0 for er in runs): + errs = {u.error for er in runs for u in er.result.utterances if u.error} hint = (" " + GLib.markup_escape_text(next(iter(errs)))) if errs else "" self.wwb_summary.set_markup( f"No utterances synthesized — check the TTS model/endpoint.{hint}") return False - by_voice = res.recall_by_voice() - voice_bits = " ".join( - f"{GLib.markup_escape_text(v)} {d}/{t}" for v, (d, t) in sorted(by_voice.items())) - colour = "#34c759" if res.recall >= 0.9 and res.false_fires == 0 else ( - "#ff9f0a" if res.recall >= 0.6 else "#ff3b30") - self.wwb_summary.set_markup( - f"Recall {res.recall * 100:.0f}% " - f"({res.detected}/{res.expected} fired) · " - f"False fires: {res.false_fires} in {len(res.filler)} filler · " - f"{res.seconds:.0f}s\nper voice: {voice_bits}") + + GREEN, ORANGE, RED_C = "#34c759", "#ff9f0a", "#ff3b30" + summary_parts = [] + for er in runs: + r = er.result + phrase = wakeword_bench.wakeword_phrase(er.model) + if r.expected == 0: + self.wwb_store.append([er.name, phrase, "—", "—", "—", "—", "—", "—", RED_C]) + summary_parts.append(f"{er.name}: no samples") + continue + # Per-voice rows + by_voice = r.recall_by_voice() + filler_fires = {v: sum(u.detections for u in r.filler + if u.ok and u.voice == v) + for v in by_voice} + for voice, (det, tot) in sorted(by_voice.items()): + recall_pct = det / tot * 100 if tot else 0.0 + col = GREEN if recall_pct >= 90 else (ORANGE if recall_pct >= 60 else RED_C) + ff = filler_fires.get(voice, 0) + self.wwb_store.append([ + er.name, phrase, voice, + str(det), str(tot), + f"{recall_pct:.0f}%", + str(ff), + f"{r.seconds:.1f}", + col]) + # Engine summary row (bold aggregates via colour only) + overall_col = GREEN if r.recall >= 0.9 and r.false_fires == 0 else ( + ORANGE if r.recall >= 0.6 else RED_C) + self.wwb_store.append([ + er.name, phrase, f"ALL ({len(by_voice)} voices)", + str(r.detected), str(r.expected), + f"{r.recall * 100:.0f}%", + str(r.false_fires), + f"{r.seconds:.1f}", + overall_col]) + summary_parts.append( + f"{er.name}: {r.recall*100:.0f}% recall, {r.false_fires} false fires") + + self.wwb_summary.set_markup(" · ".join( + GLib.markup_escape_text(p) for p in summary_parts)) return False + _WWB_CSV_HEADERS = ["Engine", "Wakeword", "Voice", "Detected", "Total", + "Recall %", "False fires", "Time (s)"] + + def _wwbench_csv_text(self) -> str: + import csv, io + out = io.StringIO() + w = csv.writer(out) + w.writerow(self._WWB_CSV_HEADERS) + store = self.wwb_store + it = store.get_iter_first() + while it: + w.writerow([store.get_value(it, c) for c in range(8)]) + it = store.iter_next(it) + return out.getvalue() + + def _wwbench_copy_csv(self, _b) -> None: + if not self.wwb_store.get_iter_first(): + return + Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).set_text(self._wwbench_csv_text(), -1) + + def _wwbench_save_csv(self, _b) -> None: + if not self.wwb_store.get_iter_first(): + return + dlg = Gtk.FileChooserDialog( + title="Save wakeword results as CSV", + transient_for=self.dlg, + action=Gtk.FileChooserAction.SAVE) + dlg.add_button("_Cancel", Gtk.ResponseType.CANCEL) + dlg.add_button("_Save", Gtk.ResponseType.ACCEPT) + dlg.set_do_overwrite_confirmation(True) + dlg.set_current_name("wakeword_benchmark.csv") + f = Gtk.FileFilter(); f.set_name("CSV files (*.csv)"); f.add_pattern("*.csv") + dlg.add_filter(f) + if dlg.run() == Gtk.ResponseType.ACCEPT: + path = dlg.get_filename() + if path and not path.endswith(".csv"): + path += ".csv" + try: + with open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(self._wwbench_csv_text()) + except OSError as exc: + self._error(f"Could not save: {exc}") + dlg.destroy() + + # ===== Manual =========================================================== + def _build_manual(self, page: Gtk.Box) -> None: + text = _read_first(_app_paths()["manual"]) + if text == "Not available in this install.": + # Show a clickable link instead of raw text + lbl = Gtk.Label(xalign=0.0) + lbl.set_line_wrap(True) + lbl.set_markup("Manual not bundled in this install.\n\n" + "Read it online: " + "" + "github.com/mARTin-B78/blitztext-app-linux") + lbl.set_margin_top(20); lbl.set_margin_start(20) + page.pack_start(lbl, False, False, 0) + else: + page.pack_start(_md_panel(text, height=420), True, True, 0) + # ===== About ============================================================ def _build_about(self, page: Gtk.Box) -> None: _infobox(page, "About Blitztext: version, source code, recent changes, and licence.") @@ -1399,11 +3045,11 @@ class SettingsDialog: page.pack_start(nb, True, True, 0) changelog_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - changelog_box.pack_start(_text_panel(changelog, height=300), True, True, 0) + changelog_box.pack_start(_md_panel(changelog, height=300), True, True, 0) nb.append_page(changelog_box, Gtk.Label(label="Changelog")) license_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - license_box.pack_start(_text_panel(license_text, height=300), True, True, 0) + license_box.pack_start(_md_panel(license_text, height=300), True, True, 0) nb.append_page(license_box, Gtk.Label(label="License")) @@ -1422,6 +3068,17 @@ class SettingsDialog: bar = Gtk.Box(spacing=8); bar.set_margin_top(6) self.log_autoscroll = Gtk.CheckButton(label="Auto-scroll"); self.log_autoscroll.set_active(True) bar.pack_start(self.log_autoscroll, False, False, 0) + + lvl_lbl = Gtk.Label(label="Level:"); lvl_lbl.set_margin_start(8) + bar.pack_start(lvl_lbl, False, False, 0) + self.log_level = _block_scroll(Gtk.ComboBoxText()) + for lvl in ("Verbose", "Info", "Warning", "Error"): + self.log_level.append_text(lvl) + self.log_level.set_active(1) # default: Info + self.log_level.set_tooltip_text("Show log entries at or above this severity level") + self.log_level.connect("changed", lambda _: (setattr(self, "_log_last", None), self._log_refresh())) + bar.pack_start(self.log_level, False, False, 0) + clear = Gtk.Button(label="Clear"); clear.connect("clicked", lambda _b: (logbuffer.clear(), self._log_refresh())) copy = Gtk.Button(label="Copy"); copy.connect("clicked", lambda _b: self._log_copy()) bar.pack_end(clear, False, False, 0); bar.pack_end(copy, False, False, 0) @@ -1431,12 +3088,16 @@ class SettingsDialog: self._log_refresh() self._log_timer = GLib.timeout_add(1000, self._log_tick) + def _log_min_level(self) -> str: + idx = self.log_level.get_active() if hasattr(self, "log_level") else 1 + return ("DEBUG", "INFO", "WARNING", "ERROR")[max(0, min(idx, 3))] + def _log_tick(self) -> bool: self._log_refresh() return True def _log_refresh(self) -> None: - text = "\n".join(logbuffer.lines()) + text = "\n".join(logbuffer.lines(self._log_min_level())) if text == getattr(self, "_log_last", None): return self._log_last = text @@ -1447,7 +3108,27 @@ class SettingsDialog: def _log_copy(self) -> None: cb = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - cb.set_text("\n".join(logbuffer.lines()), -1) + cb.set_text("\n".join(logbuffer.lines(self._log_min_level())), -1) + + def _draw_resize_grip(self, _da, cr) -> None: + """Draw a 3×3 dotted SE resize-grip in the DrawingArea.""" + import math + w = _da.get_allocated_width() + h = _da.get_allocated_height() + cr.set_source_rgba(0.55, 0.55, 0.55, 0.65) + spacing, radius = 5, 1.5 + for row in range(3): + for col in range(3): + if row + col >= 2: # lower-right triangle only + cx = w - 3 + (col - 2) * spacing + cy = h - 3 + (row - 2) * spacing + cr.arc(cx, cy, radius, 0, 2 * math.pi) + cr.fill() + + def _open_wizard(self, _btn=None) -> None: + from .setup_wizard import SetupWizard + wiz = SetupWizard(self.cfg, parent=self.dlg) + wiz.run() def _cleanup(self) -> None: self._stop_meter() @@ -1477,13 +3158,27 @@ class SettingsDialog: c.sound_before = self.snd_before.get_filename() or "" c.sound_after = self.snd_after.get_filename() or "" c.wakeword_enabled = self.ww_enabled.get_active() - c.wakeword_uri = self.ww_uri.get_text().strip() - c.wakeword_model = _combo_text(self.ww_model) + self._ww_commit() + idx = getattr(self, "_ww_idx", 0) + if 0 <= idx < len(self.cfg.wakeword_engines): + e = self.cfg.wakeword_engines[idx] + c.wakeword_active = e.name + c.wakeword_uri = e.uri + c.wakeword_model = e.model + else: + c.wakeword_uri = self.ww_uri.get_text().strip() + c.wakeword_model = _combo_text(self.ww_model) c.wakeword_sound_detected = self.ww_snd_detected.get_filename() or "" c.wakeword_sound_done = self.ww_snd_done.get_filename() or "" c.wakeword_silence_seconds = float(self.ww_silence.get_text()) + c.wakeword_cancel_model = _combo_text(self.ww_cancel_model) if hasattr(self, "ww_cancel_model") else "" + c.wakeword_send_model = _combo_text(self.ww_send_model) if hasattr(self, "ww_send_model") else "" c.cancel_keywords = [k.strip() for k in self.cancel_keywords.get_text().split(",") if k.strip()] c.send_keywords = [k.strip() for k in self.send_keywords.get_text().split(",") if k.strip()] + if hasattr(self, "ww_key_cancel"): + c.key_cancel = self.ww_key_cancel.get_text().strip() or c.key_cancel + if hasattr(self, "ww_key_send"): + c.key_send = self.ww_key_send.get_text().strip() or c.key_send c.tts_url = self.wwb_url.get_text().strip().rstrip("/") c.tts_api_key_env = self.wwb_key.get_text().strip() c.tts_model = self.wwb_model.get_text().strip() @@ -1494,22 +3189,25 @@ class SettingsDialog: c.notify = self.gen_notify.get_active() c.notify_routing = self.gen_notify_routing.get_active() c.overlay_enabled = self.gen_overlay.get_active() - c.device = self.stt_device.get_active_text() or "auto" - c.compute_type = self.stt_compute.get_active_text() or "auto" + c.device = _type_key(self.stt_device, _DEVICE_OPTIONS, "auto") + c.compute_type = _type_key(self.stt_compute, _COMPUTE_OPTIONS, "auto") autostart.set_enabled(self.gen_boot.get_active()) if c.wakeword_enabled: - import socket - from urllib.parse import urlparse - parsed = urlparse(c.wakeword_uri) - host = parsed.hostname or "127.0.0.1" - port = parsed.port or 10400 - try: - with socket.create_connection((host, port), timeout=1.5): - pass - except OSError as e: - self._error(f"Cannot connect to Wakeword server at {c.wakeword_uri}:\n{e}\n\nPlease check your server or disable wakeword.") - return False + # Check reachability in background — don't block the GTK thread + uri = c.wakeword_uri + def _bg_ww_check(uri=uri): + import socket + from urllib.parse import urlparse + parsed = urlparse(uri) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 10400 + try: + with socket.create_connection((host, port), timeout=2.0): + logbuffer.log(f"[wakeword] server reachable at {uri}", level="INFO") + except OSError as e: + logbuffer.log(f"[wakeword] cannot reach {uri}: {e}", level="WARNING") + threading.Thread(target=_bg_ww_check, daemon=True).start() except ValueError as exc: self._error(f"Check numeric fields: {exc}") return False @@ -1520,18 +3218,62 @@ class SettingsDialog: buttons=Gtk.ButtonsType.OK, text=msg) d.run(); d.destroy() - def _info(self, msg: str) -> None: - d = Gtk.MessageDialog(transient_for=self.dlg, modal=True, message_type=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.OK, text=msg) - d.run(); d.destroy() + # ---- restart-diff fields --------------------------------------------------- + # Human-readable label → tuple of Config field names that must match. + _RESTART_FIELDS: list[tuple[str, tuple]] = [ + ("active STT engine", ("stt_active",)), + ("STT engine settings", ("stt_engines",)), + ("device / compute", ("device", "compute_type")), + ("wakeword", ("wakeword_enabled", "wakeword_active", + "wakeword_engines")), + ("hotkeys", ("input_mode", "push_to_talk", + "key_start", "key_stop", "key_send", "key_cancel")), + ("microphone", ("mic",)), + ] + + @staticmethod + def _cfg_snapshot(cfg) -> dict: + import dataclasses + return dataclasses.asdict(cfg) + + def _diff_requires_restart(self, snap_before: dict, snap_after: dict) -> list[str]: + reasons = [] + for label, fields in self._RESTART_FIELDS: + if any(snap_before.get(f) != snap_after.get(f) for f in fields): + reasons.append(label) + return reasons + + def _set_status(self, msg: str, *, warning: bool = False) -> None: + colour = "#e67e00" if warning else "#2a7d2a" + self._status_lbl.set_markup( + f'{GLib.markup_escape_text(msg)}') + self._status_lbl.show() + if warning: + self._save_restart_btn.get_style_context().add_class("suggested-action") + else: + self._save_restart_btn.get_style_context().remove_class("suggested-action") + + def _clear_status(self) -> bool: + self._status_lbl.hide() + return False # don't repeat the timeout def _on_response(self, dlg, resp): if resp in (RESP_SAVE, RESP_SAVE_RESTART): - self._force_build_tabs() # ensure every tab's fields exist + self._force_build_tabs() if resp == RESP_SAVE: + import copy + snap_before = self._cfg_snapshot(self.cfg) if self._collect(): save(self.cfg) - self._info("Saved. Restart Blitztext to apply engine/hotkey changes.") + snap_after = self._cfg_snapshot(self.cfg) + needs = self._diff_requires_restart(snap_before, snap_after) + if needs: + self._set_status( + f"⚠ Saved — restart needed for: {', '.join(needs)}", + warning=True) + else: + self._set_status("✓ Settings applied") + GLib.timeout_add(4000, self._clear_status) return if resp == RESP_SAVE_RESTART: if self._collect(): @@ -1539,6 +3281,7 @@ class SettingsDialog: self._cleanup() os.execv(sys.executable, [sys.executable, "-m", "blitztext", "tray"]) return + # X button / window close self._cleanup() dlg.destroy()