Redesign control panel UI (v1.0.1)

Minimal flat design with the Ubuntu font: clickable workflow rows with hover
(click to record/stop), muted descriptions and hotkey hints, subtle dividers,
and text-style Settings/Quit. Drops monogram avatars and per-row buttons.
Settings window restyled to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-03 22:53:38 +02:00
parent 83d4143e83
commit 658339d67c
3 changed files with 249 additions and 156 deletions

View File

@ -9,6 +9,15 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [Unreleased] ## [Unreleased]
## [1.0.1] - 2026-06-03
### Changed
- Redesigned the control-panel window: minimal flat layout, Ubuntu font
throughout, clickable workflow rows with hover (click to record / stop),
subtle dividers, and text-style Settings/Quit actions. Dropped the monogram
avatars and per-row buttons in favour of a cleaner, simpler look. The Settings
window picks up the same font and styling.
## [1.0.0] - 2026-06-03 ## [1.0.0] - 2026-06-03
First release of the Linux port. The upstream project is a macOS-only menu-bar First release of the Linux port. The upstream project is a macOS-only menu-bar
@ -49,5 +58,6 @@ into that field.
AppIndicator typelibs and GNOME `ubuntu-appindicators` extension are already AppIndicator typelibs and GNOME `ubuntu-appindicators` extension are already
present on the target host). present on the target host).
[Unreleased]: https://github.com/mARTin-B78/blitztext-app/compare/v1.0.0...HEAD [Unreleased]: https://github.com/mARTin-B78/blitztext-app/compare/v1.0.1...HEAD
[1.0.1]: https://github.com/mARTin-B78/blitztext-app/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/mARTin-B78/blitztext-app/releases/tag/v1.0.0 [1.0.0]: https://github.com/mARTin-B78/blitztext-app/releases/tag/v1.0.0

View File

@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host
(not in a container) so it can type into any application via xdotool. (not in a container) so it can type into any application via xdotool.
""" """
__version__ = "1.0.0" __version__ = "1.0.1"

View File

