routing by preset name; right-aligned General switches; About copyright

- Spoken presets: route() now also matches a preset's name as an implicit
  keyword, so presets with no configured keywords (Nicer email, Calm down,
  Add emojis) are triggerable by voice — say the name. Explicit keywords still
  win; names are added to STT hotwords too. Tests added.
- General tab: switches moved to the far right of each row with an inline
  grey description (new _switch_row helper) so each toggle is self-explanatory.
- About tab: add "Copyright: 2026 mARTin Bierschenk - Design".
- MANUAL + CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-07 11:48:22 +02:00
parent 8cfc006296
commit d00064b6cd
6 changed files with 91 additions and 12 deletions

View File

@ -35,7 +35,7 @@ Use the dropdown at the top to pick a preset to edit, **+ Add** to create one, o
| **Name** | `name` | Short name for the action, shown in the main panel. | | **Name** | `name` | Short name for the action, shown in the main panel. |
| **Icon (emoji)** | `icon` | Emoji shown next to this preset in the "matched preset" notification — give each a distinct one to tell them apart at a glance. Default `⚡`. | | **Icon (emoji)** | `icon` | Emoji shown next to this preset in the "matched preset" notification — give each a distinct one to tell them apart at a glance. Default `⚡`. |
| **Description** | `description` | One line explaining what the preset does (shown in the panel). | | **Description** | `description` | One line explaining what the preset does (shown in the panel). |
| **Keywords (comma)** | `keywords` | Spoken trigger words, comma-separated. Say one at the **start or end** of your speech to select this preset (fuzzy-matched, e.g. `nicer email, bessere email`). | | **Keywords (comma)** | `keywords` | Spoken trigger words, comma-separated. Say one at the **start or end** of your speech to select this preset (fuzzy-matched, e.g. `nicer email, bessere email`). The preset's **name is always an implicit trigger**, so it works by voice even with no keywords here; add keywords for alternate/foreign-language phrasings. |
| **Hotkey (optional)** | `hotkey` | A direct keyboard shortcut for this preset. Click **Set** and press the combo, or type it (e.g. `<ctrl>+<alt>+e`). Leave blank for keyword-only. | | **Hotkey (optional)** | `hotkey` | A direct keyboard shortcut for this preset. Click **Set** and press the combo, or type it (e.g. `<ctrl>+<alt>+e`). Leave blank for keyword-only. |
| **Mode** | `mode` | `transcribe` types your words as-is · `rewrite` sends them to the language model first · `stream` shows live text from a realtime STT engine. | | **Mode** | `mode` | `transcribe` types your words as-is · `rewrite` sends them to the language model first · `stream` shows live text from a realtime STT engine. |
| **LLM model (opt.)** | `model` | Override the language model for *this preset only*. Blank = use the active LLM engine's model. | | **LLM model (opt.)** | `model` | Override the language model for *this preset only*. Blank = use the active LLM engine's model. |
@ -212,7 +212,7 @@ Read-only information:
- **Version** and a link to the source repository - **Version** and a link to the source repository
(`github.com/mARTin-B78/blitztext-app-linux`). (`github.com/mARTin-B78/blitztext-app-linux`).
- **License: MIT**. - **License: MIT** · **Copyright: 2026 mARTin Bierschenk - Design**.
- Sub-tabs with the full **Changelog** and **License** text. - Sub-tabs with the full **Changelog** and **License** text.
--- ---

View File

@ -9,6 +9,15 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [Unreleased] ## [Unreleased]
### Changed
- **Presets are speakable by name**: voice routing now matches a preset's *name*
as an implicit keyword, so a preset works by voice even with no keywords
configured (e.g. just say "nicer email …"). Explicit keywords still take
precedence, and preset names also bias the STT for better recognition.
- **General settings switches** moved to the far right of each row, each with an
inline description so it's clear what the toggle does without hovering.
- **About**: added a "Copyright: 2026 mARTin Bierschenk - Design" line.
## [1.4.0] - 2026-06-07 ## [1.4.0] - 2026-06-07
### Added ### Added

View File

@ -126,6 +126,8 @@ class Config:
out: list[str] = [] out: list[str] = []
for w in self.workflows: for w in self.workflows:
out.extend(w.keywords) out.extend(w.keywords)
if w.name and w.name not in out:
out.append(w.name) # names are implicit triggers — bias STT for them too
return out return out
@property @property

View File

