"""First-run setup wizard — guides new users through the essential settings. Shows automatically when no config file exists yet (fresh install). Can also be reopened from Settings → "Setup Wizard…". Flow: Welcome → Trigger method → [Keyboard] → [Wakeword] → STT → LLM → Done Pages in brackets are shown conditionally depending on the trigger choice. """ from __future__ import annotations 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, Pango # noqa: E402 # --------------------------------------------------------------------------- # Tiny style helpers # --------------------------------------------------------------------------- def _h1(text: str) -> Gtk.Label: lbl = Gtk.Label(label=text, xalign=0.0) lbl.set_line_wrap(True) attrs = Pango.AttrList() attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD)) attrs.insert(Pango.attr_scale_new(1.45)) lbl.set_attributes(attrs) return lbl def _sub(text: str) -> Gtk.Label: lbl = Gtk.Label(label=text, xalign=0.0) lbl.set_line_wrap(True) lbl.set_max_width_chars(64) lbl.get_style_context().add_class("dim-label") return lbl def _section(text: str) -> Gtk.Label: lbl = Gtk.Label(xalign=0.0) lbl.set_markup(f"{GLib.markup_escape_text(text.upper())}") lbl.set_margin_top(12) lbl.get_style_context().add_class("dim-label") return lbl def _page_box() -> Gtk.Box: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_top(28); box.set_margin_bottom(8) box.set_margin_start(32); box.set_margin_end(32) return box def _option_card(icon: str, title: str, desc: str) -> tuple[Gtk.RadioButton, Gtk.Box]: """A large selectable card with an icon, bold title, and grey description.""" card = Gtk.Box(spacing=14) card.set_margin_top(4); card.set_margin_bottom(4) card.get_style_context().add_class("card") icon_lbl = Gtk.Label(label=icon) attrs = Pango.AttrList() attrs.insert(Pango.attr_scale_new(2.0)) icon_lbl.set_attributes(attrs) icon_lbl.set_margin_start(14) card.pack_start(icon_lbl, False, False, 0) text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) text_box.set_margin_top(12); text_box.set_margin_bottom(12) title_lbl = Gtk.Label(label=title, xalign=0.0) attrs2 = Pango.AttrList() attrs2.insert(Pango.attr_weight_new(Pango.Weight.BOLD)) title_lbl.set_attributes(attrs2) text_box.pack_start(title_lbl, False, False, 0) desc_lbl = Gtk.Label(label=desc, xalign=0.0) desc_lbl.get_style_context().add_class("dim-label") desc_lbl.set_line_wrap(True) desc_lbl.set_max_width_chars(55) text_box.pack_start(desc_lbl, False, False, 0) card.pack_start(text_box, True, True, 0) # Invisible RadioButton — the whole card is the clickable area. rb = Gtk.RadioButton() rb.set_margin_end(14) card.pack_start(rb, False, False, 0) return rb, card def _keyval_token(keyval: int) -> str | None: from gi.repository import Gdk as _Gdk name = _Gdk.keyval_name(keyval) or "" low = name.lower() for mod in ("control", "ctrl"): if low.startswith(mod): return "ctrl" for mod in ("alt", "meta"): if low.startswith(mod): return "alt" if low.startswith("super") or low.startswith("hyper") or low.startswith("win"): return "cmd" if low.startswith("shift"): return "shift" if low in ("escape", "esc"): return "esc" if low in ("return", "enter"): return "enter" if low == "space": return "space" if len(name) == 1 and name.isalpha(): return name.lower() return None def _format_combo(tokens: list[str]) -> str: if not tokens: return "" order = ["ctrl", "alt", "shift", "cmd"] mods = [t for t in order if t in tokens] rest = [t for t in tokens if t not in order] parts = mods + rest return "+".join(f"<{p}>" for p in parts) # --------------------------------------------------------------------------- # Wizard # --------------------------------------------------------------------------- class SetupWizard: """Paged first-run setup dialog.""" # Page names in fixed order; "keyboard" and "wakeword" are conditional. _ALL_PAGES = ("welcome", "trigger", "keyboard", "wakeword", "stt", "llm", "done") def __init__(self, cfg, parent: Gtk.Window | None = None): self.cfg = cfg self._parent = parent # Wizard state collected as user moves through pages. self._trigger = "keyboard" # "keyboard" | "wakeword" | "both" self._kb_start = cfg.key_start self._kb_stop = cfg.key_stop self._kb_send = cfg.key_send self._kb_cancel = cfg.key_cancel self._ww_uri = cfg.wakeword_uri self._ww_model = cfg.wakeword_model self._stt_local = True # True = faster-whisper, False = remote API self._stt_size = "small" self._stt_url = "" self._stt_key = "" self._llm_enabled = bool(cfg.base_url and cfg.base_url != "https://api.openai.com/v1") self._llm_url = cfg.base_url self._llm_model = cfg.rewrite_model self._llm_key = cfg.api_key_env # Key-binding capture state self._bind_entry: Gtk.Entry | None = None self._bind_pressed: list[str] = [] self._build() # ------------------------------------------------------------------ # Dialog shell # ------------------------------------------------------------------ def _build(self) -> None: self.dlg = Gtk.Dialog(title="Blitztext Setup") if self._parent: self.dlg.set_transient_for(self._parent) self.dlg.set_modal(True) self.dlg.set_default_size(580, 520) self.dlg.set_resizable(False) self.dlg.get_action_area().hide() # Key capture self.dlg.add_events(Gdk.EventMask.KEY_PRESS_MASK | Gdk.EventMask.KEY_RELEASE_MASK) self.dlg.connect("key-press-event", self._on_key_press) self.dlg.connect("key-release-event", self._on_key_release) content = self.dlg.get_content_area() content.set_spacing(0) # Stack self.stack = Gtk.Stack() self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) self.stack.set_transition_duration(180) content.pack_start(self.stack, True, True, 0) self._pg: dict[str, Gtk.Widget] = {} builders = { "welcome": self._build_welcome, "trigger": self._build_trigger, "keyboard": self._build_keyboard, "wakeword": self._build_wakeword, "stt": self._build_stt, "llm": self._build_llm, "done": self._build_done, } for name in self._ALL_PAGES: w = builders[name]() self.stack.add_named(w, name) self._pg[name] = w # Nav bar sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) content.pack_start(sep, False, False, 0) nav = Gtk.Box(spacing=8) nav.set_margin_top(10); nav.set_margin_bottom(10) nav.set_margin_start(20); nav.set_margin_end(20) self._btn_back = Gtk.Button(label="← Back") self._btn_back.connect("clicked", lambda _b: self._go(-1)) nav.pack_start(self._btn_back, False, False, 0) self._step_lbl = Gtk.Label() self._step_lbl.get_style_context().add_class("dim-label") nav.pack_start(self._step_lbl, True, True, 0) self._btn_skip = Gtk.Button(label="Skip") self._btn_skip.get_style_context().add_class("flat") self._btn_skip.connect("clicked", lambda _b: self._go(+1, skip=True)) nav.pack_start(self._btn_skip, False, False, 0) self._btn_next = Gtk.Button(label="Next →") self._btn_next.get_style_context().add_class("suggested-action") self._btn_next.connect("clicked", lambda _b: self._go(+1)) nav.pack_start(self._btn_next, False, False, 0) content.pack_start(nav, False, False, 0) self._page_order: list[str] = [] self._idx = 0 self._refresh_page_order() self._show(0) # ------------------------------------------------------------------ # Navigation # ------------------------------------------------------------------ def _refresh_page_order(self) -> None: pages = ["welcome", "trigger"] if self._trigger in ("keyboard", "both"): pages.append("keyboard") if self._trigger in ("wakeword", "both"): pages.append("wakeword") pages += ["stt", "llm", "done"] self._page_order = pages def _show(self, idx: int) -> None: self._idx = max(0, min(idx, len(self._page_order) - 1)) name = self._page_order[self._idx] self.stack.set_visible_child_name(name) is_first = self._idx == 0 is_last = self._idx == len(self._page_order) - 1 is_welcome = name == "welcome" is_done = name == "done" self._btn_back.set_sensitive(not is_first) self._btn_skip.set_visible(not is_welcome and not is_done) self._btn_next.set_label("Start dictating →" if is_last else "Next →") # Step counter (exclude welcome and done from the count) core_pages = [p for p in self._page_order if p not in ("welcome", "done")] if name in core_pages: step = core_pages.index(name) + 1 self._step_lbl.set_text(f"Step {step} of {len(core_pages)}") else: self._step_lbl.set_text("") # When leaving "trigger", re-evaluate page order. if name == "trigger": self._read_trigger() self._refresh_page_order() def _go(self, direction: int, skip: bool = False) -> None: name = self._page_order[self._idx] if not skip: self._commit_page(name) if direction > 0 and self._idx >= len(self._page_order) - 1: self._finish() return self._show(self._idx + direction) def _finish(self) -> None: self._apply_to_cfg() self.dlg.response(Gtk.ResponseType.OK) # ------------------------------------------------------------------ # Page builders # ------------------------------------------------------------------ def _build_welcome(self) -> Gtk.Widget: box = _page_box() box.set_valign(Gtk.Align.CENTER) logo = Gtk.Label(label="🎙") attrs = Pango.AttrList() attrs.insert(Pango.attr_scale_new(4.0)) logo.set_attributes(attrs) logo.set_margin_bottom(12) box.pack_start(logo, False, False, 0) box.pack_start(_h1("Welcome to Blitztext"), False, False, 0) box.pack_start(_sub( "Blitztext lets you speak and have your words typed anywhere on screen — " "with optional AI polishing.\n\n" "This short wizard sets up the basics. " "You can change everything later in Settings." ), False, False, 0) return box def _build_trigger(self) -> Gtk.Widget: box = _page_box() box.pack_start(_h1("How do you want to trigger recording?"), False, False, 0) box.pack_start(_sub("You can change this later in Settings → Input."), False, False, 0) self._rb_kb, card_kb = _option_card("⌨", "Keyboard shortcut", "Press a key combination to start and stop recording. " "Great for desktop use.") self._rb_ww, card_ww = _option_card("🎙", "Voice wakeword", "Say a wake phrase like \"okay computer\" to start hands-free. " "Needs a wakeword server.") self._rb_both, card_both = _option_card("✨", "Both", "Use a keyboard shortcut AND a voice wakeword — whichever is handy.") # Group the radio buttons self._rb_ww.join_group(self._rb_kb) self._rb_both.join_group(self._rb_kb) self._rb_kb.set_active(True) for rb, card in ((self._rb_kb, card_kb), (self._rb_ww, card_ww), (self._rb_both, card_both)): # Make the card clickable by forwarding clicks to the radio button ebox = Gtk.EventBox() ebox.add(card) ebox.connect("button-press-event", lambda _e, _ev, r=rb: r.set_active(True)) box.pack_start(ebox, False, False, 0) return box def _build_keyboard(self) -> Gtk.Widget: box = _page_box() box.pack_start(_h1("Set up your keyboard shortcuts"), False, False, 0) box.pack_start(_sub( 'Click "Set" and press a key combination to capture it. ' "The defaults shown here work well for most users." ), False, False, 0) grid = Gtk.Grid(column_spacing=8, row_spacing=8) grid.set_margin_top(16) rows = [ ("Start recording", self._kb_start, "_kb_start"), ("Stop and paste", self._kb_stop, "_kb_stop"), ("Stop, paste + Enter", self._kb_send, "_kb_send"), ("Cancel", self._kb_cancel, "_kb_cancel"), ] self._kb_entries: dict[str, Gtk.Entry] = {} for i, (label, value, attr) in enumerate(rows): lbl = Gtk.Label(label=label, xalign=0.0) lbl.set_size_request(180, -1) grid.attach(lbl, 0, i, 1, 1) entry = Gtk.Entry() entry.set_text(value) entry.set_size_request(140, -1) entry.set_editable(False) self._kb_entries[attr] = entry grid.attach(entry, 1, i, 1, 1) btn = Gtk.Button(label="Set") btn.connect("clicked", lambda _b, e=entry: self._bind_key(e)) grid.attach(btn, 2, i, 1, 1) box.pack_start(grid, False, False, 0) note = Gtk.Label(xalign=0.0) note.set_markup( '' "Tip: use modifier keys (Ctrl, Alt, Win/Cmd) rather than letters " "so shortcuts don't interfere with typing." "") note.set_line_wrap(True) note.set_margin_top(8) box.pack_start(note, False, False, 0) return box def _build_wakeword(self) -> Gtk.Widget: box = _page_box() box.pack_start(_h1("Set up voice activation"), False, False, 0) box.pack_start(_sub( "Blitztext listens for your wake phrase through a local wakeword server. " "The server runs on your machine — nothing is sent to the cloud." ), False, False, 0) grid = Gtk.Grid(column_spacing=8, row_spacing=10) grid.set_margin_top(16) # Server URL lbl_uri = Gtk.Label(label="Server URL", xalign=0.0) lbl_uri.set_size_request(120, -1) self._wiz_ww_uri = Gtk.Entry() self._wiz_ww_uri.set_text(self._ww_uri) self._wiz_ww_uri.set_hexpand(True) self._wiz_ww_uri.set_placeholder_text("tcp://127.0.0.1:10400") grid.attach(lbl_uri, 0, 0, 1, 1) grid.attach(self._wiz_ww_uri, 1, 0, 1, 1) # Model lbl_model = Gtk.Label(label="Wake phrase", xalign=0.0) self._wiz_ww_model = Gtk.Entry() self._wiz_ww_model.set_text(self._ww_model) self._wiz_ww_model.set_placeholder_text("okay_computer") grid.attach(lbl_model, 0, 1, 1, 1) grid.attach(self._wiz_ww_model, 1, 1, 1, 1) box.pack_start(grid, False, False, 0) # Test row test_row = Gtk.Box(spacing=10) test_row.set_margin_top(8) self._wiz_ww_test_btn = Gtk.Button(label="Test connection") self._wiz_ww_test_btn.connect("clicked", self._ww_test) self._wiz_ww_test_lbl = Gtk.Label(label="", xalign=0.0) test_row.pack_start(self._wiz_ww_test_btn, False, False, 0) test_row.pack_start(self._wiz_ww_test_lbl, False, False, 0) box.pack_start(test_row, False, False, 0) # Setup hint (collapsible-ish — just always visible for simplicity) hint = Gtk.Label(xalign=0.0) hint.set_markup( '' "Don't have a wakeword server yet? Run one with Docker:\n" "docker run -it -p 10400:10400 homeassistant/wyoming-openwakeword" "") hint.set_line_wrap(True) hint.set_selectable(True) hint.set_margin_top(12) box.pack_start(hint, False, False, 0) return box def _build_stt(self) -> Gtk.Widget: box = _page_box() box.pack_start(_h1("Choose your speech-to-text engine"), False, False, 0) box.pack_start(_sub( "Blitztext transcribes your speech locally using Whisper, " "or you can connect to a remote OpenAI-compatible API." ), False, False, 0) # Local vs remote toggle self._rb_local, card_local = _option_card( "💻", "Local (faster-whisper)", "Runs on your machine — private, no API key needed. " "Choose the model size below.") self._rb_remote, card_remote = _option_card( "☁", "Remote API", "Send audio to an OpenAI-compatible endpoint " "(OpenAI, local Whisper server, etc.).") self._rb_remote.join_group(self._rb_local) self._rb_local.set_active(True) for rb, card in ((self._rb_local, card_local), (self._rb_remote, card_remote)): ebox = Gtk.EventBox() ebox.add(card) ebox.connect("button-press-event", lambda _e, _ev, r=rb: (r.set_active(True), self._stt_toggle())) box.pack_start(ebox, False, False, 0) rb.connect("toggled", lambda _r: self._stt_toggle()) # Local options self._stt_local_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self._stt_local_box.set_margin_top(8) size_lbl = Gtk.Label(label="Model size", xalign=0.0) size_lbl.get_style_context().add_class("dim-label") self._stt_size_rb: dict[str, Gtk.RadioButton] = {} sizes = [ ("tiny", "Tiny — fastest, basic accuracy"), ("base", "Base — fast, decent accuracy"), ("small", "Small — balanced ✓ recommended"), ("medium", "Medium — better accuracy, slower"), ("large-v3", "Large — best accuracy, most memory"), ] size_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) prev = None for key, desc in sizes: rb = Gtk.RadioButton(label=desc) if prev: rb.join_group(prev) rb.set_active(key == self._stt_size) rb.connect("toggled", lambda r, k=key: r.get_active() and setattr(self, "_stt_size", k)) self._stt_size_rb[key] = rb size_box.pack_start(rb, False, False, 0) prev = rb self._stt_local_box.pack_start(size_lbl, False, False, 0) self._stt_local_box.pack_start(size_box, False, False, 0) box.pack_start(self._stt_local_box, False, False, 0) # Remote options self._stt_remote_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self._stt_remote_box.set_margin_top(8) rgrid = Gtk.Grid(column_spacing=8, row_spacing=6) rlbl_url = Gtk.Label(label="API URL", xalign=0.0); rlbl_url.set_size_request(100, -1) self._stt_url_entry = Gtk.Entry(); self._stt_url_entry.set_hexpand(True) self._stt_url_entry.set_text(self._stt_url or "http://localhost:8010/v1") self._stt_url_entry.set_placeholder_text("http://localhost:8010/v1") rlbl_key = Gtk.Label(label="API key env", xalign=0.0) self._stt_key_entry = Gtk.Entry(); self._stt_key_entry.set_hexpand(True) self._stt_key_entry.set_text(self._stt_key) self._stt_key_entry.set_placeholder_text("OPENAI_API_KEY (leave empty if not needed)") rgrid.attach(rlbl_url, 0, 0, 1, 1); rgrid.attach(self._stt_url_entry, 1, 0, 1, 1) rgrid.attach(rlbl_key, 0, 1, 1, 1); rgrid.attach(self._stt_key_entry, 1, 1, 1, 1) self._stt_remote_box.pack_start(rgrid, False, False, 0) self._stt_remote_box.set_no_show_all(True) box.pack_start(self._stt_remote_box, False, False, 0) first_run_note = Gtk.Label(xalign=0.0) first_run_note.set_markup( '' "The local model is downloaded the first time you use it (~500 MB for Small). " "Subsequent starts are instant." "") first_run_note.set_line_wrap(True) first_run_note.set_margin_top(6) box.pack_start(first_run_note, False, False, 0) return box def _build_llm(self) -> Gtk.Widget: box = _page_box() box.pack_start(_h1("AI text processing (optional)"), False, False, 0) box.pack_start(_sub( "Blitztext can send your transcript to an AI model that rewrites it — " "cleaning up speech artifacts, adjusting tone, or reformatting it." ), False, False, 0) enable_row = Gtk.Box(spacing=10) enable_row.set_margin_top(12) self._llm_switch = Gtk.Switch() self._llm_switch.set_active(self._llm_enabled) self._llm_switch.set_valign(Gtk.Align.CENTER) enable_lbl = Gtk.Label(label="Enable AI rewriting", xalign=0.0) enable_row.pack_start(self._llm_switch, False, False, 0) enable_row.pack_start(enable_lbl, False, False, 0) box.pack_start(enable_row, False, False, 0) self._llm_detail = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self._llm_detail.set_margin_top(10) lgrid = Gtk.Grid(column_spacing=8, row_spacing=8) llbl_url = Gtk.Label(label="API URL", xalign=0.0); llbl_url.set_size_request(120, -1) self._llm_url_entry = Gtk.Entry(); self._llm_url_entry.set_hexpand(True) self._llm_url_entry.set_text(self._llm_url) self._llm_url_entry.set_placeholder_text("https://api.openai.com/v1") llbl_model = Gtk.Label(label="Model", xalign=0.0) self._llm_model_entry = Gtk.Entry(); self._llm_model_entry.set_hexpand(True) self._llm_model_entry.set_text(self._llm_model) self._llm_model_entry.set_placeholder_text("gpt-4o-mini") llbl_key = Gtk.Label(label="API key env", xalign=0.0) self._llm_key_entry = Gtk.Entry(); self._llm_key_entry.set_hexpand(True) self._llm_key_entry.set_text(self._llm_key) self._llm_key_entry.set_placeholder_text("OPENAI_API_KEY") lgrid.attach(llbl_url, 0, 0, 1, 1); lgrid.attach(self._llm_url_entry, 1, 0, 1, 1) lgrid.attach(llbl_model, 0, 1, 1, 1); lgrid.attach(self._llm_model_entry, 1, 1, 1, 1) lgrid.attach(llbl_key, 0, 2, 1, 1); lgrid.attach(self._llm_key_entry, 1, 2, 1, 1) self._llm_detail.pack_start(lgrid, False, False, 0) llm_note = Gtk.Label(xalign=0.0) llm_note.set_markup( '' "Works with OpenAI, or a local model via Ollama / LM Studio / vLLM. " "You can fine-tune prompts per workflow in Settings → Workflows." "") llm_note.set_line_wrap(True) self._llm_detail.pack_start(llm_note, False, False, 0) box.pack_start(self._llm_detail, False, False, 0) self._llm_switch.connect("notify::active", lambda s, _p: self._llm_toggle()) self._llm_toggle() return box def _build_done(self) -> Gtk.Widget: box = _page_box() box.set_valign(Gtk.Align.CENTER) lbl_done = Gtk.Label(label="✅") attrs = Pango.AttrList() attrs.insert(Pango.attr_scale_new(3.5)) lbl_done.set_attributes(attrs) lbl_done.set_margin_bottom(12) box.pack_start(lbl_done, False, False, 0) box.pack_start(_h1("You're all set!"), False, False, 0) self._done_summary = Gtk.Label(xalign=0.0) self._done_summary.set_line_wrap(True) self._done_summary.set_max_width_chars(60) self._done_summary.get_style_context().add_class("dim-label") box.pack_start(self._done_summary, False, False, 0) tip = Gtk.Label(xalign=0.0) tip.set_markup( '\n' "You can always open Settings from the system tray to add workflows, " "tune STT quality, or configure more shortcuts." "") tip.set_line_wrap(True) box.pack_start(tip, False, False, 0) return box # ------------------------------------------------------------------ # Page commit helpers # ------------------------------------------------------------------ def _read_trigger(self) -> None: if self._rb_ww.get_active(): self._trigger = "wakeword" elif self._rb_both.get_active(): self._trigger = "both" else: self._trigger = "keyboard" def _commit_page(self, name: str) -> None: if name == "trigger": self._read_trigger() self._refresh_page_order() elif name == "keyboard": for attr, entry in self._kb_entries.items(): setattr(self, attr, entry.get_text().strip()) elif name == "wakeword": self._ww_uri = self._wiz_ww_uri.get_text().strip() self._ww_model = self._wiz_ww_model.get_text().strip() elif name == "stt": self._stt_local = self._rb_local.get_active() if not self._stt_local: self._stt_url = self._stt_url_entry.get_text().strip() self._stt_key = self._stt_key_entry.get_text().strip() elif name == "llm": self._llm_enabled = self._llm_switch.get_active() self._llm_url = self._llm_url_entry.get_text().strip() self._llm_model = self._llm_model_entry.get_text().strip() self._llm_key = self._llm_key_entry.get_text().strip() self._update_done_summary() def _update_done_summary(self) -> None: lines: list[str] = [] if self._trigger in ("keyboard", "both"): lines.append(f"⌨ Keyboard — start: {self._kb_start}, cancel: {self._kb_cancel}") if self._trigger in ("wakeword", "both"): lines.append(f"🎙 Wakeword — {self._ww_model!r} @ {self._ww_uri}") if self._stt_local: lines.append(f"🤖 Local Whisper ({self._stt_size})") else: lines.append(f"☁ Remote STT — {self._stt_url}") if self._llm_enabled: lines.append(f"✨ AI rewriting — {self._llm_model} @ {self._llm_url}") else: lines.append("✨ AI rewriting — disabled") if hasattr(self, "_done_summary"): self._done_summary.set_text("\n".join(lines)) # ------------------------------------------------------------------ # Apply to config # ------------------------------------------------------------------ def _apply_to_cfg(self) -> None: from .config import save from .stt import STTEngine cfg = self.cfg # Trigger / keyboard mode if self._trigger in ("keyboard", "both"): cfg.input_mode = "modifiers" cfg.key_start = self._kb_start cfg.key_stop = self._kb_stop cfg.key_send = self._kb_send cfg.key_cancel = self._kb_cancel # Wakeword cfg.wakeword_enabled = self._trigger in ("wakeword", "both") if cfg.wakeword_enabled: cfg.wakeword_uri = self._ww_uri cfg.wakeword_model = self._ww_model from .config import WakewordEngine # Update or create the first engine preset. if cfg.wakeword_engines: cfg.wakeword_engines[0].uri = self._ww_uri cfg.wakeword_engines[0].model = self._ww_model else: cfg.wakeword_engines = [WakewordEngine( name="Default", uri=self._ww_uri, model=self._ww_model)] cfg.wakeword_active = cfg.wakeword_engines[0].name # STT if self._stt_local: local_eng = STTEngine(name="Local Whisper", type="local", model=self._stt_size) # Replace or add. locals_ = [e for e in cfg.stt_engines if e.is_local] if locals_: idx = cfg.stt_engines.index(locals_[0]) cfg.stt_engines[idx] = local_eng else: cfg.stt_engines.insert(0, local_eng) cfg.stt_active = local_eng.name cfg.model = self._stt_size else: remote_eng = STTEngine(name="Remote STT", type="openai", url=self._stt_url, api_key_env=self._stt_key) remotes = [e for e in cfg.stt_engines if not e.is_local] if remotes: idx = cfg.stt_engines.index(remotes[0]) cfg.stt_engines[idx] = remote_eng else: cfg.stt_engines.append(remote_eng) cfg.stt_active = remote_eng.name # LLM if self._llm_enabled: cfg.base_url = self._llm_url cfg.rewrite_model = self._llm_model cfg.api_key_env = self._llm_key from .llm import LLMEngine llm_eng = LLMEngine("Default", self._llm_url, self._llm_model, self._llm_key) if cfg.llm_engines: cfg.llm_engines[0] = llm_eng else: cfg.llm_engines = [llm_eng] cfg.llm_active = llm_eng.name # Mark setup as complete so the wizard doesn't auto-show again. cfg.setup_complete = True save(cfg) # ------------------------------------------------------------------ # UI toggle helpers # ------------------------------------------------------------------ def _stt_toggle(self) -> None: local = self._rb_local.get_active() self._stt_local_box.set_visible(local) self._stt_remote_box.set_visible(not local) def _llm_toggle(self) -> None: on = self._llm_switch.get_active() self._llm_detail.set_sensitive(on) # ------------------------------------------------------------------ # Wakeword connection test # ------------------------------------------------------------------ def _ww_test(self, _btn) -> None: uri = self._wiz_ww_uri.get_text().strip() self._wiz_ww_test_lbl.set_text("Testing…") self._wiz_ww_test_btn.set_sensitive(False) def _probe(): import socket as _socket from urllib.parse import urlparse p = urlparse(uri) host = p.hostname or "127.0.0.1" port = p.port or 10400 try: with _socket.create_connection((host, port), timeout=3.0): ok = True except OSError: ok = False def _update(): self._wiz_ww_test_btn.set_sensitive(True) if ok: self._wiz_ww_test_lbl.set_markup( '✓ Connected') else: self._wiz_ww_test_lbl.set_markup( '✗ Could not connect — is the server running?') GLib.idle_add(_update) threading.Thread(target=_probe, daemon=True).start() # ------------------------------------------------------------------ # Key-binding capture # ------------------------------------------------------------------ def _bind_key(self, entry: Gtk.Entry) -> None: self._bind_entry = entry self._bind_pressed = [] entry.set_text("") entry.set_placeholder_text("press the key combination…") def _on_key_press(self, _w, event) -> bool: if self._bind_entry is None: return False tok = _keyval_token(event.keyval) if tok and tok not in self._bind_pressed: self._bind_pressed.append(tok) return True def _on_key_release(self, _w, event) -> bool: if self._bind_entry is None: return False combo = _format_combo(self._bind_pressed) if combo: self._bind_entry.set_text(combo) self._bind_entry.set_placeholder_text("") self._bind_entry = None self._bind_pressed = [] return True # ------------------------------------------------------------------ # Public entry point # ------------------------------------------------------------------ def run(self) -> None: self.dlg.show_all() # Hide remote STT box initially (local is default) self._stt_remote_box.hide() self.dlg.run() self.dlg.destroy() # --------------------------------------------------------------------------- # Convenience: show if this is a fresh install # --------------------------------------------------------------------------- def maybe_show(cfg, parent: Gtk.Window | None = None) -> None: """Show the wizard if setup has never been completed.""" if not getattr(cfg, "setup_complete", False): wiz = SetupWizard(cfg, parent=parent) wiz.run()