diff --git a/linux/blitztext/__main__.py b/linux/blitztext/__main__.py index c928f65..b749e75 100644 --- a/linux/blitztext/__main__.py +++ b/linux/blitztext/__main__.py @@ -31,7 +31,7 @@ def main(argv: list[str] | None = None) -> int: if cmd in ("gui", "tray"): ensure_default(CONFIG_PATH) - from .gui import run_gui + from .gtkui import run_gui return run_gui(tray_mode=(cmd == "tray")) diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py new file mode 100644 index 0000000..7ffcf1e --- /dev/null +++ b/linux/blitztext/gtksettings.py @@ -0,0 +1,194 @@ +"""GTK settings dialog for Blitztext. + +A functional editor for the engine, rewrite endpoint, and per-workflow prompts. +The richer presets manager + remote-engine controls build on this next. +""" + +from __future__ import annotations + +import os +import sys + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa: E402 + +from .config import Config, save # noqa: E402 + +RESP_SAVE = 1 +RESP_SAVE_RESTART = 2 + + +def _row(parent: Gtk.Box, label: str) -> Gtk.Box: + box = Gtk.Box(spacing=10) + box.set_margin_top(4) + box.set_margin_bottom(4) + lbl = Gtk.Label(label=label, xalign=0.0) + lbl.set_size_request(150, -1) + box.pack_start(lbl, False, False, 0) + parent.pack_start(box, False, False, 0) + return box + + +def _entry(parent: Gtk.Box, label: str, value) -> Gtk.Entry: + box = _row(parent, label) + e = Gtk.Entry() + e.set_text(str(value)) + e.set_hexpand(True) + box.pack_start(e, True, True, 0) + return e + + +def _combo(parent: Gtk.Box, label: str, value: str, options: list[str]) -> Gtk.ComboBoxText: + box = _row(parent, label) + c = Gtk.ComboBoxText() + for i, o in enumerate(options): + c.append_text(o) + if o == value: + c.set_active(i) + if c.get_active() < 0: + c.set_active(0) + box.pack_start(c, False, False, 0) + return c + + +def _page(nb: Gtk.Notebook, title: str) -> Gtk.Box: + page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + page.set_margin_top(12) + page.set_margin_bottom(12) + page.set_margin_start(14) + page.set_margin_end(14) + nb.append_page(page, Gtk.Label(label=title)) + return page + + +class SettingsDialog: + def __init__(self, parent: Gtk.Window, cfg: Config): + self.cfg = cfg + self.dlg = Gtk.Dialog(title="Blitztext — Settings", transient_for=parent, modal=True) + self.dlg.set_default_size(580, 580) + self.dlg.add_button("Close", Gtk.ResponseType.CLOSE) + self.dlg.add_button("Save", RESP_SAVE) + self.dlg.add_button("Save & Restart", RESP_SAVE_RESTART) + + nb = Gtk.Notebook() + self.dlg.get_content_area().pack_start(nb, True, True, 0) + + # Engine + p = _page(nb, "Engine") + self.e_model = _entry(p, "Whisper model", cfg.model) + self.e_device = _combo(p, "Device", cfg.device, ["auto", "cpu", "cuda"]) + self.e_compute = _combo(p, "Compute type", cfg.compute_type, ["auto", "int8", "float16", "int8_float16"]) + self.e_lang = _entry(p, "Language hint", cfg.language) + self.e_output = _combo(p, "Output", cfg.output, ["type", "paste"]) + self.e_delay = _entry(p, "Type delay (ms)", cfg.type_delay_ms) + nbox = _row(p, "Notifications") + self.e_notify = Gtk.Switch() + self.e_notify.set_active(cfg.notify) + self.e_notify.set_halign(Gtk.Align.START) + nbox.pack_start(self.e_notify, False, False, 0) + + # Rewrite + p = _page(nb, "Rewrite LLM") + self.r_url = _entry(p, "Base URL", cfg.base_url) + self.r_keyenv = _entry(p, "API key env var", cfg.api_key_env) + self.r_model = _entry(p, "Model", cfg.rewrite_model) + self.r_temp = _entry(p, "Temperature", cfg.temperature) + self.r_timeout = _entry(p, "Timeout (s)", cfg.timeout) + hint = Gtk.Label(xalign=0.0, wrap=True) + hint.set_markup( + f'OpenAI-compatible endpoint (OpenAI, vLLM, llama-swap…). ' + f'Env {cfg.api_key_env}: ' + f'{"set ✓" if cfg.api_key else "NOT set"}.' + ) + p.pack_start(hint, False, False, 8) + + # Per-workflow prompts + self.wf_widgets: list[dict] = [] + for wf in cfg.workflows: + p = _page(nb, wf.name[:14]) + w = { + "name": _entry(p, "Name", wf.name), + "description": _entry(p, "Description", wf.description), + "hotkey": _entry(p, "Hotkey", wf.hotkey), + "model": _entry(p, "Model (opt.)", wf.model or ""), + "temperature": _entry(p, "Temp (opt.)", "" if wf.temperature is None else wf.temperature), + "mode": _combo(p, "Mode", wf.mode, ["transcribe", "rewrite"]), + } + p.pack_start(Gtk.Label(label="Rewrite prompt (system):", xalign=0.0), False, False, 6) + sw = Gtk.ScrolledWindow() + sw.set_min_content_height(150) + sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + tv = Gtk.TextView() + tv.set_wrap_mode(Gtk.WrapMode.WORD) + tv.get_buffer().set_text(wf.prompt) + sw.add(tv) + p.pack_start(sw, True, True, 0) + w["prompt"] = tv + self.wf_widgets.append(w) + + self.dlg.connect("response", self._on_response) + + # -- helpers -------------------------------------------------------------- + @staticmethod + def _tv_text(tv: Gtk.TextView) -> str: + b = tv.get_buffer() + return b.get_text(b.get_start_iter(), b.get_end_iter(), True).strip() + + def _collect(self) -> bool: + try: + c = self.cfg + c.model = self.e_model.get_text().strip() + c.device = self.e_device.get_active_text() + c.compute_type = self.e_compute.get_active_text() + c.language = self.e_lang.get_text().strip() + c.output = self.e_output.get_active_text() + c.type_delay_ms = int(self.e_delay.get_text()) + c.notify = self.e_notify.get_active() + c.base_url = self.r_url.get_text().strip().rstrip("/") + c.api_key_env = self.r_keyenv.get_text().strip() + c.rewrite_model = self.r_model.get_text().strip() + c.temperature = float(self.r_temp.get_text()) + c.timeout = int(self.r_timeout.get_text()) + for wf, w in zip(c.workflows, self.wf_widgets): + wf.name = w["name"].get_text().strip() or wf.name + wf.description = w["description"].get_text().strip() + wf.hotkey = w["hotkey"].get_text().strip() + wf.model = w["model"].get_text().strip() or None + t = w["temperature"].get_text().strip() + wf.temperature = float(t) if t else None + wf.mode = w["mode"].get_active_text() + wf.prompt = self._tv_text(w["prompt"]) + except ValueError as exc: + self._error(f"Check numeric fields: {exc}") + return False + return True + + def _error(self, msg: str) -> None: + d = Gtk.MessageDialog(transient_for=self.dlg, modal=True, + message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=msg) + d.run() + d.destroy() + + def _info(self, msg: str) -> None: + d = Gtk.MessageDialog(transient_for=self.dlg, modal=True, + message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, text=msg) + d.run() + d.destroy() + + def _on_response(self, dlg: Gtk.Dialog, resp: int) -> None: + if resp == RESP_SAVE: + if self._collect(): + save(self.cfg) + self._info("Saved. Restart Blitztext to apply hotkey/model changes.") + return + if resp == RESP_SAVE_RESTART: + if self._collect(): + save(self.cfg) + os.execv(sys.executable, [sys.executable, "-m", "blitztext", "tray"]) + return + dlg.destroy() + + def run_dialog(self) -> None: + self.dlg.show_all() diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py new file mode 100644 index 0000000..72c6f40 --- /dev/null +++ b/linux/blitztext/gtkui.py @@ -0,0 +1,296 @@ +"""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/scalable/apps/blitztext.svg", + str(Path(__file__).resolve().parent.parent / "packaging" / "blitztext.svg"), +] + +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.daemon = Daemon(cfg, status_cb=self._status_cb) + self._rows: dict[str, dict] = {} + self._active: str | None = 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") + listbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + listbox.set_margin_top(10) + listbox.set_margin_bottom(6) + listbox.set_margin_start(10) + listbox.set_margin_end(10) + for i, wf in enumerate(self.cfg.workflows): + listbox.pack_start(self._make_row(i, wf), False, False, 0) + outer.pack_start(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.connect("clicked", lambda _b, w=wf: self.on_row_click(w)) + self._rows[wf.name] = {"btn": btn, "pill": pill} + return btn + + # -- startup -------------------------------------------------------------- + def _startup(self) -> None: + try: + self.daemon.prepare() + self.daemon.start_hotkeys() + 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() + + # -- 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", + "busy": "#ff9f0a", "done": "#34c759", "error": "#ff3b30"} + labels = {"loading": "Loading…", "idle": "Ready", "recording": "Recording", + "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)) + + 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 == "recording": + self._active = workflow + for nm, r in self._rows.items(): + if nm == workflow: + cls(nm, "recording", "dim") + r["pill"].set_text("● 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") + r["pill"].set_text(pretty_hotkey(next(w.hotkey for w in self.cfg.workflows if w.name == nm))) + return False # one-shot idle + + # -- panel / settings / lifecycle ----------------------------------------- + def open_settings(self) -> None: + from .gtksettings import SettingsDialog + + SettingsDialog(self.win, self.cfg).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_hotkeys() + 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 diff --git a/linux/blitztext/gui.py b/linux/blitztext/gui.py deleted file mode 100644 index 242af9e..0000000 --- a/linux/blitztext/gui.py +++ /dev/null @@ -1,472 +0,0 @@ -"""tkinter control panel for Blitztext — a Linux analogue of the macOS menu bar. - -A minimal, flat design: clickable workflow rows with hover, a single status dot, -and the Ubuntu font throughout. Global hotkeys keep working in the background, so -the panel is optional once it's running. -""" - -from __future__ import annotations - -import os -import queue -import sys -import threading -import tkinter as tk -import tkinter.font as tkfont -from tkinter import messagebox, ttk - -from . import __version__ -from .config import Config, load, save -from .daemon import Daemon - -# --- palette (light, minimal) ------------------------------------------------ -WIN = "#ffffff" -TEXT = "#1a1a1c" -SUBTLE = "#8e8e93" -FAINT = "#b8b8be" -HOVER = "#f5f5f7" -LINE = "#ececee" -ACCENT = "#0a84ff" -GREEN = "#34c759" -RED = "#ff3b30" -AMBER = "#ff9f0a" - -DOT = {"loading": AMBER, "idle": GREEN, "recording": RED, "busy": AMBER, "done": GREEN, "error": RED} -DOT_LABEL = {"loading": "Loading…", "idle": "Ready", "recording": "Recording", - "busy": "Working…", "done": "Ready", "error": "Error"} - -_FONT = "TkDefaultFont" -_MONO = "TkFixedFont" - - -def _pick_fonts(root: tk.Tk) -> None: - """Apply the nicest available UI/mono fonts to the default named fonts.""" - global _FONT, _MONO - fams = set(tkfont.families(root)) - for f in ("Ubuntu", "Cantarell", "Noto Sans", "DejaVu Sans"): - if f in fams: - _FONT = f - break - for f in ("Ubuntu Mono", "DejaVu Sans Mono", "Noto Sans Mono"): - if f in fams: - _MONO = f - break - if _FONT != "TkDefaultFont": - for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont"): - try: - tkfont.nametofont(name).configure(family=_FONT, size=11) - except tk.TclError: - pass - - -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) - - -class Row: - """One clickable workflow row.""" - - def __init__(self, app: "BlitztextGUI", parent: tk.Widget, wf): - self.app = app - self.wf = wf - self.dimmed = False - - self.frame = tk.Frame(parent, bg=WIN, cursor="hand2") - self.frame.pack(fill="x") - self.strip = tk.Frame(self.frame, bg=WIN, width=3) - self.strip.pack(side="left", fill="y") - - body = tk.Frame(self.frame, bg=WIN) - body.pack(side="left", fill="x", expand=True, padx=(18, 16), pady=11) - - top = tk.Frame(body, bg=WIN) - top.pack(fill="x") - self.name = tk.Label(top, text=wf.name, font=(_FONT, 13), bg=WIN, fg=TEXT, anchor="w") - self.name.pack(side="left") - self.hint = tk.Label(top, text=pretty_hotkey(wf.hotkey), font=(_MONO, 9), bg=WIN, fg=FAINT, anchor="e") - self.hint.pack(side="right") - - self.desc = None - if wf.description: - self.desc = tk.Label(body, text=wf.description, font=(_FONT, 9), bg=WIN, fg=SUBTLE, anchor="w") - self.desc.pack(fill="x") - - self._widgets = [self.frame, body, top, self.name, self.hint] + ([self.desc] if self.desc else []) - for w in self._widgets: - w.bind("", lambda _e: self.app.on_row_click(self)) - w.bind("", lambda _e: self._hover(True)) - w.bind("", lambda _e: self._hover(False)) - - def _paint(self, bg: str) -> None: - for w in self._widgets: - w.configure(bg=bg) - - def _hover(self, on: bool) -> None: - if self.dimmed or not self.app.daemon.ready: - return - self._paint(HOVER if on else WIN) - self.strip.configure(bg=HOVER if on else WIN) - - def set_idle(self) -> None: - self.dimmed = False - self._paint(WIN) - self.strip.configure(bg=WIN) - self.name.configure(fg=TEXT) - if self.desc: - self.desc.configure(fg=SUBTLE) - self.hint.configure(text=pretty_hotkey(self.wf.hotkey), fg=FAINT) - - def set_recording(self) -> None: - self.dimmed = False - self._paint(WIN) - self.strip.configure(bg=RED) - self.name.configure(fg=TEXT) - if self.desc: - self.desc.configure(fg=SUBTLE) - self.hint.configure(text="● Stop", fg=RED) - - def set_busy(self) -> None: - self.hint.configure(text="Working…", fg=AMBER) - - def set_dimmed(self) -> None: - self.dimmed = True - self._paint(WIN) - self.strip.configure(bg=WIN) - self.name.configure(fg=FAINT) - if self.desc: - self.desc.configure(fg=FAINT) - self.hint.configure(fg=FAINT) - - -class BlitztextGUI: - def __init__(self, cfg: Config, tray_mode: bool = False): - self.cfg = cfg - self.tray_mode = tray_mode - self.tray = None - self.daemon = Daemon(cfg, status_cb=self._status_cb) - self._events: queue.Queue = queue.Queue() - self._rows: list[Row] = [] - self._active: str | None = None - - self.root = tk.Tk() - self.root.title("Blitztext") - self.root.configure(bg=WIN) - _pick_fonts(self.root) - self.root.minsize(380, 320) - - self._build_header() - tk.Frame(self.root, bg=LINE, height=1).pack(fill="x", padx=22) - self._build_rows() - tk.Frame(self.root, bg=LINE, height=1).pack(fill="x", padx=22) - self._build_footer() - - self.root.after(80, self._drain_events) - threading.Thread(target=self._startup, daemon=True).start() - self.root.protocol("WM_DELETE_WINDOW", self._on_close) - - # -- startup -------------------------------------------------------------- - def _startup(self) -> None: - try: - self.daemon.prepare() - self.daemon.start_hotkeys() - except Exception as exc: # noqa: BLE001 - self._events.put(("error", None, f"Startup failed: {exc}")) - - # -- layout --------------------------------------------------------------- - def _build_header(self) -> None: - head = tk.Frame(self.root, bg=WIN) - head.pack(fill="x", padx=22, pady=(20, 14)) - tk.Label(head, text="Blitztext", font=(_FONT, 17), bg=WIN, fg=TEXT).pack(side="left") - wrap = tk.Frame(head, bg=WIN) - wrap.pack(side="right") - self.dot = tk.Label(wrap, text="●", font=(_FONT, 10), bg=WIN, fg=AMBER) - self.dot.pack(side="left", padx=(0, 5)) - self.status_label = tk.Label(wrap, text="Starting…", font=(_FONT, 11), bg=WIN, fg=SUBTLE) - self.status_label.pack(side="left") - - def _build_rows(self) -> None: - body = tk.Frame(self.root, bg=WIN) - body.pack(fill="both", expand=True, padx=8, pady=6) - for wf in self.cfg.workflows: - self._rows.append(Row(self, body, wf)) - - def _build_footer(self) -> None: - foot = tk.Frame(self.root, bg=WIN) - foot.pack(fill="x", padx=22, pady=(12, 16)) - tk.Label(foot, text=f"v{__version__}", font=(_FONT, 9), bg=WIN, fg=FAINT).pack(side="left") - self._text_button(foot, "Quit", self.quit_all).pack(side="right") - self._text_button(foot, "Settings", self.open_settings).pack(side="right", padx=(0, 18)) - - def _text_button(self, parent, label, cmd) -> tk.Label: - b = tk.Label(parent, text=label, font=(_FONT, 11), bg=WIN, fg=ACCENT, cursor="hand2") - b.bind("", lambda _e: cmd()) - b.bind("", lambda _e: b.configure(fg=TEXT)) - b.bind("", lambda _e: b.configure(fg=ACCENT)) - return b - - # -- interaction ---------------------------------------------------------- - def on_row_click(self, row: Row) -> None: - if not self.daemon.ready or self.daemon._busy: - return - if self._active and row.wf.name != self._active: - return # another workflow is recording - self.trigger_workflow(row.wf) - - def trigger_workflow(self, wf) -> None: - if not self.daemon.ready: - return - threading.Thread(target=lambda: self.daemon.toggle(wf), daemon=True).start() - - # -- status plumbing (thread-safe via queue) ------------------------------ - def _status_cb(self, state: str, workflow: str | None, message: str) -> None: - self._events.put((state, workflow, message)) - - def _drain_events(self) -> None: - try: - while True: - self._apply_status(*self._events.get_nowait()) - except queue.Empty: - pass - self.root.after(80, self._drain_events) - - def _apply_status(self, state: str, workflow: str | None, message: str) -> None: - label = DOT_LABEL.get(state, message) or message - self.dot.configure(fg=DOT.get(state, SUBTLE)) - self.status_label.configure(text=label) - if self.tray is not None: - self.tray.update_status(state, label) - - if state == "recording": - self._active = workflow - for r in self._rows: - r.set_recording() if r.wf.name == workflow else r.set_dimmed() - elif state == "busy": - for r in self._rows: - if r.wf.name == workflow: - r.set_busy() - else: - r.set_dimmed() - elif state == "loading": - for r in self._rows: - r.set_dimmed() - else: # idle / done / error - self._active = None - for r in self._rows: - r.set_idle() - if state == "error" and message: - self.status_label.configure(text=message[:42], fg=RED) - - # -- settings / panel ----------------------------------------------------- - def open_settings(self) -> None: - SettingsWindow(self.root, self.cfg) - - def show_panel(self) -> None: - self.root.deiconify() - self.root.lift() - self.root.focus_force() - - def hide_panel(self) -> None: - self.root.withdraw() - - # -- lifecycle ------------------------------------------------------------ - def _on_close(self) -> None: - if self.tray is not None: - self.hide_panel() - else: - self.quit_all() - - def quit_all(self) -> None: - try: - self.daemon.stop_hotkeys() - finally: - self.root.destroy() - - def _pump_gtk(self) -> None: - if self.tray is not None: - self.tray.pump() - self.root.after(50, self._pump_gtk) - - def run(self) -> None: - if self.tray_mode: - from . import tray as tray_mod - - if tray_mod.gi_available(): - self.tray = tray_mod.Tray(self) - self.root.withdraw() - self._pump_gtk() - else: - print(tray_mod.INSTALL_HINT, file=sys.stderr) - self.tray_mode = False - self.root.mainloop() - - -class SettingsWindow: - """Edit config and save. Hotkey/model changes apply after a restart.""" - - def __init__(self, parent: tk.Misc, cfg: Config): - self.cfg = cfg - self.win = tk.Toplevel(parent) - self.win.title("Blitztext — Settings") - self.win.configure(bg=WIN) - self.win.minsize(560, 500) - - style = ttk.Style(self.win) - try: - style.configure("TNotebook", background=WIN, borderwidth=0) - style.configure("TNotebook.Tab", padding=(14, 7), font=(_FONT, 10)) - except tk.TclError: - pass - - nb = ttk.Notebook(self.win) - nb.pack(fill="both", expand=True, padx=14, pady=14) - - self.vars: dict[str, tk.Variable] = {} - self._build_general(nb) - self._build_rewrite(nb) - self.prompt_texts: dict[int, tk.Text] = {} - self.wf_vars: dict[int, dict[str, tk.Variable]] = {} - for idx, wf in enumerate(cfg.workflows): - self._build_workflow_tab(nb, idx, wf) - - bar = tk.Frame(self.win, bg=WIN) - bar.pack(fill="x", padx=14, pady=(0, 14)) - save_btn = tk.Label(bar, text="Save & Restart", font=(_FONT, 11), bg=ACCENT, fg="white", - padx=14, pady=7, cursor="hand2") - save_btn.bind("", lambda _e: self._save_restart()) - save_btn.pack(side="right") - save2 = tk.Label(bar, text="Save", font=(_FONT, 11), bg=HOVER, fg=TEXT, padx=14, pady=7, cursor="hand2") - save2.bind("", lambda _e: self._save()) - save2.pack(side="right", padx=8) - tk.Label(bar, text="Hotkey & model changes apply after restart.", - font=(_FONT, 9), bg=WIN, fg=SUBTLE).pack(side="left") - - def _field(self, parent, label, value, key): - frame = tk.Frame(parent, bg=WIN) - frame.pack(fill="x", padx=16, pady=6) - tk.Label(frame, text=label, font=(_FONT, 10), bg=WIN, fg=TEXT, width=16, anchor="w").pack(side="left") - var = tk.StringVar(value=str(value)) - self.vars[key] = var - tk.Entry(frame, textvariable=var, font=(_FONT, 10), relief="solid", bd=1, - highlightthickness=0).pack(side="left", fill="x", expand=True, ipady=3) - return var - - def _combo(self, parent, label, value, options, key): - frame = tk.Frame(parent, bg=WIN) - frame.pack(fill="x", padx=16, pady=6) - tk.Label(frame, text=label, font=(_FONT, 10), bg=WIN, fg=TEXT, width=16, anchor="w").pack(side="left") - var = tk.StringVar(value=value) - self.vars[key] = var - ttk.Combobox(frame, textvariable=var, values=options, state="readonly", width=18).pack(side="left") - return var - - def _build_general(self, nb): - tab = tk.Frame(nb, bg=WIN) - nb.add(tab, text="Engine") - self._field(tab, "Whisper model", self.cfg.model, "model") - self._combo(tab, "Device", self.cfg.device, ["auto", "cpu", "cuda"], "device") - self._combo(tab, "Compute type", self.cfg.compute_type, ["auto", "int8", "float16", "int8_float16"], "compute_type") - self._field(tab, "Language hint", self.cfg.language, "language") - self._combo(tab, "Output", self.cfg.output, ["type", "paste"], "output") - self._field(tab, "Type delay (ms)", self.cfg.type_delay_ms, "type_delay_ms") - nv = tk.BooleanVar(value=self.cfg.notify) - self.vars["notify"] = nv - f = tk.Frame(tab, bg=WIN) - f.pack(fill="x", padx=16, pady=6) - tk.Checkbutton(f, text="Desktop notifications", variable=nv, bg=WIN, fg=TEXT, - font=(_FONT, 10), activebackground=WIN, selectcolor=WIN).pack(side="left") - - def _build_rewrite(self, nb): - tab = tk.Frame(nb, bg=WIN) - nb.add(tab, text="Rewrite LLM") - self._field(tab, "Base URL", self.cfg.base_url, "base_url") - self._field(tab, "API key env var", self.cfg.api_key_env, "api_key_env") - self._field(tab, "Model", self.cfg.rewrite_model, "rewrite_model") - self._field(tab, "Temperature", self.cfg.temperature, "temperature") - self._field(tab, "Timeout (s)", self.cfg.timeout, "timeout") - present = "set ✓" if self.cfg.api_key else "NOT set" - tk.Label(tab, text=f"OpenAI-compatible endpoint (OpenAI, vLLM, llama-swap…). " - f"Env {self.cfg.api_key_env}: {present}.", - font=(_FONT, 9), bg=WIN, fg=SUBTLE, wraplength=500, justify="left").pack(fill="x", padx=16, pady=(10, 0)) - - def _build_workflow_tab(self, nb, idx, wf): - tab = tk.Frame(nb, bg=WIN) - nb.add(tab, text=wf.name[:14]) - v: dict[str, tk.Variable] = {} - self.wf_vars[idx] = v - for label, key, val in [("Name", "name", wf.name), - ("Description", "description", wf.description), - ("Hotkey", "hotkey", wf.hotkey), - ("Model (opt.)", "model", wf.model or ""), - ("Temp (opt.)", "temperature", "" if wf.temperature is None else wf.temperature)]: - frame = tk.Frame(tab, bg=WIN) - frame.pack(fill="x", padx=16, pady=5) - tk.Label(frame, text=label, font=(_FONT, 10), bg=WIN, fg=TEXT, width=14, anchor="w").pack(side="left") - sv = tk.StringVar(value=str(val)) - v[key] = sv - tk.Entry(frame, textvariable=sv, font=(_FONT, 10), relief="solid", bd=1, - highlightthickness=0).pack(side="left", fill="x", expand=True, ipady=3) - - mframe = tk.Frame(tab, bg=WIN) - mframe.pack(fill="x", padx=16, pady=5) - tk.Label(mframe, text="Mode", font=(_FONT, 10), bg=WIN, fg=TEXT, width=14, anchor="w").pack(side="left") - mv = tk.StringVar(value=wf.mode) - v["mode"] = mv - ttk.Combobox(mframe, textvariable=mv, values=["transcribe", "rewrite"], state="readonly", width=14).pack(side="left") - - tk.Label(tab, text="Rewrite prompt (system):", font=(_FONT, 10), bg=WIN, fg=TEXT, anchor="w").pack(fill="x", padx=16, pady=(10, 2)) - txt = tk.Text(tab, height=9, wrap="word", font=(_FONT, 10), relief="solid", bd=1, highlightthickness=0) - txt.insert("1.0", wf.prompt) - txt.pack(fill="both", expand=True, padx=16, pady=(0, 12)) - self.prompt_texts[idx] = txt - - def _collect(self) -> bool: - try: - self.cfg.model = self.vars["model"].get().strip() - self.cfg.device = self.vars["device"].get() - self.cfg.compute_type = self.vars["compute_type"].get() - self.cfg.language = self.vars["language"].get().strip() - self.cfg.output = self.vars["output"].get() - self.cfg.type_delay_ms = int(self.vars["type_delay_ms"].get()) - self.cfg.notify = bool(self.vars["notify"].get()) - self.cfg.base_url = self.vars["base_url"].get().strip().rstrip("/") - self.cfg.api_key_env = self.vars["api_key_env"].get().strip() - self.cfg.rewrite_model = self.vars["rewrite_model"].get().strip() - self.cfg.temperature = float(self.vars["temperature"].get()) - self.cfg.timeout = int(self.vars["timeout"].get()) - for idx, wf in enumerate(self.cfg.workflows): - v = self.wf_vars[idx] - wf.name = v["name"].get().strip() or wf.name - wf.description = v["description"].get().strip() - wf.hotkey = v["hotkey"].get().strip() - wf.mode = v["mode"].get() - wf.model = v["model"].get().strip() or None - temp = v["temperature"].get().strip() - wf.temperature = float(temp) if temp else None - wf.prompt = self.prompt_texts[idx].get("1.0", "end").strip() - except ValueError as exc: - messagebox.showerror("Invalid value", f"Check numeric fields: {exc}", parent=self.win) - return False - return True - - def _save(self) -> None: - if not self._collect(): - return - save(self.cfg) - messagebox.showinfo("Saved", "Settings saved. Restart Blitztext to apply hotkey/model changes.", parent=self.win) - - def _save_restart(self) -> None: - if not self._collect(): - return - save(self.cfg) - os.execv(sys.executable, [sys.executable, "-m", "blitztext", "tray"]) - - -def run_gui(tray_mode: bool = False) -> int: - cfg = load() - BlitztextGUI(cfg, tray_mode=tray_mode).run() - return 0 diff --git a/linux/blitztext/tray.py b/linux/blitztext/tray.py index 37c02bc..c430ec7 100644 --- a/linux/blitztext/tray.py +++ b/linux/blitztext/tray.py @@ -71,7 +71,7 @@ class Tray: self._build_menu() def _build_menu(self) -> None: - from .gui import pretty_hotkey + from .gtkui import pretty_hotkey Gtk = self._Gtk menu = Gtk.Menu()