Rebuild GUI in GTK3 (native panel, unified with tray)
Replace the tkinter panel with a GTK3 control panel (gtkui.py) + settings dialog (gtksettings.py): rounded CSD window, gradient background, circular icon avatars, hotkey pills, hover states, Ubuntu font. Runs on the GTK main loop, unified with the AppIndicator tray (no tkinter event-loop pump). Status updates marshalled via GLib.idle_add. Removes the old tkinter gui.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
937619a20b
commit
3cbea06e6c
@ -31,7 +31,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
if cmd in ("gui", "tray"):
|
if cmd in ("gui", "tray"):
|
||||||
ensure_default(CONFIG_PATH)
|
ensure_default(CONFIG_PATH)
|
||||||
from .gui import run_gui
|
from .gtkui import run_gui
|
||||||
|
|
||||||
return run_gui(tray_mode=(cmd == "tray"))
|
return run_gui(tray_mode=(cmd == "tray"))
|
||||||
|
|
||||||
|
|||||||
194
linux/blitztext/gtksettings.py
Normal file
194
linux/blitztext/gtksettings.py
Normal file
@ -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'<small>OpenAI-compatible endpoint (OpenAI, vLLM, llama-swap…). '
|
||||||
|
f'Env <tt>{cfg.api_key_env}</tt>: '
|
||||||
|
f'{"set ✓" if cfg.api_key else "NOT set"}.</small>'
|
||||||
|
)
|
||||||
|
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()
|
||||||
296
linux/blitztext/gtkui.py
Normal file
296
linux/blitztext/gtkui.py
Normal file
@ -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>": "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.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'<span foreground="{color}">●</span>')
|
||||||
|
|
||||||
|
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
|
||||||
@ -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>": "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)
|
|
||||||
|
|
||||||
|
|
||||||
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("<Button-1>", lambda _e: self.app.on_row_click(self))
|
|
||||||
w.bind("<Enter>", lambda _e: self._hover(True))
|
|
||||||
w.bind("<Leave>", 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("<Button-1>", lambda _e: cmd())
|
|
||||||
b.bind("<Enter>", lambda _e: b.configure(fg=TEXT))
|
|
||||||
b.bind("<Leave>", 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("<Button-1>", 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("<Button-1>", 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
|
|
||||||
@ -71,7 +71,7 @@ class Tray:
|
|||||||
self._build_menu()
|
self._build_menu()
|
||||||
|
|
||||||
def _build_menu(self) -> None:
|
def _build_menu(self) -> None:
|
||||||
from .gui import pretty_hotkey
|
from .gtkui import pretty_hotkey
|
||||||
|
|
||||||
Gtk = self._Gtk
|
Gtk = self._Gtk
|
||||||
menu = Gtk.Menu()
|
menu = Gtk.Menu()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user