diff --git a/MANUAL.md b/MANUAL.md index 365dbfd..fb768b2 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -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. `++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. --- diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index fcdff9e..41835b5 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -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 diff --git a/linux/blitztext/config.py b/linux/blitztext/config.py index 042df8b..286668c 100644 --- a/linux/blitztext/config.py +++ b/linux/blitztext/config.py @@ -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 diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 930d8cb..45c6aee 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -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) diff --git a/linux/blitztext/routing.py b/linux/blitztext/routing.py index 12ee8e4..7333ce1 100644 --- a/linux/blitztext/routing.py +++ b/linux/blitztext/routing.py @@ -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: diff --git a/linux/tests/test_routing.py b/linux/tests/test_routing.py index 80e7a9f..c76bd81 100644 --- a/linux/tests/test_routing.py +++ b/linux/tests/test_routing.py @@ -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"