@ -92,6 +92,44 @@ def _labeled(parent: Gtk.Box, label: str, widget: Gtk.Widget, width: int = 130,
return widget return widget
def _switch_row(parent: Gtk.Box, 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.
"""
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)
row.pack_start(lbl, False, False, 0)
desc = Gtk.Label(label=description, xalign=0.0)
desc.set_line_wrap(True)
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)
row.pack_end(switch, False, False, 0)
# ATK accessibility + tooltips
if hasattr(lbl, "set_mnemonic_widget"):
lbl.set_mnemonic_widget(switch)
atk = switch.get_accessible()
if atk and label:
atk.set_name(label.replace("_", ""))
if description:
atk.set_description(description)
if description:
switch.set_tooltip_text(description)
lbl.set_tooltip_text(description)
desc.set_tooltip_text(description)
parent.pack_start(row, False, False, 0)
return switch
def _infobox(parent: Gtk.Box, text: str) -> Gtk.Box: def _infobox(parent: Gtk.Box, text: str) -> Gtk.Box:
"""A plain-language help box at the top of a tab (read by screen readers).""" """A plain-language help box at the top of a tab (read by screen readers)."""
box = Gtk.Box(spacing=8) box = Gtk.Box(spacing=8)
@ -860,15 +898,15 @@ class SettingsDialog:
tooltip="type types the text key by key. paste copies it and presses Ctrl+V (faster for long text).") 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)"), self.gen_lang = _labeled(page, "Language hint", _entry(self.cfg.language, placeholder="de, en, … (blank = autodetect)"),
tooltip="Spoken language code (de, en, …). Leave blank to auto-detect.") tooltip="Spoken language code (de, en, …). Leave blank to auto-detect.")
self.gen_notify = Gtk.Switch(); self.gen_notify.set_active(self.cfg.notify); self.gen_notify.set_halign(Gtk.Align.START) self.gen_notify = Gtk.Switch(); self.gen_notify.set_active(self.cfg.notify)
_labeled(page, "Notifications", self.gen_notify) _switch_row(page, "Notifications", self.gen_notify,
self.gen_notify_routing = Gtk.Switch(); self.gen_notify_routing.set_active(self.cfg.notify_routing); self.gen_notify_routing.set_halign(Gtk.Align.START) "Desktop pop-ups for recording, transcription, and errors (manual dictation).")
_labeled(page, "Announce matched preset", self.gen_notify_routing, self.gen_notify_routing = Gtk.Switch(); self.gen_notify_routing.set_active(self.cfg.notify_routing)
tooltip="Pop a notification showing which preset (and spoken keyword) a voice command " _switch_row(page, "Announce matched preset", self.gen_notify_routing,
"matched. Shown even for hands-free wakeword sessions, so you can always see " "After a voice command, show which preset and keyword matched — even hands-free.")
"what you triggered. Each preset's emoji icon appears in the notification.") self.gen_boot = Gtk.Switch(); self.gen_boot.set_active(autostart.is_enabled())
self.gen_boot = Gtk.Switch(); self.gen_boot.set_active(autostart.is_enabled()); self.gen_boot.set_halign(Gtk.Align.START) _switch_row(page, "Launch on login", self.gen_boot,
_labeled(page, "Launch on login", self.gen_boot) "Start Blitztext automatically when you log in.")
self._start_meter() self._start_meter()
def _selected_mic_name(self) -> str: def _selected_mic_name(self) -> str:
@ -1071,6 +1109,10 @@ class SettingsDialog:
license_label.set_selectable(True) license_label.set_selectable(True)
page.pack_start(license_label, False, False, 2) page.pack_start(license_label, False, False, 2)
copyright_label = Gtk.Label(label="Copyright: 2026 mARTin Bierschenk - Design", xalign=0.0)
copyright_label.set_selectable(True)
page.pack_start(copyright_label, False, False, 2)
nb = Gtk.Notebook() nb = Gtk.Notebook()
nb.set_margin_top(8) nb.set_margin_top(8)
page.pack_start(nb, True, True, 0) page.pack_start(nb, True, True, 0)

View File

@ -85,7 +85,13 @@ def route(
best = None # (score, span_len, position, preset_name, keyword) best = None # (score, span_len, position, preset_name, keyword)
for preset in presets: for preset in presets:
for keyword in getattr(preset, "keywords", None) or []: # The preset's own name is always an implicit trigger, so a preset is
# speakable by name even when it has no keywords configured.
candidates = list(getattr(preset, "keywords", None) or [])
name = getattr(preset, "name", "")
if name:
candidates.append(name)
for keyword in candidates:
kw_tokens = normalize(keyword) kw_tokens = normalize(keyword)
m = _match_window(tokens, kw_tokens, threshold) m = _match_window(tokens, kw_tokens, threshold)
if m is None: if m is None:

View File

@ -51,3 +51,23 @@ def test_strip_span():
# 'nicer e-mail' normalizes to 3 tokens # 'nicer e-mail' normalizes to 3 tokens
res = _strip_span("Nicer e-mail can you send me the report", 3, "start") res = _strip_span("Nicer e-mail can you send me the report", 3, "start")
assert res == "can you send me the report" assert res == "can you send me the report"
def test_route_matches_preset_name_without_keywords():
"""A preset with no keywords is still speakable by its name."""
presets = [
DummyPreset("Transcribe", []),
DummyPreset("Nicer email", []), # no keywords configured
]
res = route("nicer email please call me back tomorrow", presets)
assert res.preset_name == "Nicer email"
assert res.text == "please call me back tomorrow"
def test_explicit_keyword_still_wins_over_name():
presets = [
DummyPreset("Add emojis", ["emoji"]),
]
res = route("emoji this is great", presets)
assert res.preset_name == "Add emojis"
assert res.text == "this is great"