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. |
| **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). |
| **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. |
| **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. |
@ -212,7 +212,7 @@ Read-only information:
- **Version** and a link to the source repository
(`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.
---

View File

@ -9,6 +9,15 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [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
### Added

View File

@ -126,6 +126,8 @@ class Config:
out: list[str] = []
for w in self.workflows:
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
@property

View File

@ -92,6 +92,44 @@ def _labeled(parent: Gtk.Box, label: str, widget: Gtk.Widget, width: int = 130,
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:
"""A plain-language help box at the top of a tab (read by screen readers)."""
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).")
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.")
self.gen_notify = Gtk.Switch(); self.gen_notify.set_active(self.cfg.notify); self.gen_notify.set_halign(Gtk.Align.START)
_labeled(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)
_labeled(page, "Announce matched preset", self.gen_notify_routing,
tooltip="Pop a notification showing which preset (and spoken keyword) a voice command "
"matched. Shown even for hands-free wakeword sessions, so you can always see "
"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.set_halign(Gtk.Align.START)
_labeled(page, "Launch on login", self.gen_boot)
self.gen_notify = Gtk.Switch(); self.gen_notify.set_active(self.cfg.notify)
_switch_row(page, "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,
"After a voice command, show which preset and keyword matched — even hands-free.")
self.gen_boot = Gtk.Switch(); self.gen_boot.set_active(autostart.is_enabled())
_switch_row(page, "Launch on login", self.gen_boot,
"Start Blitztext automatically when you log in.")
self._start_meter()
def _selected_mic_name(self) -> str:
@ -1071,6 +1109,10 @@ class SettingsDialog:
license_label.set_selectable(True)
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.set_margin_top(8)
page.pack_start(nb, True, True, 0)

View File

@ -85,7 +85,13 @@ def route(
best = None # (score, span_len, position, preset_name, keyword)
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)
m = _match_window(tokens, kw_tokens, threshold)
if m is None:

View File

@ -51,3 +51,23 @@ def test_strip_span():
# 'nicer e-mail' normalizes to 3 tokens
res = _strip_span("Nicer e-mail can you send me the report", 3, "start")
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"