diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 95d9079..c2b8f56 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,29 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [1.9.4] - 2026-06-09 + +### Changed +- **Settings UI completely redesigned.** All six settings tabs (Presets, Engines, + Input, General, Input, General) now use a card-based layout following GTK3 best + practices: related fields are grouped inside visually distinct cards with bold + section titles. CSS is injected at start-up to give cards a consistent rounded + border (`boxed-list` + `bt-card`) and a styled info banner at the top of each + tab. +- **Dialog is larger (740×700 px) and every tab scrolls.** The notebook pages + now wrap their content in a `Gtk.ScrolledWindow` so no fields are ever clipped, + even on small screens. +- **Engines toolbar reorganised.** Creation actions (+ Add, + Stream, Quickstart) + are left-aligned; destructive/status actions (Delete, Test, ⟳) are + right-aligned via `pack_end`, making the bar scannable at a glance. +- **Section titles replace plain separators.** The old `Gtk.Separator` + + unstyled `Gtk.Label` pattern is gone; every section now has a small, dimmed, + bold all-caps header rendered with markup. +- **Cleaner section names.** "WW - Wakeword (Hands-free)" → "Hands-free + wakeword"; "Audio cues (manual dictation)" → "Audio cues (keyboard / hotkey + dictation)"; "Local engine … device & precision" → "Internal engine — device & + precision". + ## [1.9.3] - 2026-06-09 ### Added diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index 951ac76..bd3db2b 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -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. """ -__version__ = "1.9.3" +__version__ = "1.9.4" diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 5acba82..8d7a84a 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -192,12 +192,15 @@ def _format_combo(tokens: list[str]) -> str: return "+".join(parts) -def _labeled(parent: Gtk.Box, label: str, widget: Gtk.Widget, width: int = 130, tooltip: str = "") -> Gtk.Widget: - row = Gtk.Box(spacing=10) - row.set_margin_top(3); row.set_margin_bottom(3) +def _labeled(parent, label: str, widget: Gtk.Widget, width: int = 130, tooltip: str = "") -> Gtk.Widget: + is_lb = isinstance(parent, Gtk.ListBox) + row_box = Gtk.Box(spacing=10) + mt, mb, ms, me = (6, 6, 12, 8) if is_lb else (3, 3, 0, 0) + row_box.set_margin_top(mt); row_box.set_margin_bottom(mb) + if is_lb: + row_box.set_margin_start(ms); row_box.set_margin_end(me) + lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) - - # ATK Accessibility linking if hasattr(lbl, "set_mnemonic_widget"): lbl.set_mnemonic_widget(widget) atk = widget.get_accessible() @@ -208,23 +211,28 @@ def _labeled(parent: Gtk.Box, label: str, widget: Gtk.Widget, width: int = 130, lbl.set_tooltip_text(tooltip) if atk: atk.set_description(tooltip) - - row.pack_start(lbl, False, False, 0) - row.pack_start(widget, True, True, 0) + + row_box.pack_start(lbl, False, False, 0) + row_box.pack_start(widget, True, True, 0) if tooltip: - row.pack_start(_info_btn(tooltip), False, False, 0) - parent.pack_start(row, False, False, 0) + row_box.pack_start(_info_btn(tooltip), False, False, 0) + + if is_lb: + _lb_add(parent, row_box) + else: + parent.pack_start(row_box, False, False, 0) return widget -def _switch_row(parent: Gtk.Box, label: str, switch: Gtk.Switch, description: str = "", +def _switch_row(parent, label: str, switch: Gtk.Switch, description: str = "", width: int = 130) -> Gtk.Switch: - """A settings row: [label] [grey description ……………] [switch, far right]. - - The description tells the user what the switch does without hovering. - """ + """A settings row: [label] [grey description ……………] [ⓘ] [switch].""" + is_lb = isinstance(parent, Gtk.ListBox) row = Gtk.Box(spacing=10) - row.set_margin_top(3); row.set_margin_bottom(3) + mt, mb = (6, 6) if is_lb else (3, 3) + row.set_margin_top(mt); row.set_margin_bottom(mb) + if is_lb: + row.set_margin_start(12); row.set_margin_end(8) lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) row.pack_start(lbl, False, False, 0) @@ -234,13 +242,11 @@ def _switch_row(parent: Gtk.Box, label: str, switch: Gtk.Switch, description: st desc.get_style_context().add_class("dim-label") row.pack_start(desc, True, True, 0) - switch.set_halign(Gtk.Align.END) - switch.set_valign(Gtk.Align.CENTER) + switch.set_halign(Gtk.Align.END); switch.set_valign(Gtk.Align.CENTER) row.pack_end(switch, False, False, 0) if description: row.pack_end(_info_btn(description), False, False, 0) - # ATK accessibility + tooltips if hasattr(lbl, "set_mnemonic_widget"): lbl.set_mnemonic_widget(switch) atk = switch.get_accessible() @@ -253,19 +259,26 @@ def _switch_row(parent: Gtk.Box, label: str, switch: Gtk.Switch, description: st lbl.set_tooltip_text(description) desc.set_tooltip_text(description) - parent.pack_start(row, False, False, 0) + if is_lb: + _lb_add(parent, row) + else: + parent.pack_start(row, False, False, 0) return switch def _infobox(parent: Gtk.Box, text: str) -> Gtk.Box: - """A plain-language help box at the top of a tab (read by screen readers).""" - box = Gtk.Box(spacing=8) - box.set_margin_top(2); box.set_margin_bottom(10) + """A styled banner at the top of each settings tab.""" + box = Gtk.Box(spacing=10) + box.set_margin_top(0); box.set_margin_bottom(12) + box.set_margin_start(4); box.set_margin_end(4) + box.get_style_context().add_class("bt-infobox") icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.BUTTON) - icon.set_valign(Gtk.Align.START) + icon.set_valign(Gtk.Align.CENTER) + icon.set_margin_start(6); icon.set_margin_end(2) box.pack_start(icon, False, False, 0) lbl = Gtk.Label(label=text, xalign=0.0) lbl.set_line_wrap(True); lbl.set_xalign(0.0); lbl.set_max_width_chars(72) + lbl.set_margin_top(8); lbl.set_margin_bottom(8); lbl.set_margin_end(8) box.pack_start(lbl, True, True, 0) acc = box.get_accessible() if acc: @@ -275,6 +288,35 @@ def _infobox(parent: Gtk.Box, text: str) -> Gtk.Box: return box +def _section_title(parent: Gtk.Box, text: str, margin_top: int = 16) -> None: + """A small bold uppercase section header above a group of settings.""" + lbl = Gtk.Label(xalign=0.0) + lbl.set_markup(f"{GLib.markup_escape_text(text.upper())}") + lbl.set_margin_top(margin_top); lbl.set_margin_bottom(3) + lbl.get_style_context().add_class("bt-section") + parent.pack_start(lbl, False, False, 0) + + +def _card_section(parent: Gtk.Box, title: str = "", margin_top: int = 16) -> Gtk.ListBox: + """A titled card group. Returns a ListBox styled as a card for packing rows into.""" + if title: + _section_title(parent, title, margin_top) + lb = Gtk.ListBox() + lb.set_selection_mode(Gtk.SelectionMode.NONE) + lb.get_style_context().add_class("boxed-list") + lb.get_style_context().add_class("bt-card") + parent.pack_start(lb, False, False, 0) + return lb + + +def _lb_add(lb: Gtk.ListBox, widget: Gtk.Widget) -> None: + """Add any widget as a non-interactive row inside a card ListBox.""" + row = Gtk.ListBoxRow() + row.set_activatable(False); row.set_selectable(False) + row.add(widget) + lb.add(row) + + def _entry(text="", placeholder="") -> Gtk.Entry: e = Gtk.Entry(); e.set_text(str(text)); e.set_hexpand(True) if placeholder: @@ -441,12 +483,35 @@ def _url_field(parent: Gtk.Box, label: str, placeholder: str, on_reload, return e -def _page(nb: Gtk.Notebook, title: str) -> Gtk.Box: +def _url_field_lb(lb: Gtk.ListBox, label: str, placeholder: str, on_reload, + dot: Gtk.Label | None = None, width: int = 130) -> Gtk.Entry: + """_url_field variant that adds the row as a ListBox card row.""" box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + _url_field(box, label, placeholder, on_reload, dot=dot, width=width) + row_box = box.get_children()[0] # the row box built by _url_field + box.remove(row_box) + row_box.set_margin_top(6); row_box.set_margin_bottom(6) + row_box.set_margin_start(12); row_box.set_margin_end(8) + _lb_add(lb, row_box) + # return the Entry widget (second child of row_box after label [and optional dot]) + for child in row_box.get_children(): + if isinstance(child, Gtk.Entry): + return child + return row_box.get_children()[-2] # fallback: second-to-last (before refresh btn) + + +def _page(nb: Gtk.Notebook, title: str) -> Gtk.Box: + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + nb.append_page(outer, Gtk.Label(label=title)) + sw = Gtk.ScrolledWindow() + sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + outer.pack_start(sw, True, True, 0) + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) for m in ("top", "bottom", "start", "end"): - getattr(box, f"set_margin_{m}")(12) - nb.append_page(box, Gtk.Label(label=title)) - return box + getattr(inner, f"set_margin_{m}")(15) + sw.add(inner) + outer._bt_inner = inner + return outer def _read_first(paths: list[Path], fallback: str = "Not available in this install.") -> str: @@ -497,11 +562,43 @@ class SettingsDialog: self._wf_idx = self._stt_idx = self._llm_idx = 0 self.dlg = Gtk.Dialog(title="Blitztext — Settings", transient_for=parent, modal=True) - self.dlg.set_default_size(620, 600) + self.dlg.set_default_size(740, 700) self.dlg.add_button("Close", Gtk.ResponseType.CLOSE) self.dlg.add_button("Save", RESP_SAVE) self.dlg.add_button("Save & Restart", RESP_SAVE_RESTART) + _prov = Gtk.CssProvider() + _prov.load_from_data(b""" +.bt-section { + font-weight: bold; + font-size: small; + letter-spacing: 1px; + color: mix(@theme_fg_color, @theme_bg_color, 0.45); + margin-top: 14px; + margin-bottom: 2px; +} +.bt-card { + border-radius: 8px; + border: 1px solid mix(@theme_fg_color, @theme_bg_color, 0.8); + padding: 2px 0px; + margin-bottom: 4px; +} +.bt-card row { + padding: 0px; +} +.bt-infobox { + border-radius: 6px; + border: 1px solid mix(@theme_selected_bg_color, @theme_bg_color, 0.55); + background-color: mix(@theme_selected_bg_color, @theme_base_color, 0.07); + padding: 4px; + margin-bottom: 10px; +} +""") + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), _prov, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + nb = Gtk.Notebook() self.dlg.get_content_area().pack_start(nb, True, True, 0) # Build each tab lazily on first view so the dialog opens instantly. The @@ -533,7 +630,8 @@ class SettingsDialog: """Build a notebook tab's contents the first time it's shown (lazy).""" builder = self._pending_tabs.pop(page, None) if builder is not None: - builder(page) + content = getattr(page, "_bt_inner", page) + builder(content) page.show_all() # the dialog may already be on-screen def _force_build_tabs(self) -> None: @@ -548,7 +646,10 @@ class SettingsDialog: "(for example into a polished email). Trigger it by speaking its " "keyword, or with an optional keyboard shortcut.") self._wf_idx = 0 + + # ── Selector bar ────────────────────────────────────────────────────── bar = Gtk.Box(spacing=8) + bar.set_margin_bottom(6) self.wf_combo = Gtk.ComboBoxText() for wf in self.cfg.workflows: self.wf_combo.append_text(wf.name) @@ -556,34 +657,42 @@ class SettingsDialog: self.wf_combo.connect("changed", self._wf_changed) bar.pack_start(self.wf_combo, True, True, 0) add = Gtk.Button(label="+ Add"); add.connect("clicked", self._wf_add) - rm = Gtk.Button(label="Delete"); rm.connect("clicked", self._wf_delete) + rm = Gtk.Button(label="Delete"); rm.connect("clicked", self._wf_delete) bar.pack_start(add, False, False, 0); bar.pack_start(rm, False, False, 0) - page.pack_start(bar, False, False, 4) + page.pack_start(bar, False, False, 0) - form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - page.pack_start(form, True, True, 6) - self.wf_name = _labeled(form, "Name", _entry(placeholder="Preset name"), + # ── Identity card ───────────────────────────────────────────────────── + id_card = _card_section(page, "Identity", margin_top=4) + self.wf_name = _labeled(id_card, "Name", _entry(placeholder="Preset name"), tooltip="A short name for this action, shown in the main panel.") - self.wf_icon = self._icon_field(form, "Icon (emoji)") - self.wf_desc = _labeled(form, "Description", _entry(placeholder="Short description shown in the panel"), + self.wf_icon = self._icon_field_lb(id_card, "Icon (emoji)") + self.wf_desc = _labeled(id_card, "Description", _entry(placeholder="Short description shown in the panel"), tooltip="One line explaining what this preset does.") - self.wf_keywords = _labeled(form, "Keywords (comma)", _entry(placeholder="nicer email, bessere email"), - tooltip="Spoken trigger words. Say one at the start or end of your speech to use this preset.") - self.wf_hotkey = self._key_field(form, "Hotkey (optional)", "", placeholder="click Set, or e.g. ++e", width=130) - self.wf_mode = _labeled(form, "Mode", _combo(["transcribe", "rewrite", "stream"]), - tooltip="‘transcribe’ types your words as-is. ‘rewrite’ sends them to the language model first. ‘stream’ shows live text from a realtime engine.") - self.wf_model = _labeled(form, "LLM model (opt.)", _entry(placeholder="blank = use the active LLM engine's model"), - tooltip="Override the language model just for this preset. Leave blank to use the active LLM engine.") - self.wf_temp = _labeled(form, "Temperature (opt.)", _entry(placeholder="blank = engine default (e.g. 0.3)"), - tooltip="Creativity of the rewrite, 0–1. Lower is more predictable. Blank uses the engine default.") - form.pack_start(Gtk.Label(label="Prompt sent to the LLM (rewrite mode):", xalign=0.0), False, False, 6) + # ── Trigger card ────────────────────────────────────────────────────── + trig_card = _card_section(page, "Trigger") + self.wf_keywords = _labeled(trig_card, "Keywords", _entry(placeholder="nicer email, bessere email"), + tooltip="Spoken trigger words, comma-separated. Say one at the start or end of your speech to activate this preset.") + self.wf_hotkey = self._key_field_lb(trig_card, "Hotkey (optional)", "", placeholder="click Set, or e.g. ++e") + + # ── Behaviour card ──────────────────────────────────────────────────── + beh_card = _card_section(page, "Behaviour") + self.wf_mode = _labeled(beh_card, "Mode", _combo(["transcribe", "rewrite", "stream"]), + tooltip="’transcribe’ types your words as-is. ‘rewrite’ sends them to the language model first. ‘stream’ shows live text from a realtime engine.") + self.wf_model = _labeled(beh_card, "LLM model (opt.)", _entry(placeholder="blank = active LLM engine model"), + tooltip="Override the language model just for this preset. Leave blank to use the active LLM engine.") + self.wf_temp = _labeled(beh_card, "Temperature (opt.)", _entry(placeholder="blank = engine default (e.g. 0.3)"), + tooltip="Creativity of the rewrite, 0–1. Lower is more predictable. Blank uses the engine default.") + + # ── Prompt ──────────────────────────────────────────────────────────── + _section_title(page, "Prompt sent to the LLM (rewrite mode)", margin_top=14) frame = Gtk.Frame(); frame.set_shadow_type(Gtk.ShadowType.IN) - sw = Gtk.ScrolledWindow(); sw.set_min_content_height(150) + sw = Gtk.ScrolledWindow(); sw.set_min_content_height(140) self.wf_prompt = Gtk.TextView(); self.wf_prompt.set_wrap_mode(Gtk.WrapMode.WORD) - self.wf_prompt.set_left_margin(6); self.wf_prompt.set_top_margin(6) + self.wf_prompt.set_left_margin(8); self.wf_prompt.set_top_margin(6) + self.wf_prompt.set_right_margin(8); self.wf_prompt.set_bottom_margin(6) sw.add(self.wf_prompt); frame.add(sw) - form.pack_start(frame, True, True, 0) + page.pack_start(frame, True, True, 0) self._wf_load(0) def _wf_load(self, idx: int) -> None: @@ -668,6 +777,44 @@ class SettingsDialog: page.pack_start(row, False, False, 0) return entry + def _icon_field_lb(self, lb: Gtk.ListBox, label: str, width: int = 130) -> Gtk.Entry: + """_icon_field variant for use inside a card ListBox.""" + TOOLTIP = ("An emoji shown next to this preset when a voice command matches it — " + "give each preset a distinct one so you can tell at a glance which fired.") + box = Gtk.Box(spacing=10) + box.set_margin_top(6); box.set_margin_bottom(6) + box.set_margin_start(12); box.set_margin_end(8) + lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) + lbl.set_tooltip_text(TOOLTIP) + box.pack_start(lbl, False, False, 0) + entry = Gtk.Entry(); entry.set_hexpand(True) + entry.set_placeholder_text("⚡"); entry.set_tooltip_text(TOOLTIP) + box.pack_start(entry, True, True, 0) + btn = Gtk.Button(label="😀"); btn.set_tooltip_text("Pick an emoji") + btn.connect("clicked", lambda _b: self._show_emoji_picker(btn, entry)) + box.pack_start(btn, False, False, 0) + box.pack_start(_info_btn(TOOLTIP), False, False, 0) + _lb_add(lb, box) + return entry + + def _key_field_lb(self, lb: Gtk.ListBox, label: str, value: str, + placeholder: str = "", width: int = 130) -> Gtk.Entry: + """_key_field variant for use inside a card ListBox.""" + box = Gtk.Box(spacing=10) + box.set_margin_top(6); box.set_margin_bottom(6) + box.set_margin_start(12); box.set_margin_end(8) + lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) + box.pack_start(lbl, False, False, 0) + entry = Gtk.Entry(); entry.set_text(value); entry.set_hexpand(True) + if placeholder: + entry.set_placeholder_text(placeholder) + box.pack_start(entry, True, True, 0) + btn = Gtk.Button(label="Set") + btn.connect("clicked", lambda _b, e=entry: self._bind_key(e)) + box.pack_start(btn, False, False, 0) + _lb_add(lb, box) + return entry + def _show_emoji_picker(self, anchor: Gtk.Widget, entry: Gtk.Entry) -> None: pop = Gtk.Popover(relative_to=anchor) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) @@ -737,19 +884,20 @@ class SettingsDialog: # ===== Engines ========================================================== def _build_engines(self, page: Gtk.Box) -> None: _infobox(page, "Engines do the work. The speech-to-text engine turns your voice into " - "text; the language model rewrites it. Each can run locally on this " - "computer or on a server you enter. A green dot means it is reachable, " - "red means offline. Use Test to try the speech engine.") - page.pack_start(self._stt_section(), False, False, 4) - page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(self._llm_section(), False, False, 4) + "text; the language model rewrites it. Each can run locally or on " + "a server you enter. A green dot means it is reachable, red means offline.") + _section_title(page, "Speech-to-text engine", margin_top=4) + page.pack_start(self._stt_section(), False, False, 0) + _section_title(page, "Language model (rewrite)") + page.pack_start(self._llm_section(), False, False, 0) self._refresh_status() def _stt_section(self) -> Gtk.Box: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.pack_start(Gtk.Label(label="STT Speech-to-text engine", xalign=0.0), False, False, 2) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) self._stt_idx = 0 - bar = Gtk.Box(spacing=8) + + # ── Selector bar ────────────────────────────────────────────────────── + bar = Gtk.Box(spacing=6) self.stt_combo = Gtk.ComboBoxText() for e in self.cfg.stt_engines: self.stt_combo.append_text(e.name) @@ -758,46 +906,52 @@ class SettingsDialog: self.stt_dot = Gtk.Label(); self.stt_dot.set_markup(_dot(GREY)) bar.pack_start(self.stt_dot, False, False, 0) bar.pack_start(self.stt_combo, True, True, 0) - for label, cb in (("+ Add", self._stt_add), ("+ Stream", self._stt_add_stream), - ("Delete", self._stt_delete), ("Test", self._stt_test), - ("Refresh", lambda _b: self._refresh_status())): - b = Gtk.Button(label=label); b.connect("clicked", cb); bar.pack_start(b, False, False, 0) + # Left: creation + for label, cb in (("+ Add", self._stt_add), ("+ Stream", self._stt_add_stream)): + b = Gtk.Button(label=label); b.connect("clicked", cb) + bar.pack_start(b, False, False, 0) qs = Gtk.Button(label="Quickstart ▾") qs.set_tooltip_text("Fill the form from a common service template") qs.connect("clicked", self._show_stt_templates) bar.pack_start(qs, False, False, 0) + # Right: actions + for label, cb, tip in ( + ("Test", self._stt_test, "Record 4 s and transcribe to test this engine"), + ("Delete", self._stt_delete, "Remove this engine preset"), + ("⟳", lambda _b: self._refresh_status(), "Re-check connection status"), + ): + b = Gtk.Button(label=label); b.set_tooltip_text(tip); b.connect("clicked", cb) + bar.pack_end(b, False, False, 0) box.pack_start(bar, False, False, 2) - form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2) - self.stt_name = _labeled(form, "Name", _entry(placeholder="e.g. faster-whisper GPU"), + # ── Engine config card ──────────────────────────────────────────────── + cfg_card = _card_section(box, "", margin_top=6) + self.stt_name = _labeled(cfg_card, "Name", _entry(placeholder="e.g. faster-whisper GPU"), tooltip="A label for this engine, shown in the dropdown.") - self.stt_type = _labeled(form, "Type", _type_combo(_STT_TYPES, "local"), + self.stt_type = _labeled(cfg_card, "Type", _type_combo(_STT_TYPES, "local"), tooltip="Internal: faster-whisper runs inside Blitztext, no server needed. " "Server: any OpenAI-compatible /v1 endpoint on your LAN or a cloud API. " "Realtime: live streaming via NVIDIA Riva or NIM.") - self.stt_url = _url_field(form, "URL", "http://localhost:8010/v1 · realtime: http://localhost:8006/v1", - lambda: self._populate_models(self.stt_model, self.stt_url.get_text().strip(), self.stt_key.get_text().strip())) - self.stt_model = _labeled(form, "Model", _model_combo("blank = server default · tiny/base/small… for local"), + self.stt_url = _url_field_lb(cfg_card, "URL", + "http://localhost:8010/v1 · realtime: http://localhost:8006/v1", + lambda: self._populate_models(self.stt_model, self.stt_url.get_text().strip(), self.stt_key.get_text().strip())) + self.stt_model = _labeled(cfg_card, "Model", _model_combo("tiny/base/small… or server model"), tooltip="Which Whisper model to load. For Internal: tiny (fastest) → large-v3 (most accurate). " "For a server, press ⟳ to fetch available models from its URL.") - self.stt_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. GROQ_API_KEY (optional)"), - tooltip="Name of the environment variable that holds your API key " - "(e.g. GROQ_API_KEY). Leave blank for local servers that don't need one.") + self.stt_key = _labeled(cfg_card, "API key env", _entry(placeholder="e.g. GROQ_API_KEY (optional)"), + tooltip="Name of the environment variable that holds your API key. Leave blank for local servers.") self.stt_url.connect("changed", lambda _e: self._schedule_models("stt")) self.stt_key.connect("changed", lambda _e: self._schedule_models("stt")) self.stt_type.connect("changed", self._stt_type_changed) - sep = Gtk.Separator(); sep.set_margin_top(4); form.pack_start(sep, False, False, 4) - form.pack_start(Gtk.Label(label="Local engine (faster-whisper) — device & precision", xalign=0.0), False, False, 0) - self.stt_device = _labeled(form, "Device", _type_combo(_DEVICE_OPTIONS, self.cfg.device), - tooltip="Which processor runs the speech model. " - "Auto tries your GPU first and falls back to CPU. " - "GPU (CUDA) requires an NVIDIA GPU and CUDA toolkit — much faster than CPU.") - self.stt_compute = _labeled(form, "Compute type", _type_combo(_COMPUTE_OPTIONS, self.cfg.compute_type), - tooltip="Numerical precision of the model. " - "int8 is fastest and uses least memory. " - "float16 is most accurate but needs more GPU memory. " - "Auto lets Blitztext choose based on your hardware.") + # ── Device & precision card ─────────────────────────────────────────── + dev_card = _card_section(box, "Internal engine — device & precision") + self.stt_device = _labeled(dev_card, "Device", _type_combo(_DEVICE_OPTIONS, self.cfg.device), + tooltip="Which processor runs the speech model. " + "Auto tries your GPU first. GPU (CUDA) requires NVIDIA — much faster than CPU.") + self.stt_compute = _labeled(dev_card, "Compute type", _type_combo(_COMPUTE_OPTIONS, self.cfg.compute_type), + tooltip="Precision of the model. int8 is fastest and uses least memory. " + "float16 is most accurate. Auto lets Blitztext decide.") self.stt_result = Gtk.Label(xalign=0.0); self.stt_result.set_line_wrap(True) box.pack_start(self.stt_result, False, False, 2) @@ -805,10 +959,11 @@ class SettingsDialog: return box def _llm_section(self) -> Gtk.Box: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.pack_start(Gtk.Label(label="LLM - Language model (rewrite)", xalign=0.0), False, False, 2) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) self._llm_idx = 0 - bar = Gtk.Box(spacing=8) + + # ── Selector bar ────────────────────────────────────────────────────── + bar = Gtk.Box(spacing=6) self.llm_combo = Gtk.ComboBoxText() for e in self.cfg.llm_engines: self.llm_combo.append_text(e.name) @@ -817,33 +972,36 @@ class SettingsDialog: self.llm_dot = Gtk.Label(); self.llm_dot.set_markup(_dot(GREY)) bar.pack_start(self.llm_dot, False, False, 0) bar.pack_start(self.llm_combo, True, True, 0) - for label, cb in (("+ Add", self._llm_add), ("Delete", self._llm_delete), - ("Refresh", lambda _b: self._refresh_status())): - b = Gtk.Button(label=label); b.connect("clicked", cb); bar.pack_start(b, False, False, 0) + add = Gtk.Button(label="+ Add"); add.connect("clicked", self._llm_add) + bar.pack_start(add, False, False, 0) qs = Gtk.Button(label="Quickstart ▾") qs.set_tooltip_text("Fill the form from a common service template") qs.connect("clicked", self._show_llm_templates) bar.pack_start(qs, False, False, 0) + for label, cb, tip in ( + ("Delete", self._llm_delete, "Remove this engine preset"), + ("⟳", lambda _b: self._refresh_status(), "Re-check connection status"), + ): + b = Gtk.Button(label=label); b.set_tooltip_text(tip); b.connect("clicked", cb) + bar.pack_end(b, False, False, 0) box.pack_start(bar, False, False, 2) - form = Gtk.Box(orientation=Gtk.Orientation.VERTICAL); box.pack_start(form, False, False, 2) - self.llm_name = _labeled(form, "Name", _entry(placeholder="e.g. Local Qwen"), + # ── Engine config card ──────────────────────────────────────────────── + cfg_card = _card_section(box, "", margin_top=6) + self.llm_name = _labeled(cfg_card, "Name", _entry(placeholder="e.g. Local Qwen"), tooltip="A label for this language model engine, shown in the dropdown.") - self.llm_type = _labeled(form, "Type", _type_combo(_LLM_TYPES, "cloud"), - tooltip="LAN server: a model running on your own machine or network " - "(Ollama, vLLM, LM Studio, llama-swap, …). " + self.llm_type = _labeled(cfg_card, "Type", _type_combo(_LLM_TYPES, "cloud"), + tooltip="LAN server: a model on your own machine or network (Ollama, vLLM, LM Studio, …). " "Cloud service: a remote paid API like OpenAI or Groq.") - self.llm_url = _url_field(form, "Base URL", "http://localhost:28080/v1 · https://api.openai.com/v1", - lambda: self._populate_models(self.llm_model, self.llm_url.get_text().strip(), self.llm_key.get_text().strip())) - self.llm_model = _labeled(form, "Model", _model_combo("pick after entering URL"), - tooltip="Which language model to use for rewriting. Press ⟳ on the URL field " - "to fetch available models from the server.") - self.llm_key = _labeled(form, "API key env", _entry(placeholder="env var name, e.g. OPENAI_API_KEY (blank for local)"), - tooltip="Name of the environment variable holding your API key " - "(e.g. OPENAI_API_KEY). Leave blank for local servers.") - self.llm_temp = _labeled(form, "Temperature", _entry(placeholder="0.3"), - tooltip="How creative the rewrite is, from 0.0 (very predictable) to 1.0 (more varied). " - "0.3 is a good default — lower for formal text, higher for creative writing.") + self.llm_url = _url_field_lb(cfg_card, "Base URL", + "http://localhost:28080/v1 · https://api.openai.com/v1", + lambda: self._populate_models(self.llm_model, self.llm_url.get_text().strip(), self.llm_key.get_text().strip())) + self.llm_model = _labeled(cfg_card, "Model", _model_combo("pick after entering URL"), + tooltip="Which language model to use for rewriting. Press ⟳ on the URL field to fetch available models.") + self.llm_key = _labeled(cfg_card, "API key env", _entry(placeholder="e.g. OPENAI_API_KEY (blank for local)"), + tooltip="Name of the environment variable holding your API key. Leave blank for local servers.") + self.llm_temp = _labeled(cfg_card, "Temperature", _entry(placeholder="0.3"), + tooltip="How creative the rewrite is, 0.0–1.0. 0.3 is a good default.") self.llm_url.connect("changed", lambda _e: self._schedule_models("llm")) self.llm_key.connect("changed", lambda _e: self._schedule_models("llm")) self._llm_load(self.llm_combo.get_active()) @@ -1166,106 +1324,93 @@ class SettingsDialog: # ===== Input ============================================================ def _build_input(self, page: Gtk.Box) -> None: _infobox(page, "Choose how you start and stop dictating. With the default " - "‘modifiers’ mode: hold Ctrl and the Windows key to talk, then press " + "’modifiers’ mode: hold Ctrl and the Windows key to talk, then press " "Ctrl to stop and paste. Below you can tune the noise filter, set up " "hands-free wakeword, and pick sounds that confirm start and stop.") - LW = 175 # uniform label width for all rows in this tab - self.in_mode = _labeled(page, "Input mode", _combo(["modifiers", "hotkeys"], self.cfg.input_mode), + LW = 170 + + # ── Input mode card ─────────────────────────────────────────────────── + mode_card = _card_section(page, "Input mode & keys", margin_top=4) + self.in_mode = _labeled(mode_card, "Input mode", _combo(["modifiers", "hotkeys"], self.cfg.input_mode), width=LW, - tooltip="’modifiers’: hold/press the keys below. ‘hotkeys’: each preset has its own shortcut combo.") - self.in_ptt = Gtk.Switch(); self.in_ptt.set_active(self.cfg.push_to_talk); self.in_ptt.set_halign(Gtk.Align.START) - _labeled(page, "Push-to-talk", self.in_ptt, width=LW) - self.in_start = self._key_field(page, "Start", self.cfg.key_start, width=LW) - self.in_stop = self._key_field(page, "Stop + paste", self.cfg.key_stop, width=LW) - self.in_send = self._key_field(page, "Stop + paste + Enter", self.cfg.key_send, width=LW) - self.in_cancel = self._key_field(page, "Cancel", self.cfg.key_cancel, width=LW) - page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(Gtk.Label(label="Quality gate", xalign=0.0), False, False, 2) - self.q_min = _labeled(page, "Min seconds", _entry(self.cfg.min_speech_seconds), width=LW, - tooltip="Minimum audio length. Shorter clips are ignored.") - self.q_rms = _labeled(page, "Silence RMS", _entry(self.cfg.silence_rms), width=LW, - tooltip="Microphone volume threshold to ignore background noise.") - self.q_halluc = Gtk.Switch(); self.q_halluc.set_active(self.cfg.reject_hallucinations); self.q_halluc.set_halign(Gtk.Align.START) - _labeled(page, "Reject hallucinations", self.q_halluc, width=LW, - tooltip="Automatically drop STT ghost outputs like ‘Thank you.’ or ‘Bye.’") - self.q_strip = Gtk.Switch(); self.q_strip.set_active(self.cfg.strip_trailing_punctuation); self.q_strip.set_halign(Gtk.Align.START) - _labeled(page, "Strip trailing punctuation", self.q_strip, width=LW, - tooltip="Remove ending periods from pasted text, useful for code inserts.") - page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(Gtk.Label(label="WW - Wakeword (Hands-free)", xalign=0.0), False, False, 2) - self.ww_enabled = Gtk.Switch(); self.ww_enabled.set_active(self.cfg.wakeword_enabled); self.ww_enabled.set_halign(Gtk.Align.START) - _labeled(page, "Enable wakeword", self.ww_enabled, width=LW) + tooltip="’modifiers’: hold/press the keys below — easiest for most people. " + "’hotkeys’: each preset has its own shortcut combo (set per preset).") + self.in_ptt = Gtk.Switch(); self.in_ptt.set_active(self.cfg.push_to_talk) + _switch_row(mode_card, "Push-to-talk", self.in_ptt, width=LW, + description="Hold the Start key to record; release to stop. " + "Off = the key toggles recording on/off.") + self.in_start = self._key_field_lb(mode_card, "Start", self.cfg.key_start, width=LW) + self.in_stop = self._key_field_lb(mode_card, "Stop + paste", self.cfg.key_stop, width=LW) + self.in_send = self._key_field_lb(mode_card, "Stop + paste + Enter", self.cfg.key_send, width=LW) + self.in_cancel = self._key_field_lb(mode_card, "Cancel", self.cfg.key_cancel, width=LW) + + # ── Quality gate card ───────────────────────────────────────────────── + q_card = _card_section(page, "Quality gate") + self.q_min = _labeled(q_card, "Min seconds", _entry(self.cfg.min_speech_seconds), width=LW, + tooltip="Minimum audio length. Shorter clips are silently ignored (avoids pasting nothing).") + self.q_rms = _labeled(q_card, "Silence RMS", _entry(self.cfg.silence_rms), width=LW, + tooltip="Microphone volume threshold below which a clip is considered silent and dropped.") + self.q_halluc = Gtk.Switch(); self.q_halluc.set_active(self.cfg.reject_hallucinations) + _switch_row(q_card, "Reject hallucinations", self.q_halluc, width=LW, + description="Drop STT ghost outputs like ‘Thank you.’ or ‘Bye.’ that Whisper invents from silence.") + self.q_strip = Gtk.Switch(); self.q_strip.set_active(self.cfg.strip_trailing_punctuation) + _switch_row(q_card, "Strip trailing . / ,", self.q_strip, width=LW, + description="Remove ending punctuation from pasted text — handy for code insertion.") + + # ── Wakeword card ───────────────────────────────────────────────────── + ww_card = _card_section(page, "Hands-free wakeword") + self.ww_enabled = Gtk.Switch(); self.ww_enabled.set_active(self.cfg.wakeword_enabled) + _switch_row(ww_card, "Enable wakeword", self.ww_enabled, width=LW, + description="Start dictation with a spoken keyword via an external openWakeWord server.") self.ww_dot = Gtk.Label(); self.ww_dot.set_markup(_dot(GREY)) - self.ww_dot.set_tooltip_text("Wakeword (Wyoming / openWakeWord) server: " - "green = reachable, red = unreachable") - self.ww_uri = _url_field(page, "Wakeword engine", "tcp://127.0.0.1:10400", - self._ww_load, dot=self.ww_dot, width=LW) + self.ww_dot.set_tooltip_text("openWakeWord server: green = reachable, red = unreachable") + self.ww_uri = _url_field_lb(ww_card, "Wakeword server", "tcp://127.0.0.1:10400", + self._ww_load, dot=self.ww_dot, width=LW) self.ww_uri.set_text(self.cfg.wakeword_uri) self.ww_uri.connect("focus-out-event", self._on_ww_uri_leave) self._probe_dot(self.ww_dot, self.cfg.wakeword_uri, 10400) - self.ww_model = _labeled(page, "Wakeword Model name", _model_combo("Search models…"), width=LW) + self.ww_model = _labeled(ww_card, "Model name", _model_combo("Search models…"), width=LW, + tooltip="Which wake model to listen for (e.g. computer, okay_computer). Press ⟳ on the URI field to load models from the server.") _fill_combo(self.ww_model, [], self.cfg.wakeword_model) - self.ww_mic_level = Gtk.LevelBar(); self.ww_mic_level.set_min_value(0); self.ww_mic_level.set_max_value(1) - _labeled(page, "Input level", self.ww_mic_level, width=LW) + self.ww_mic_level = Gtk.LevelBar() + self.ww_mic_level.set_min_value(0); self.ww_mic_level.set_max_value(1) + _labeled(ww_card, "Input level", self.ww_mic_level, width=LW, + tooltip="Live microphone level — the bar should move when you speak.") - self.ww_test_btn = Gtk.Button(label="Test Wakeword"); self.ww_test_btn.set_halign(Gtk.Align.START) + self.ww_test_btn = Gtk.Button(label="Test wakeword"); self.ww_test_btn.set_halign(Gtk.Align.START) self.ww_test_btn.connect("clicked", self._ww_test) self.ww_test_lbl = Gtk.Label(label=""); self.ww_test_lbl.set_xalign(0.0) - box = Gtk.Box(spacing=10); box.pack_start(self.ww_test_btn, False, False, 0); box.pack_start(self.ww_test_lbl, False, False, 0) - _labeled(page, "", box, width=LW) + test_box = Gtk.Box(spacing=10) + test_box.pack_start(self.ww_test_btn, False, False, 0) + test_box.pack_start(self.ww_test_lbl, False, False, 0) + _labeled(ww_card, "", test_box, width=LW) - self.ww_silence = _labeled(page, "Silence to stop (s)", _entry(self.cfg.wakeword_silence_seconds), width=LW, - tooltip="After the wakeword starts recording, end it this many seconds " - "after you stop speaking. Hands-free auto-stop — the wakeword " - "can’t be released like a key. Default 2.0.") + self.ww_silence = _labeled(ww_card, "Silence to stop (s)", _entry(self.cfg.wakeword_silence_seconds), width=LW, + tooltip="After the wakeword fires, stop recording this many seconds after you stop speaking. Default 2.0.") + self.cancel_keywords = _labeled(ww_card, "Cancel words", _entry(", ".join(self.cfg.cancel_keywords), + placeholder="abbrechen, cancel"), width=LW, + tooltip="Say one of these at the start or end of a clip to DISCARD it — nothing is typed. Empty = off.") + self.send_keywords = _labeled(ww_card, "Send words", _entry(", ".join(self.cfg.send_keywords), + placeholder="computer send, computer abschicken"), width=LW, + tooltip="Say one of these to type AND press Enter (spoken ‘submit’). Use a distinctive multi-word phrase. Empty = off.") - self.cancel_keywords = _labeled( - page, "Cancel words (comma)", - _entry(", ".join(self.cfg.cancel_keywords), placeholder="abbrechen, cancel"), - width=LW, - tooltip="Say one of these at the start or end of a clip to DISCARD it — " - "nothing is transcribed onward, routed, rewritten, or typed. " - "Rescues an accidentally triggered dictation. Empty = off.") + # ── Wakeword sound cues ─────────────────────────────────────────────── + ww_snd_card = _card_section(page, "Wakeword sound cues (hands-free only)") + self.ww_snd_detected = self._sound_field(ww_snd_card, "Wakeword detected", self.cfg.wakeword_sound_detected, + "Plays the instant the wake word fires — your ‘speak now’ cue. Leave empty for no sound.", width=LW) + self.ww_snd_done = self._sound_field(ww_snd_card, "Speech captured", self.cfg.wakeword_sound_done, + "Plays when your spoken command is captured and recording stops.", width=LW) - self.send_keywords = _labeled( - page, "Send words (comma)", - _entry(", ".join(self.cfg.send_keywords), placeholder="computer send, computer abschicken"), - width=LW, - tooltip="Say one of these at the start or end of a clip to SEND it: the word is " - "stripped and the rest is typed AND submitted with Enter (spoken " - "’stop+paste+Enter’). Because it presses Enter, use a distinctive " - "multi-word phrase (e.g. your wakeword + ‘send’). Empty = off.") - - self.ww_snd_detected = self._sound_field( - page, "Wakeword detected", self.cfg.wakeword_sound_detected, - "HANDS-FREE ONLY. Plays the instant the wake word is recognised and recording starts " - "— your ‘speak now’ cue. (Keyboard/hotkey dictation ignores this and uses ‘Play before’.)", - empty_note="Leave empty for no sound. These wakeword cues are independent of the " - "’Play audio cues’ switch below.", - clear_tip="Clear — no sound", width=LW) - self.ww_snd_done = self._sound_field( - page, "Speech captured", self.cfg.wakeword_sound_done, - "HANDS-FREE ONLY. Plays when your spoken command is captured and recording stops " - "(on silence or stop). (Keyboard/hotkey dictation ignores this and uses ‘Play after’.)", - empty_note="Leave empty for no sound.", - clear_tip="Clear — no sound", width=LW) - - page.pack_start(Gtk.Separator(), False, False, 8) - page.pack_start(Gtk.Label(label="Audio cues (manual dictation)", xalign=0.0), False, False, 2) - self.snd_enabled = Gtk.Switch(); self.snd_enabled.set_active(self.cfg.sounds_enabled); self.snd_enabled.set_halign(Gtk.Align.START) - _labeled(page, "Play audio cues", self.snd_enabled, width=LW, - tooltip="On/off for the MANUAL start/stop chimes below (keyboard/hotkey dictation). " - "The hands-free wakeword sounds above are separate and always play when set.") - self.snd_before = self._sound_field( - page, "Hotkey detected", self.cfg.sound_before, - "MANUAL (keyboard/hotkey) dictation only. Plays when recording starts. " - "(Hands-free sessions use ‘Sound: detected’ instead.)", width=LW) - self.snd_after = self._sound_field( - page, "Capture done", self.cfg.sound_after, - "MANUAL (keyboard/hotkey) dictation only. Plays when recording stops " - "(paste, paste+Enter, or auto-stop on silence). (Hands-free uses ‘Sound: captured’ instead.)", - width=LW) + # ── Manual dictation sounds card ────────────────────────────────────── + snd_card = _card_section(page, "Audio cues (keyboard / hotkey dictation)") + self.snd_enabled = Gtk.Switch(); self.snd_enabled.set_active(self.cfg.sounds_enabled) + _switch_row(snd_card, "Play audio cues", self.snd_enabled, width=LW, + description="On/off for the manual start/stop chimes below. Does not affect wakeword sounds.") + self.snd_before = self._sound_field(snd_card, "Start sound", self.cfg.sound_before, + "Plays when manual recording starts.", width=LW) + self.snd_after = self._sound_field(snd_card, "Stop sound", self.cfg.sound_after, + "Plays when manual recording stops (paste, paste+Enter, or auto-stop).", width=LW) def _sound_field(self, page: Gtk.Box, label: str, value: str, tooltip: str = "", empty_note: str = "Leave empty to use the built-in system sound.", @@ -1295,7 +1440,13 @@ class SettingsDialog: clr.set_tooltip_text(clear_tip) clr.connect("clicked", lambda _b, c=chooser: c.unselect_all()) row.pack_start(clr, False, False, 0) - page.pack_start(row, False, False, 0) + if isinstance(page, Gtk.ListBox): + if not row.get_margin_start(): + row.set_margin_start(12); row.set_margin_end(8) + row.set_margin_top(6); row.set_margin_bottom(6) + _lb_add(page, row) + else: + page.pack_start(row, False, False, 0) return chooser def _play_sound_file(self, path) -> None: @@ -1305,37 +1456,46 @@ class SettingsDialog: # ===== General ========================================================== def _build_general(self, page: Gtk.Box) -> None: - _infobox(page, "General options: choose your microphone and watch its level bar move " - "when you speak, set how the text is delivered, the spoken language, " - "desktop notifications, and whether Blitztext starts automatically " - "when you log in.") + _infobox(page, "Choose your microphone, how text is delivered, the spoken language, " + "and whether Blitztext starts automatically when you log in.") + + # ── Microphone card ─────────────────────────────────────────────────── + mic_card = _card_section(page, "Microphone", margin_top=4) self._mics = audio.list_mics() names = [label for _, label in self._mics] - cur = next((lbl for nm, lbl in self._mics if nm == self.cfg.mic), names[0]) - self.gen_mic = _labeled(page, "Microphone", _combo(names, cur), + cur = next((lbl for nm, lbl in self._mics if nm == self.cfg.mic), names[0] if names else "") + self.gen_mic = _labeled(mic_card, "Device", _combo(names, cur), tooltip="Which microphone Blitztext records from.") - - self.mic_level = Gtk.LevelBar(); self.mic_level.set_min_value(0); self.mic_level.set_max_value(1) - _labeled(page, "Input level", self.mic_level, - tooltip="Live microphone level. The bar should move when you speak.") + self.mic_level = Gtk.LevelBar() + self.mic_level.set_min_value(0); self.mic_level.set_max_value(1) + _labeled(mic_card, "Input level", self.mic_level, + tooltip="Live microphone level — should move when you speak.") self.gen_mic.connect("changed", lambda _c: self._restart_meter()) - self.gen_output = _labeled(page, "Output", _combo(["type", "paste"], self.cfg.output), - tooltip="‘type’ types the text key by key. ‘paste’ copies it and presses Ctrl+V (faster for long text).") - self.gen_lang = _labeled(page, "Language hint", _entry(self.cfg.language, placeholder="de, en, … (blank = autodetect)"), + # ── Output card ─────────────────────────────────────────────────────── + out_card = _card_section(page, "Text output & language") + self.gen_output = _labeled(out_card, "Output mode", _combo(["type", "paste"], self.cfg.output), + tooltip="’type’ types the text key by key via xdotool. " + "’paste’ copies it to the clipboard and presses Ctrl+V (faster for long text).") + self.gen_lang = _labeled(out_card, "Language hint", _entry(self.cfg.language, placeholder="de, en, … (blank = autodetect)"), tooltip="Spoken language code (de, en, …). Leave blank to auto-detect.") + + # ── Notifications card ──────────────────────────────────────────────── + notif_card = _card_section(page, "Notifications & overlay") self.gen_notify = Gtk.Switch(); self.gen_notify.set_active(self.cfg.notify) - _switch_row(page, "Notifications", self.gen_notify, + _switch_row(notif_card, "Notifications", self.gen_notify, "Desktop pop-ups for recording, transcription, and errors (manual dictation).") self.gen_notify_routing = Gtk.Switch(); self.gen_notify_routing.set_active(self.cfg.notify_routing) - _switch_row(page, "Announce preset", self.gen_notify_routing, + _switch_row(notif_card, "Announce preset", self.gen_notify_routing, "After a voice command, show which preset and keyword matched — even hands-free.") self.gen_overlay = Gtk.Switch(); self.gen_overlay.set_active(self.cfg.overlay_enabled) - _switch_row(page, "Visual overlay", self.gen_overlay, - "Show a microphone, a live waveform, and the recognised text in a bubble " - "at the cursor while you dictate (also gives hands-free sessions feedback).") + _switch_row(notif_card, "Visual overlay", self.gen_overlay, + "Show a microphone, live waveform, and recognised text in a bubble at the cursor while you dictate.") + + # ── Startup card ────────────────────────────────────────────────────── + start_card = _card_section(page, "Startup") self.gen_boot = Gtk.Switch(); self.gen_boot.set_active(autostart.is_enabled()) - _switch_row(page, "Launch on login", self.gen_boot, + _switch_row(start_card, "Launch on login", self.gen_boot, "Start Blitztext automatically when you log in.") self._start_meter()