feat: overlay × cancel button + wakeword shortcut fields inline (v2.03.27)

- 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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-10 04:00:23 +02:00
parent 834cc20553
commit 016ffe0390
5 changed files with 145 additions and 17 deletions

View File

@ -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

View File

@ -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"

View File

@ -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("<esc>")
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()

View File

@ -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

View File

@ -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)