"""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", "": "Alt", "": "Shift", "": "Super", "": "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'') 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