blitztext-app-linux/linux/blitztext/gtkui.py
mARTin-B78 ae01472b77 overlay: fix AT-SPI caret freeze; fuse routing match + live LLM into HUD; Release 1.6.0
Fix a desktop-session freeze (forced logout/reboot) caused by the overlay's
AT-SPI caret tracker: it subscribed to the high-frequency object:text-caret-moved
signal and made synchronous, blocking AT-SPI reads from inside the event handler,
re-entering the a11y dispatcher and getting stormed by the app's own xdotool
typing until GNOME stopped responding. Now track focus changes only and read the
caret rectangle lazily, once, when the overlay shows — never on the hot path.

Fuse voice-routing feedback into the overlay instead of a desktop notification:
show the matched preset's emoji, name, and spoken keyword on a banner, narrate
the phase (Transcribing -> Rewriting), and stream the LLM rewrite into the bubble
token-by-token. Redundant per-dictation notifications are suppressed when the
overlay is present (errors still notify); headless/overlay-off is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:58:47 +02:00

414 lines
16 KiB
Python

"""GTK control panel for Blitztext — a polished, GNOME-native UI.
Replaces the old tkinter panel. Uses GTK3 with CSS for rounded cards, a soft
gradient background, circular icon avatars, hotkey pills, and hover states.
Runs on the GTK main loop, unified with the AppIndicator tray (no event-loop
hacks). Status updates from worker threads are marshalled with GLib.idle_add.
"""
from __future__ import annotations
import os
import sys
import threading
from pathlib import Path
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, GLib, Gtk # noqa: E402
from . import __version__ # noqa: E402
from .config import Config, load, save # noqa: E402
from .daemon import Daemon # noqa: E402
ICON_PATHS = [
"/usr/share/icons/hicolor/256x256/apps/blitztext.png",
str(Path(__file__).resolve().parent.parent / "packaging" / "blitztext.png"),
]
CSS = b"""
.bg { background-image: linear-gradient(180deg, #f8fbff 0%, #eaf1fa 100%); }
.title { font-weight: 700; font-size: 15px; }
.status { color: #7b818b; font-size: 12px; }
.dot { font-size: 11px; }
.ver { color: #aeb3bb; font-size: 10px; }
.row { background: none; border: none; box-shadow: none; padding: 9px 12px; border-radius: 12px; }
.row:hover { background-color: rgba(10,90,240,0.07); }
.row.recording { background-color: rgba(255,59,48,0.12); }
.row.dim { opacity: 0.45; }
.name { font-weight: 600; font-size: 13px; color: #1b1c1f; }
.desc { color: #80858e; font-size: 11px; }
.pill { background-color: rgba(0,0,0,0.06); color: #6a6f78; border-radius: 8px;
padding: 2px 8px; font-size: 10px; }
.pill.rec { background-color: #ff3b30; color: #ffffff; }
.avatar { color: #ffffff; font-weight: 700; font-size: 14px;
border-radius: 17px; min-width: 34px; min-height: 34px; }
.c0 { background-color: #0a84ff; }
.c1 { background-color: #30b85a; }
.c2 { background-color: #ff9f0a; }
.c3 { background-color: #ff375f; }
.c4 { background-color: #af52de; }
.c5 { background-color: #18b6c9; }
.gear { background: none; border: none; box-shadow: none; padding: 4px; }
.gear:hover { background-color: rgba(0,0,0,0.06); border-radius: 8px; }
"""
AVATAR_N = 6
def pretty_hotkey(hotkey: str) -> str:
names = {"<ctrl>": "Ctrl", "<alt>": "Alt", "<shift>": "Shift", "<cmd>": "Super", "<space>": "Space"}
parts = []
for raw in hotkey.split("+"):
inner = raw.strip("<>")
parts.append(names.get(raw, inner.upper() if len(inner) == 1 else inner.title()))
return " ".join(parts)
def _install_css() -> None:
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
class App:
def __init__(self, cfg: Config, tray_mode: bool = False):
self.cfg = cfg
self.tray_mode = tray_mode
self.tray = None
self._rows: dict[str, dict] = {}
self._active: str | None = None
# On-screen dictation HUD (mic + waveform + recognised-text bubble).
self.overlay = None
self._ov_state = "idle"
self._ov_streaming = False
if cfg.overlay_enabled:
try:
from .overlay import Overlay
self.overlay = Overlay(anchor_mode=cfg.overlay_anchor)
if cfg.overlay_anchor == "caret":
from . import caret
caret.start_tracking()
except Exception: # noqa: BLE001 - overlay is optional eye-candy
self.overlay = None
self.daemon = Daemon(
cfg, status_cb=self._status_cb,
level_cb=self._on_level, text_cb=self._on_text,
countdown_cb=self._on_countdown,
# Only consume routing on the overlay when there's an overlay to show
# it on; otherwise the daemon keeps the desktop notification.
routing_cb=self._on_routing if self.overlay is not None else None,
)
_install_css()
self._build_window()
threading.Thread(target=self._startup, daemon=True).start()
# -- window ---------------------------------------------------------------
def _build_window(self) -> None:
self.win = Gtk.Window()
self.win.set_default_size(420, -1)
self.win.set_resizable(False)
for p in ICON_PATHS:
if os.path.exists(p):
try:
self.win.set_icon_from_file(p)
except Exception: # noqa: BLE001
pass
break
self.win.set_icon_name("blitztext")
head = Gtk.HeaderBar()
head.set_show_close_button(True)
head.set_title("Blitztext")
status_box = Gtk.Box(spacing=5)
self.dot = Gtk.Label(label="")
self.dot.get_style_context().add_class("dot")
self._set_dot("#ff9f0a")
self.status_lbl = Gtk.Label(label="Starting…")
self.status_lbl.get_style_context().add_class("status")
status_box.pack_start(self.dot, False, False, 0)
status_box.pack_start(self.status_lbl, False, False, 0)
head.pack_start(status_box)
gear = Gtk.Button()
gear.set_image(Gtk.Image.new_from_icon_name("emblem-system-symbolic", Gtk.IconSize.BUTTON))
gear.get_style_context().add_class("gear")
gear.set_tooltip_text("Settings")
gear.connect("clicked", lambda _b: self.open_settings())
head.pack_end(gear)
self.win.set_titlebar(head)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
outer.get_style_context().add_class("bg")
self.listbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
self.listbox.set_margin_top(10)
self.listbox.set_margin_bottom(6)
self.listbox.set_margin_start(10)
self.listbox.set_margin_end(10)
self._refresh_listbox()
outer.pack_start(self.listbox, True, True, 0)
foot = Gtk.Box(spacing=8)
foot.set_margin_start(14)
foot.set_margin_end(14)
foot.set_margin_bottom(10)
foot.set_margin_top(2)
ver = Gtk.Label(label=f"v{__version__}")
ver.get_style_context().add_class("ver")
foot.pack_start(ver, False, False, 0)
quit_b = Gtk.Button(label="Quit")
quit_b.get_style_context().add_class("gear")
quit_b.connect("clicked", lambda _b: self.quit_all())
foot.pack_end(quit_b, False, False, 0)
outer.pack_start(foot, False, False, 0)
self.win.add(outer)
self.win.connect("delete-event", self._on_delete)
def _make_row(self, i: int, wf) -> Gtk.Button:
btn = Gtk.Button()
btn.get_style_context().add_class("row")
box = Gtk.Box(spacing=12)
avatar = Gtk.Label(label=(wf.name[:1] or "?").upper())
avatar.get_style_context().add_class("avatar")
avatar.get_style_context().add_class(f"c{i % AVATAR_N}")
box.pack_start(avatar, False, False, 0)
mid = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
name = Gtk.Label(label=wf.name, xalign=0.0)
name.get_style_context().add_class("name")
mid.pack_start(name, False, False, 0)
if wf.description:
desc = Gtk.Label(label=wf.description, xalign=0.0)
desc.get_style_context().add_class("desc")
mid.pack_start(desc, False, False, 0)
box.pack_start(mid, True, True, 0)
pill = Gtk.Label(label=pretty_hotkey(wf.hotkey))
pill.get_style_context().add_class("pill")
box.pack_end(pill, False, False, 0)
btn.add(box)
btn.set_tooltip_text(f"{wf.name}: {wf.description}" if wf.description else wf.name)
atk = btn.get_accessible()
if atk:
atk.set_name(wf.name)
if wf.description:
atk.set_description(wf.description)
btn.connect("clicked", lambda _b, w=wf: self.on_row_click(w))
self._rows[wf.name] = {"btn": btn, "pill": pill}
return btn
def _refresh_listbox(self) -> None:
for child in self.listbox.get_children():
self.listbox.remove(child)
self._rows.clear()
for i, wf in enumerate(self.cfg.workflows):
btn = self._make_row(i, wf)
btn.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, [], Gdk.DragAction.MOVE)
btn.drag_source_add_text_targets()
btn.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.MOVE)
btn.drag_dest_add_text_targets()
btn.connect("drag-data-get", self._on_drag_data_get, i)
btn.connect("drag-data-received", self._on_drag_data_received, i)
self.listbox.pack_start(btn, False, False, 0)
self.listbox.show_all()
def _on_drag_data_get(self, widget, drag_context, data, info, time, idx):
data.set_text(str(idx), -1)
def _on_drag_data_received(self, widget, drag_context, x, y, data, info, time, target_idx):
text = data.get_text()
if not text or not text.isdigit():
return
source_idx = int(text)
if source_idx == target_idx or source_idx < 0 or source_idx >= len(self.cfg.workflows):
return
wfs = self.cfg.workflows
wf = wfs.pop(source_idx)
wfs.insert(target_idx, wf)
save(self.cfg)
self._refresh_listbox()
# -- startup --------------------------------------------------------------
def _startup(self) -> None:
try:
self.daemon.prepare()
self.daemon.start_input()
except Exception as exc: # noqa: BLE001
GLib.idle_add(self._apply_status, "error", None, f"Startup failed: {exc}")
# -- interaction ----------------------------------------------------------
def on_row_click(self, wf) -> None:
if not self.daemon.ready or self.daemon._busy:
return
if self._active and wf.name != self._active:
return
self.trigger_workflow(wf)
def trigger_workflow(self, wf) -> None:
if not self.daemon.ready:
return
threading.Thread(target=lambda: self.daemon.toggle(wf), daemon=True).start()
# -- overlay feedback -----------------------------------------------------
def _on_level(self, level: float) -> None:
if self.overlay is not None:
self.overlay.set_level(level)
def _on_text(self, text: str) -> None:
if self.overlay is not None:
self.overlay.set_text(text)
def _on_countdown(self, remaining: float | None, total: float) -> None:
if self.overlay is not None:
self.overlay.set_countdown(remaining, total)
def _on_routing(self, icon: str, name: str, keyword: str | None) -> None:
if self.overlay is not None:
self.overlay.set_preset(icon, name, keyword)
def _overlay_status(self, state: str, message: str) -> None:
"""Translate engine phases into overlay show/update/hide (GTK thread)."""
ov = self.overlay
if ov is None:
return
if state in ("recording", "streaming"):
self._ov_streaming = state == "streaming"
self._ov_state = state
ov.show(state, getattr(self.daemon, "_target_window", None))
elif state == "busy":
self._ov_state = state
# The routing detail ("→ Nicer email (matched: …)") is shown on the
# preset banner, not as a phase chip — keep the chip a clean phase word.
phase = "Transcribing…" if message.startswith("") else message
ov.set_state("busy", phase)
elif state == "done":
# Non-streaming: the 'done' message carries the final text. Streaming
# already showed it live, so don't overwrite with "Streaming stopped".
if not self._ov_streaming and message:
ov.set_text(message)
self._ov_state = state
ov.set_state("done", message)
elif state == "error":
self._ov_state = state
ov.set_state("error", message)
elif state == "idle":
# Only when a session was actually live — and never clip a 'done'
# linger (idle is emitted right after done in the worker's finally).
if self._ov_state in ("recording", "streaming", "busy"):
ov.set_state("idle", message)
self._ov_state = "idle"
# -- status (marshalled to GTK thread) ------------------------------------
def _status_cb(self, state: str, workflow: str | None, message: str) -> None:
GLib.idle_add(self._apply_status, state, workflow, message)
def _set_dot(self, color: str) -> None:
self.dot.set_markup(f'<span foreground="{color}">●</span>')
def _apply_status(self, state: str, workflow: str | None, message: str) -> bool:
colors = {"loading": "#ff9f0a", "idle": "#34c759", "recording": "#ff3b30",
"streaming": "#ff3b30", "busy": "#ff9f0a", "done": "#34c759", "error": "#ff3b30"}
labels = {"loading": "Loading…", "idle": "Ready", "recording": "Recording",
"streaming": "Live", "busy": "Working…", "done": "Ready", "error": message[:40] or "Error"}
self._set_dot(colors.get(state, "#7b818b"))
self.status_lbl.set_text(labels.get(state, message))
self._overlay_status(state, message)
if self.tray is not None:
self.tray.update_status(state, labels.get(state, message))
def cls(name, add, *names):
ctx = self._rows[name]["btn"].get_style_context()
for n in names:
ctx.remove_class(n)
if add:
ctx.add_class(add)
if state in ("recording", "streaming"):
self._active = workflow
for nm, r in self._rows.items():
if nm == workflow:
cls(nm, "recording", "dim")
r["pill"].set_text("● Live" if state == "streaming" else "● Stop")
r["pill"].get_style_context().add_class("rec")
else:
cls(nm, "dim", "recording")
elif state == "busy":
for nm in self._rows:
if nm != workflow:
cls(nm, "dim", "recording")
elif state in ("idle", "done", "error"):
self._active = None
for nm, r in self._rows.items():
cls(nm, None, "dim", "recording")
r["pill"].get_style_context().remove_class("rec")
wf_match = next((w for w in self.cfg.workflows if w.name == nm), None)
r["pill"].set_text(pretty_hotkey(wf_match.hotkey) if wf_match else "")
return False # one-shot idle
# -- panel / settings / lifecycle -----------------------------------------
def open_settings(self) -> None:
from .gtksettings import SettingsDialog
SettingsDialog(self.win, self.cfg, daemon=self.daemon).run_dialog()
def show_panel(self) -> None:
self.win.show_all()
self.win.present()
def hide_panel(self) -> None:
self.win.hide()
def _on_delete(self, *_a) -> bool:
if self.tray is not None:
self.hide_panel()
return True # keep running in tray
self.quit_all()
return False
def quit_all(self) -> None:
try:
self.daemon.stop_input()
if self.overlay is not None:
self.overlay.destroy()
from . import caret
caret.stop_tracking()
finally:
Gtk.main_quit()
# -- run ------------------------------------------------------------------
def run(self) -> None:
if self.tray_mode:
from . import tray as tray_mod
self.tray = tray_mod.Tray(self)
# stay in the tray; panel opens on demand
else:
self.win.show_all()
Gtk.main()
def run_gui(tray_mode: bool = False) -> int:
cfg = load()
App(cfg, tray_mode=tray_mode).run()
return 0