@ -1,9 +1,8 @@
"""tkinter control panel for Blitztext — a Linux analogue of the macOS menu bar. """tkinter control panel for Blitztext — a Linux analogue of the macOS menu bar.
Shows the workflow list with per-row Record buttons and a live status dot, plus A minimal, flat design: clickable workflow rows with hover, a single status dot,
a Settings window to edit hotkeys, the Whisper engine, the rewrite endpoint, and and the Ubuntu font throughout. Global hotkeys keep working in the background, so
each workflow's prompt. Global hotkeys keep working in the background, so the the panel is optional once it's running.
panel is optional once it's running.
""" """
from __future__ import annotations from __future__ import annotations
@ -13,44 +12,135 @@ import queue
import sys import sys
import threading import threading
import tkinter as tk import tkinter as tk
import tkinter.font as tkfont
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
from . import __version__ from . import __version__
from .config import Config, load, save from .config import Config, load, save
from .daemon import Daemon from .daemon import Daemon
# Light palette, loosely matching the macOS panel. # --- palette (light, minimal) ------------------------------------------------
BG = "#ececf1" WIN = "#ffffff"
CARD = "#ffffff" TEXT = "#1a1a1c"
TEXT = "#1c1c1e" SUBTLE = "#8e8e93"
MUTED = "#8a8a8e" FAINT = "#b8b8be"
BADGE = "#e3e3e8" HOVER = "#f5f5f7"
DIVIDER = "#d8d8de" LINE = "#ececee"
AVATAR_COLORS = ["#0a84ff", "#34c759", "#ff9f0a", "#ff375f", "#bf5af2", "#5ac8fa"] ACCENT = "#0a84ff"
DOT = { GREEN = "#34c759"
"loading": "#ff9f0a", RED = "#ff3b30"
"idle": "#34c759", AMBER = "#ff9f0a"
"recording": "#ff3b30",
"busy": "#ff9f0a", DOT = {"loading": AMBER, "idle": GREEN, "recording": RED, "busy": AMBER, "done": GREEN, "error": RED}
"done": "#34c759", DOT_LABEL = {"loading": "Loading…", "idle": "Ready", "recording": "Recording",
"error": "#ff3b30", "busy": "Working…", "done": "Ready", "error": "Error"}
}
DOT_LABEL = { _FONT = "TkDefaultFont"
"loading": "Loading model…", _MONO = "TkFixedFont"
"idle": "Ready",
"recording": "Recording…",
"busy": "Working…", def _pick_fonts(root: tk.Tk) -> None:
"done": "Ready", """Apply the nicest available UI/mono fonts to the default named fonts."""
"error": "Error", 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: def pretty_hotkey(hotkey: str) -> str:
names = {"<ctrl>": "Ctrl", "<alt>": "Alt", "<shift>": "Shift", "<cmd>": "Super", "<space>": "Space"} names = {"<ctrl>": "Ctrl", "<alt>": "Alt", "<shift>": "Shift", "<cmd>": "Super", "<space>": "Space"}
parts = [] parts = []
for raw in hotkey.split("+"): for raw in hotkey.split("+"):
parts.append(names.get(raw, raw.strip("<>").upper() if len(raw.strip("<>")) == 1 else raw.strip("<>").title())) inner = raw.strip("<>")
return "+".join(parts) 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: class BlitztextGUI:
@ -60,22 +150,22 @@ class BlitztextGUI:
self.tray = None self.tray = None
self.daemon = Daemon(cfg, status_cb=self._status_cb) self.daemon = Daemon(cfg, status_cb=self._status_cb)
self._events: queue.Queue = queue.Queue() self._events: queue.Queue = queue.Queue()
self._active_wf: str | None = None self._rows: list[Row] = []
self._row_buttons: dict[str, tk.Button] = {} self._active: str | None = None
self.root = tk.Tk() self.root = tk.Tk()
self.root.title("Blitztext") self.root.title("Blitztext")
self.root.configure(bg=BG) self.root.configure(bg=WIN)
self.root.minsize(420, 360) _pick_fonts(self.root)
self.root.minsize(380, 320)
self._build_header() self._build_header()
tk.Frame(self.root, bg=LINE, height=1).pack(fill="x", padx=22)
self._build_rows() self._build_rows()
tk.Frame(self.root, bg=LINE, height=1).pack(fill="x", padx=22)
self._build_footer() self._build_footer()
# Disable controls until the model is loaded.
self._set_buttons_enabled(False)
self.root.after(80, self._drain_events) self.root.after(80, self._drain_events)
# Load model + start hotkeys off the UI thread.
threading.Thread(target=self._startup, daemon=True).start() threading.Thread(target=self._startup, daemon=True).start()
self.root.protocol("WM_DELETE_WINDOW", self._on_close) self.root.protocol("WM_DELETE_WINDOW", self._on_close)
@ -87,78 +177,51 @@ class BlitztextGUI:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self._events.put(("error", None, f"Startup failed: {exc}")) self._events.put(("error", None, f"Startup failed: {exc}"))
# -- header --------------------------------------------------------------- # -- layout ---------------------------------------------------------------
def _build_header(self) -> None: def _build_header(self) -> None:
head = tk.Frame(self.root, bg=BG) head = tk.Frame(self.root, bg=WIN)
head.pack(fill="x", padx=16, pady=(14, 8)) head.pack(fill="x", padx=22, pady=(20, 14))
tk.Label(head, text="Blitztext", font=("", 15, "bold"), bg=BG, fg=TEXT).pack(side="left") 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")
status = tk.Frame(head, bg=BG)
status.pack(side="right")
self.dot = tk.Label(status, text="", font=("", 12), bg=BG, fg=DOT["loading"])
self.dot.pack(side="left")
self.status_label = tk.Label(status, text="Starting…", font=("", 11), bg=BG, fg=MUTED)
self.status_label.pack(side="left", padx=(4, 0))
# -- workflow rows --------------------------------------------------------
def _build_rows(self) -> None: def _build_rows(self) -> None:
body = tk.Frame(self.root, bg=CARD, highlightthickness=1, highlightbackground=DIVIDER) body = tk.Frame(self.root, bg=WIN)
body.pack(fill="both", expand=True, padx=12, pady=4) body.pack(fill="both", expand=True, padx=8, pady=6)
for i, wf in enumerate(self.cfg.workflows): for wf in self.cfg.workflows:
if i: self._rows.append(Row(self, body, wf))
tk.Frame(body, bg=DIVIDER, height=1).pack(fill="x", padx=12)
row = tk.Frame(body, bg=CARD)
row.pack(fill="x", padx=12, pady=8)
color = AVATAR_COLORS[i % len(AVATAR_COLORS)]
avatar = tk.Canvas(row, width=32, height=32, bg=CARD, highlightthickness=0)
avatar.create_oval(3, 3, 29, 29, fill=color, outline="")
avatar.create_text(16, 16, text=(wf.name[:1] or "?").upper(), fill="white", font=("", 13, "bold"))
avatar.pack(side="left", padx=(0, 10))
mid = tk.Frame(row, bg=CARD)
mid.pack(side="left", fill="x", expand=True)
tk.Label(mid, text=wf.name, font=("", 12, "bold"), bg=CARD, fg=TEXT, anchor="w").pack(fill="x")
sub = wf.description or ("Transcribe only" if wf.mode == "transcribe" else "Transcribe → rewrite")
tk.Label(mid, text=sub, font=("", 10), bg=CARD, fg=MUTED, anchor="w").pack(fill="x")
badge = tk.Label(row, text=pretty_hotkey(wf.hotkey), font=("", 9), bg=BADGE, fg=MUTED, padx=6, pady=2)
badge.pack(side="left", padx=8)
btn = tk.Button(
row, text="● Rec", font=("", 10, "bold"), width=7,
relief="flat", bg="#e8453c", fg="white", activebackground="#c93b33", activeforeground="white",
command=lambda w=wf: self._on_record(w),
)
btn.pack(side="right")
self._row_buttons[wf.name] = btn
# -- footer ---------------------------------------------------------------
def _build_footer(self) -> None: def _build_footer(self) -> None:
foot = tk.Frame(self.root, bg=BG) foot = tk.Frame(self.root, bg=WIN)
foot.pack(fill="x", padx=16, pady=(6, 12)) foot.pack(fill="x", padx=22, pady=(12, 16))
tk.Label(foot, text=f"recorder: {self.daemon.recorder_name} · v{__version__}", tk.Label(foot, text=f"v{__version__}", font=(_FONT, 9), bg=WIN, fg=FAINT).pack(side="left")
font=("", 9), bg=BG, fg=MUTED).pack(side="left") self._text_button(foot, "Quit", self.quit_all).pack(side="right")
tk.Button(foot, text="Quit", font=("", 10), relief="flat", bg=BADGE, fg=TEXT, self._text_button(foot, "Settings", self.open_settings).pack(side="right", padx=(0, 18))
command=self.quit_all).pack(side="right")
tk.Button(foot, text="Settings", font=("", 10), relief="flat", bg=BADGE, fg=TEXT,
command=self.open_settings).pack(side="right", padx=6)
# -- record button -------------------------------------------------------- def _text_button(self, parent, label, cmd) -> tk.Label:
def _on_record(self, wf) -> None: b = tk.Label(parent, text=label, font=(_FONT, 11), bg=WIN, fg=ACCENT, cursor="hand2")
self.trigger_workflow(wf) 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: def trigger_workflow(self, wf) -> None:
"""Start/stop a workflow (from a row button or the tray menu)."""
if not self.daemon.ready: if not self.daemon.ready:
return return
threading.Thread(target=lambda: self.daemon.toggle(wf), daemon=True).start() threading.Thread(target=lambda: self.daemon.toggle(wf), daemon=True).start()
def _set_buttons_enabled(self, enabled: bool) -> None:
state = "normal" if enabled else "disabled"
for btn in self._row_buttons.values():
btn.configure(state=state)
# -- status plumbing (thread-safe via queue) ------------------------------ # -- status plumbing (thread-safe via queue) ------------------------------
def _status_cb(self, state: str, workflow: str | None, message: str) -> None: def _status_cb(self, state: str, workflow: str | None, message: str) -> None:
self._events.put((state, workflow, message)) self._events.put((state, workflow, message))
@ -166,34 +229,37 @@ class BlitztextGUI:
def _drain_events(self) -> None: def _drain_events(self) -> None:
try: try:
while True: while True:
state, workflow, message = self._events.get_nowait() self._apply_status(*self._events.get_nowait())
self._apply_status(state, workflow, message)
except queue.Empty: except queue.Empty:
pass pass
self.root.after(80, self._drain_events) self.root.after(80, self._drain_events)
def _apply_status(self, state: str, workflow: str | None, message: str) -> None: def _apply_status(self, state: str, workflow: str | None, message: str) -> None:
label = DOT_LABEL.get(state, message) or message label = DOT_LABEL.get(state, message) or message
self.dot.configure(fg=DOT.get(state, MUTED)) self.dot.configure(fg=DOT.get(state, SUBTLE))
self.status_label.configure(text=label) self.status_label.configure(text=label)
if self.tray is not None: if self.tray is not None:
self.tray.update_status(state, label) self.tray.update_status(state, label)
if state == "recording": if state == "recording":
self._active_wf = workflow self._active = workflow
for name, btn in self._row_buttons.items(): for r in self._rows:
if name == workflow: r.set_recording() if r.wf.name == workflow else r.set_dimmed()
btn.configure(text="■ Stop", state="normal") elif state == "busy":
for r in self._rows:
if r.wf.name == workflow:
r.set_busy()
else: else:
btn.configure(state="disabled") r.set_dimmed()
elif state in ("busy", "loading"): elif state == "loading":
self._set_buttons_enabled(False) for r in self._rows:
elif state in ("idle", "done", "error"): r.set_dimmed()
self._active_wf = None else: # idle / done / error
for btn in self._row_buttons.values(): self._active = None
btn.configure(text="● Rec", state="normal") for r in self._rows:
r.set_idle()
if state == "error" and message: if state == "error" and message:
self.status_label.configure(text=message[:48]) self.status_label.configure(text=message[:42], fg=RED)
# -- settings / panel ----------------------------------------------------- # -- settings / panel -----------------------------------------------------
def open_settings(self) -> None: def open_settings(self) -> None:
@ -209,7 +275,6 @@ class BlitztextGUI:
# -- lifecycle ------------------------------------------------------------ # -- lifecycle ------------------------------------------------------------
def _on_close(self) -> None: def _on_close(self) -> None:
# In tray mode the window close button just hides to the tray.
if self.tray is not None: if self.tray is not None:
self.hide_panel() self.hide_panel()
else: else:
@ -232,7 +297,7 @@ class BlitztextGUI:
if tray_mod.gi_available(): if tray_mod.gi_available():
self.tray = tray_mod.Tray(self) self.tray = tray_mod.Tray(self)
self.root.withdraw() # live in the tray; panel opens on demand self.root.withdraw()
self._pump_gtk() self._pump_gtk()
else: else:
print(tray_mod.INSTALL_HINT, file=sys.stderr) print(tray_mod.INSTALL_HINT, file=sys.stderr)
@ -247,11 +312,18 @@ class SettingsWindow:
self.cfg = cfg self.cfg = cfg
self.win = tk.Toplevel(parent) self.win = tk.Toplevel(parent)
self.win.title("Blitztext — Settings") self.win.title("Blitztext — Settings")
self.win.configure(bg=BG) self.win.configure(bg=WIN)
self.win.minsize(560, 480) 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 = ttk.Notebook(self.win)
nb.pack(fill="both", expand=True, padx=10, pady=10) nb.pack(fill="both", expand=True, padx=14, pady=14)
self.vars: dict[str, tk.Variable] = {} self.vars: dict[str, tk.Variable] = {}
self._build_general(nb) self._build_general(nb)
@ -261,26 +333,39 @@ class SettingsWindow:
for idx, wf in enumerate(cfg.workflows): for idx, wf in enumerate(cfg.workflows):
self._build_workflow_tab(nb, idx, wf) self._build_workflow_tab(nb, idx, wf)
bar = tk.Frame(self.win, bg=BG) bar = tk.Frame(self.win, bg=WIN)
bar.pack(fill="x", padx=10, pady=(0, 10)) bar.pack(fill="x", padx=14, pady=(0, 14))
tk.Button(bar, text="Save & Restart", font=("", 10, "bold"), relief="flat", save_btn = tk.Label(bar, text="Save & Restart", font=(_FONT, 11), bg=ACCENT, fg="white",
bg="#0a84ff", fg="white", command=self._save_restart).pack(side="right") padx=14, pady=7, cursor="hand2")
tk.Button(bar, text="Save", font=("", 10), relief="flat", bg=BADGE, fg=TEXT, save_btn.bind("<Button-1>", lambda _e: self._save_restart())
command=self._save).pack(side="right", padx=6) 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.", tk.Label(bar, text="Hotkey & model changes apply after restart.",
font=("", 9), bg=BG, fg=MUTED).pack(side="left") font=(_FONT, 9), bg=WIN, fg=SUBTLE).pack(side="left")
def _field(self, parent, label, value, key, width=42): def _field(self, parent, label, value, key):
frame = tk.Frame(parent, bg=BG) frame = tk.Frame(parent, bg=WIN)
frame.pack(fill="x", padx=14, pady=5) frame.pack(fill="x", padx=16, pady=6)
tk.Label(frame, text=label, font=("", 10), bg=BG, fg=TEXT, width=16, anchor="w").pack(side="left") 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)) var = tk.StringVar(value=str(value))
self.vars[key] = var self.vars[key] = var
tk.Entry(frame, textvariable=var, width=width).pack(side="left", fill="x", expand=True) 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 return var
def _build_general(self, nb): def _build_general(self, nb):
tab = tk.Frame(nb, bg=BG) tab = tk.Frame(nb, bg=WIN)
nb.add(tab, text="Engine") nb.add(tab, text="Engine")
self._field(tab, "Whisper model", self.cfg.model, "model") self._field(tab, "Whisper model", self.cfg.model, "model")
self._combo(tab, "Device", self.cfg.device, ["auto", "cpu", "cuda"], "device") self._combo(tab, "Device", self.cfg.device, ["auto", "cpu", "cuda"], "device")
@ -290,12 +375,13 @@ class SettingsWindow:
self._field(tab, "Type delay (ms)", self.cfg.type_delay_ms, "type_delay_ms") self._field(tab, "Type delay (ms)", self.cfg.type_delay_ms, "type_delay_ms")
nv = tk.BooleanVar(value=self.cfg.notify) nv = tk.BooleanVar(value=self.cfg.notify)
self.vars["notify"] = nv self.vars["notify"] = nv
f = tk.Frame(tab, bg=BG); f.pack(fill="x", padx=14, pady=5) f = tk.Frame(tab, bg=WIN)
tk.Checkbutton(f, text="Desktop notifications", variable=nv, bg=BG, fg=TEXT, f.pack(fill="x", padx=16, pady=6)
activebackground=BG, selectcolor=CARD).pack(side="left") 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): def _build_rewrite(self, nb):
tab = tk.Frame(nb, bg=BG) tab = tk.Frame(nb, bg=WIN)
nb.add(tab, text="Rewrite LLM") nb.add(tab, text="Rewrite LLM")
self._field(tab, "Base URL", self.cfg.base_url, "base_url") 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, "API key env var", self.cfg.api_key_env, "api_key_env")
@ -305,10 +391,10 @@ class SettingsWindow:
present = "set ✓" if self.cfg.api_key else "NOT set" present = "set ✓" if self.cfg.api_key else "NOT set"
tk.Label(tab, text=f"OpenAI-compatible endpoint (OpenAI, vLLM, llama-swap…). " tk.Label(tab, text=f"OpenAI-compatible endpoint (OpenAI, vLLM, llama-swap…). "
f"Env {self.cfg.api_key_env}: {present}.", f"Env {self.cfg.api_key_env}: {present}.",
font=("", 9), bg=BG, fg=MUTED, wraplength=500, justify="left").pack(fill="x", padx=14, pady=(8, 0)) 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): def _build_workflow_tab(self, nb, idx, wf):
tab = tk.Frame(nb, bg=BG) tab = tk.Frame(nb, bg=WIN)
nb.add(tab, text=wf.name[:14]) nb.add(tab, text=wf.name[:14])
v: dict[str, tk.Variable] = {} v: dict[str, tk.Variable] = {}
self.wf_vars[idx] = v self.wf_vars[idx] = v
@ -317,30 +403,27 @@ class SettingsWindow:
("Hotkey", "hotkey", wf.hotkey), ("Hotkey", "hotkey", wf.hotkey),
("Model (opt.)", "model", wf.model or ""), ("Model (opt.)", "model", wf.model or ""),
("Temp (opt.)", "temperature", "" if wf.temperature is None else wf.temperature)]: ("Temp (opt.)", "temperature", "" if wf.temperature is None else wf.temperature)]:
frame = tk.Frame(tab, bg=BG); frame.pack(fill="x", padx=14, pady=4) frame = tk.Frame(tab, bg=WIN)
tk.Label(frame, text=label, font=("", 10), bg=BG, fg=TEXT, width=14, anchor="w").pack(side="left") 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)) sv = tk.StringVar(value=str(val))
v[key] = sv v[key] = sv
tk.Entry(frame, textvariable=sv).pack(side="left", fill="x", expand=True) 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=BG); mframe.pack(fill="x", padx=14, pady=4) mframe = tk.Frame(tab, bg=WIN)
tk.Label(mframe, text="Mode", font=("", 10), bg=BG, fg=TEXT, width=14, anchor="w").pack(side="left") mframe.pack(fill="x", padx=16, pady=5)
mv = tk.StringVar(value=wf.mode); v["mode"] = mv 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") ttk.Combobox(mframe, textvariable=mv, values=["transcribe", "rewrite"], state="readonly", width=14).pack(side="left")
tk.Label(tab, text="Rewrite prompt (system):", font=("", 10), bg=BG, fg=TEXT, anchor="w").pack(fill="x", padx=14, pady=(8, 2)) 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=10, wrap="word") txt = tk.Text(tab, height=9, wrap="word", font=(_FONT, 10), relief="solid", bd=1, highlightthickness=0)
txt.insert("1.0", wf.prompt) txt.insert("1.0", wf.prompt)
txt.pack(fill="both", expand=True, padx=14, pady=(0, 10)) txt.pack(fill="both", expand=True, padx=16, pady=(0, 12))
self.prompt_texts[idx] = txt self.prompt_texts[idx] = txt
def _combo(self, parent, label, value, options, key):
frame = tk.Frame(parent, bg=BG); frame.pack(fill="x", padx=14, pady=5)
tk.Label(frame, text=label, font=("", 10), bg=BG, 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 _collect(self) -> bool: def _collect(self) -> bool:
try: try:
self.cfg.model = self.vars["model"].get().strip() self.cfg.model = self.vars["model"].get().strip()
@ -380,7 +463,7 @@ class SettingsWindow:
if not self._collect(): if not self._collect():
return return
save(self.cfg) save(self.cfg)
os.execv(sys.executable, [sys.executable, "-m", "blitztext", "gui"]) os.execv(sys.executable, [sys.executable, "-m", "blitztext", "tray"])
def run_gui(tray_mode: bool = False) -> int: def run_gui(tray_mode: bool = False) -> int: