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:
parent
834cc20553
commit
016ffe0390
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user