From 016ffe03902f44e6c46421dc32e6a004f1b5c822 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 10 Jun 2026 04:00:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20overlay=20=C3=97=20cancel=20button=20+?= =?UTF-8?q?=20wakeword=20shortcut=20fields=20inline=20(v2.03.27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Overlay HUD shows a × button in the top-right corner during recording/ streaming; clicking it cancels dictation. The button area is the only clickable region — rest stays fully click-through. - Wakeword tab "Cancel words" and "Send words" rows now include an inline keyboard shortcut entry + Set button, so key_cancel / key_send can be configured right next to the spoken-word equivalents. Co-Authored-By: Claude Sonnet 4.6 --- linux/CHANGELOG.md | 13 ++++++ linux/blitztext/__init__.py | 2 +- linux/blitztext/gtksettings.py | 67 +++++++++++++++++++++++++++--- linux/blitztext/gtkui.py | 6 ++- linux/blitztext/overlay.py | 74 +++++++++++++++++++++++++++++----- 5 files changed, 145 insertions(+), 17 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 7558ddf..c0f2822 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,19 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [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 diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index 044b766..da66f45 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__ = "2.03.26" +__version__ = "2.03.27" diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 1125d4a..df59a1f 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -1648,6 +1648,51 @@ notebook.bt-nb tab:checked label { 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) @@ -1789,12 +1834,18 @@ notebook.bt-nb tab:checked label { 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 = _labeled(ww_card, "Cancel words", _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 typed. Empty = off.") - self.send_keywords = _labeled(ww_card, "Send words", _entry(", ".join(self.cfg.send_keywords), - placeholder="computer send, computer abschicken"), width=LW, - tooltip="Say one of these to type AND press Enter (spoken ‘submit’). Use a distinctive multi-word phrase. Empty = off.") + 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) self._ww_load_idx(active_idx) @@ -3028,6 +3079,10 @@ notebook.bt-nb tab:checked label { c.wakeword_silence_seconds = float(self.ww_silence.get_text()) 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() diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py index 16eb4b4..4568f98 100644 --- a/linux/blitztext/gtkui.py +++ b/linux/blitztext/gtkui.py @@ -96,7 +96,11 @@ class App: try: from .overlay import Overlay - self.overlay = Overlay(anchor_mode=cfg.overlay_anchor) + self.overlay = Overlay( + anchor_mode=cfg.overlay_anchor, + on_cancel=lambda: threading.Thread( + target=self.daemon.cancel_dictation, daemon=True).start(), + ) if cfg.overlay_anchor == "caret": from . import caret diff --git a/linux/blitztext/overlay.py b/linux/blitztext/overlay.py index 50e1a05..c644e7e 100644 --- a/linux/blitztext/overlay.py +++ b/linux/blitztext/overlay.py @@ -56,9 +56,12 @@ _PHASES = { } +_CANCEL_BTN_R = 11 # hit-radius of the × button (px) + class Overlay: - def __init__(self, anchor_mode: str = "caret") -> None: + def __init__(self, anchor_mode: str = "caret", on_cancel=None) -> None: self.anchor_mode = anchor_mode + self._on_cancel_cb = on_cancel # callable() → cancels current recording self._visible = False self._state = "recording" self._text = "" @@ -94,6 +97,8 @@ class Overlay: self._pending_level: float = 0.0 self._level_flush_queued: bool = False + self._cancel_btn_rect = (0, 0, 0, 0) # (x, y, w, h) in window coords + self._win = Gtk.Window(type=Gtk.WindowType.POPUP) self._win.set_app_paintable(True) self._win.set_resizable(False) @@ -112,15 +117,32 @@ class Overlay: self._area.connect("draw", self._on_draw) self._win.add(self._area) self._win.connect("realize", self._on_realize) + self._win.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) + self._win.connect("button-press-event", self._on_click) self._win.set_default_size(_WIDTH, self._height) - # -- click-through -------------------------------------------------------- + # -- click-through (all except the × button) ------------------------------ def _on_realize(self, _w) -> None: + self._update_input_region() + + def _update_input_region(self) -> None: gdkwin = self._win.get_window() - if gdkwin is not None: - # Empty input region → the HUD ignores all clicks; they fall through - # to whatever is underneath (the field you're typing into). - gdkwin.input_shape_combine_region(cairo.Region(), 0, 0) + if gdkwin is None: + return + if self._on_cancel_cb and self._state in ("recording", "streaming"): + x, y, w, h = self._cancel_btn_rect + r = cairo.Region(cairo.RectangleInt(int(x), int(y), int(w), int(h))) + else: + r = cairo.Region() # empty → fully click-through + gdkwin.input_shape_combine_region(r, 0, 0) + + def _on_click(self, _win, event) -> bool: + if self._on_cancel_cb is None: + return False + x, y, w, h = self._cancel_btn_rect + if x <= event.x <= x + w and y <= event.y <= y + h: + self._on_cancel_cb() + return True # -- thread-safe public API ---------------------------------------------- def show(self, state: str, window_id: str | None) -> None: @@ -235,6 +257,7 @@ class Overlay: # The countdown only makes sense while listening; drop it as soon as # we move on to transcribing / done / idle so the ring doesn't linger. self._cd_deadline = None + self._update_input_region() # enable/disable × hit area if state in ("recording", "streaming", "busy"): self._cancel_hide() elif state in ("done", "idle", "error"): @@ -342,6 +365,13 @@ class Overlay: self._win.move(int(win_x), int(win_y)) self._area.set_size_request(_WIDTH, self._height) + # X button: top-right corner of the bubble body. + _body_top = _TAIL_H if self._tail_up else 0 + btn_d = _CANCEL_BTN_R * 2 + self._cancel_btn_rect = ( + _WIDTH - _PAD - btn_d, _body_top + _PAD // 2, btn_d, btn_d) + self._update_input_region() + # -- drawing -------------------------------------------------------------- def _on_draw(self, _area, cr) -> bool: # Start fully transparent. @@ -373,10 +403,17 @@ class Overlay: wf_w = w - _PAD - wf_x self._draw_wave(cr, wf_x, body_top + _PAD, wf_w, _HEADER_H) - # Phase label by the waveform (top-right) — only when there's no preset - # banner below (which carries the phase instead) and no text yet. + # × cancel button (top-right corner, recording/streaming only). + if self._on_cancel_cb and self._state in ("recording", "streaming"): + self._draw_cancel_btn(cr, body_top) + + # Phase label by the waveform — only when there's no preset banner and no text. + # Shift left to leave room for the × button. + label_right = (w - _PAD - _CANCEL_BTN_R * 2 - 6 + if self._on_cancel_cb and self._state in ("recording", "streaming") + else w - _PAD) if self._phase_label and not self._text and not self._preset_name: - self._draw_label(cr, w - _PAD, body_top + _PAD + 12, self._phase_label) + self._draw_label(cr, label_right, body_top + _PAD + 12, self._phase_label) y = body_top + _PAD + _HEADER_H # Matched-preset banner: emoji + name (left), live phase chip (right). @@ -524,6 +561,25 @@ class Overlay: cr.move_to(right_x - tw, cy - th / 2) PangoCairo.show_layout(cr, layout) + def _draw_cancel_btn(self, cr, body_top) -> None: + x, y, w, h = self._cancel_btn_rect + cx_btn = x + w / 2 + cy_btn = y + h / 2 + cr.set_source_rgba(1, 1, 1, 0.15) + cr.arc(cx_btn, cy_btn, _CANCEL_BTN_R, 0, 2 * math.pi) + cr.fill() + arm = _CANCEL_BTN_R * 0.45 + cr.set_source_rgba(1, 1, 1, 0.80) + cr.set_line_width(1.8) + cr.set_line_cap(cairo.LINE_CAP_ROUND) + cr.move_to(cx_btn - arm, cy_btn - arm) + cr.line_to(cx_btn + arm, cy_btn + arm) + cr.stroke() + cr.move_to(cx_btn + arm, cy_btn - arm) + cr.line_to(cx_btn - arm, cy_btn + arm) + cr.stroke() + cr.set_line_cap(cairo.LINE_CAP_BUTT) + def _draw_text(self, cr, x, y, w) -> None: layout = self._win.create_pango_layout(self._text) layout.set_width(w * Pango.SCALE)