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:
parent
83d4143e83
commit
658339d67c
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user