blitztext-app-linux/linux/blitztext/gtksettings.py
mARTin-B78 3cbea06e6c 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>
2026-06-04 09:40:24 +02:00

195 lines
7.3 KiB
Python

"""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()