diff --git a/AGENTS.md b/AGENTS.md index c5c5bc3..6d9c499 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,6 @@ - Install deps: `uv sync`. - Run daemon: `uv run python3 src/leld.py --config ~/.config/lel/config.json`. -- Open settings: `uv run python3 src/leld.py --settings --config ~/.config/lel/config.json`. ## Coding Style & Naming Conventions diff --git a/README.md b/README.md index 22c644f..6256bd3 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,6 @@ Run: uv run python3 src/leld.py --config ~/.config/lel/config.json ``` -Open settings: - -```bash -uv run python3 src/leld.py --settings --config ~/.config/lel/config.json -``` - ## Config Create `~/.config/lel/config.json`: @@ -63,7 +57,7 @@ Env overrides: - `LEL_AI_CLEANUP_BASE_URL`, `LEL_AI_CLEANUP_API_KEY` Recording input can be a device index (preferred) or a substring of the device -name. Use the settings window to list available input devices. +name. ## systemd user service diff --git a/src/leld.py b/src/leld.py index 095a541..6566bc5 100755 --- a/src/leld.py +++ b/src/leld.py @@ -17,7 +17,6 @@ from context import I3Provider from inject import inject from x11_hotkey import listen from tray import run_tray -from settings_window import open_settings_window class State: @@ -230,7 +229,6 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--config", default="", help="path to config.json") parser.add_argument("--no-tray", action="store_true", help="disable tray icon") - parser.add_argument("--settings", action="store_true", help="open settings window and exit") parser.add_argument("--dry-run", action="store_true", help="log hotkey only") args = parser.parse_args() @@ -238,21 +236,12 @@ def main(): cfg = load(args.config) config_path = Path(args.config) if args.config else Path.home() / ".config" / "lel" / "config.json" - if args.settings: - open_settings_window(cfg, config_path) - import gi - gi.require_version("Gtk", "3.0") - from gi.repository import Gtk # type: ignore[import-not-found] - Gtk.main() - return - _lock_single_instance() logging.info("ready (hotkey: %s)", cfg.daemon.get("hotkey", "")) logging.info("config (%s):\n%s", args.config or str(Path.home() / ".config" / "lel" / "config.json"), json.dumps(redacted_dict(cfg), indent=2)) daemon = Daemon(cfg) - suppress_hotkeys = threading.Event() def on_quit(): os._exit(0) @@ -271,28 +260,18 @@ def main(): if args.no_tray: listen( cfg.daemon.get("hotkey", ""), - lambda: logging.info("hotkey pressed (dry-run)") - if args.dry_run - else (None if suppress_hotkeys.is_set() else daemon.toggle()), + lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(), ) return threading.Thread( target=lambda: listen( cfg.daemon.get("hotkey", ""), - lambda: logging.info("hotkey pressed (dry-run)") - if args.dry_run - else (None if suppress_hotkeys.is_set() else daemon.toggle()), + lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(), ), daemon=True, ).start() - def open_settings(): - suppress_hotkeys.set() - win = open_settings_window(load(args.config), config_path) - win.window.connect("destroy", lambda *_: suppress_hotkeys.clear()) - return win - - run_tray(daemon.get_state, on_quit, open_settings) + run_tray(daemon.get_state, on_quit, None) if __name__ == "__main__": diff --git a/src/settings_window.py b/src/settings_window.py deleted file mode 100644 index affc615..0000000 --- a/src/settings_window.py +++ /dev/null @@ -1,551 +0,0 @@ -from __future__ import annotations - -import json -import time -from dataclasses import asdict -from pathlib import Path - -import gi - -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") - -from gi.repository import Gdk, Gtk # type: ignore[import-not-found] - -from config import Config, validate -from recorder import default_input_device, list_input_devices, resolve_input_device -from aiprocess import list_models - - -class SettingsWindow: - def __init__(self, cfg: Config, config_path: Path): - self.cfg = cfg - self.config_path = config_path - self._model_cache: dict[str, list[str]] = {} - self.window = Gtk.Window(title="lel settings") - self.window.set_default_size(920, 700) - self.window.set_position(Gtk.WindowPosition.CENTER) - self.window.set_type_hint(Gdk.WindowTypeHint.DIALOG) - - self.error_label = Gtk.Label() - self.error_label.set_xalign(0.0) - self.error_label.get_style_context().add_class("error") - - self.notebook = Gtk.Notebook() - self.widgets: dict[str, Gtk.Widget] = {} - - self._build_tabs() - - btn_save = Gtk.Button(label="Save") - btn_save.connect("clicked", self._on_save) - btn_cancel = Gtk.Button(label="Cancel") - btn_cancel.connect("clicked", lambda *_: self.window.destroy()) - - btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - btn_row.pack_end(btn_save, False, False, 0) - btn_row.pack_end(btn_cancel, False, False, 0) - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - vbox.set_border_width(12) - vbox.pack_start(self.error_label, False, False, 0) - vbox.pack_start(self.notebook, True, True, 0) - vbox.pack_start(btn_row, False, False, 0) - - self.window.add(vbox) - self.window.show_all() - - def _on_quick_run(self, *_args): - buf = self.quick_text.get_buffer() - start, end = buf.get_bounds() - text = buf.get_text(start, end, True).strip() - if not text: - self.widgets["quick_status"].set_text("No input text") - return - language = "en" - output = text - steps = self._collect_quick_steps() - if not steps: - self.widgets["quick_status"].set_text("No AI steps") - return - from aiprocess import AIConfig, build_processor - - for idx, step in enumerate(steps, 1): - prompt_text = step.get("prompt_text") or "" - if prompt_text: - from aiprocess import GenericAPIProcessor - - processor = GenericAPIProcessor( - AIConfig( - model=step["model"], - temperature=step["temperature"], - system_prompt_file="", - base_url=step["base_url"], - api_key=step["api_key"], - timeout_sec=25, - language_hint=language, - ) - ) - processor.system = prompt_text - else: - processor = build_processor( - AIConfig( - model=step["model"], - temperature=step["temperature"], - system_prompt_file="", - base_url=step["base_url"], - api_key=step["api_key"], - timeout_sec=25, - language_hint=language, - ) - ) - output = processor.process(output) - self.widgets["quick_status"].set_text("Done") - - def _collect_quick_steps(self) -> list[dict]: - steps: list[dict] = [] - for row in self.quick_steps.get_children(): - e = row._lel_step_entries - model = e["model_entry"].get_text().strip() - combo = e["model_combo"] - if combo.get_visible(): - combo_text = combo.get_active_text() - if combo_text: - model = combo_text - prompt_buf = e["prompt_text"].get_buffer() - start, end = prompt_buf.get_bounds() - prompt_text = prompt_buf.get_text(start, end, True).strip() - steps.append( - { - "model": model or self.cfg.ai_cleanup.get("model", ""), - "temperature": float(e["temperature"].get_value()), - "prompt_text": prompt_text, - "base_url": e["base_url"].get_text().strip() or self.cfg.ai_cleanup.get("base_url", ""), - "api_key": e["api_key"].get_text().strip() or self.cfg.ai_cleanup.get("api_key", ""), - "timeout": 25, - } - ) - return steps - - def _build_tabs(self): - self._add_tab("Settings", self._build_settings_tab()) - self._add_tab("Quick Run", self._build_quick_run_tab()) - - def _add_tab(self, title: str, widget: Gtk.Widget): - label = Gtk.Label(label=title) - self.notebook.append_page(widget, label) - - def _grid(self) -> Gtk.Grid: - grid = Gtk.Grid() - grid.set_row_spacing(8) - grid.set_column_spacing(12) - grid.set_margin_top(8) - grid.set_margin_bottom(8) - grid.set_margin_start(8) - grid.set_margin_end(8) - return grid - - def _entry(self, value: str) -> Gtk.Entry: - entry = Gtk.Entry() - entry.set_text(value or "") - return entry - - def _spin(self, value: int, min_val: int, max_val: int) -> Gtk.SpinButton: - adj = Gtk.Adjustment(value=value, lower=min_val, upper=max_val, step_increment=1, page_increment=10) - spin = Gtk.SpinButton(adjustment=adj, climb_rate=1, digits=0) - return spin - - def _float_spin(self, value: float, min_val: float, max_val: float, step: float) -> Gtk.SpinButton: - adj = Gtk.Adjustment(value=value, lower=min_val, upper=max_val, step_increment=step, page_increment=0.1) - spin = Gtk.SpinButton(adjustment=adj, climb_rate=0.1, digits=2) - return spin - - def _combo(self, options: list[str], value: str) -> Gtk.ComboBoxText: - combo = Gtk.ComboBoxText() - for opt in options: - combo.append_text(opt) - combo.set_active(options.index(value) if value in options else 0) - return combo - - def _row(self, grid: Gtk.Grid, row: int, label: str, widget: Gtk.Widget): - lbl = Gtk.Label(label=label) - lbl.set_xalign(0.0) - grid.attach(lbl, 0, row, 1, 1) - grid.attach(widget, 1, row, 1, 1) - - def _build_hotkeys_tab(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - grid = self._grid() - current_hotkey = self.cfg.daemon.get("hotkey", "") - self.widgets["hotkey_value"] = Gtk.Label(label=current_hotkey or "(not set)") - self.widgets["hotkey_value"].set_xalign(0.0) - set_btn = Gtk.Button(label="Set Hotkey") - set_btn.connect("clicked", lambda *_: self._capture_hotkey()) - hotkey_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - hotkey_row.pack_start(self.widgets["hotkey_value"], True, True, 0) - hotkey_row.pack_start(set_btn, False, False, 0) - self._row(grid, 0, "Hotkey", hotkey_row) - box.pack_start(grid, False, False, 0) - return box - - def _build_recording_tab(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - grid = self._grid() - self.widgets["input_device"] = Gtk.ComboBoxText() - self._populate_mic_sources() - refresh_btn = Gtk.Button(label="Refresh") - refresh_btn.connect("clicked", lambda *_: self._populate_mic_sources()) - mic_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - mic_row.pack_start(self.widgets["input_device"], True, True, 0) - mic_row.pack_start(refresh_btn, False, False, 0) - self._row(grid, 0, "Input Device", mic_row) - # Record timeout is fixed (300s); no UI control. - box.pack_start(grid, False, False, 0) - return box - - def _selected_mic_source(self) -> str: - combo = self.widgets["input_device"] - text = (combo.get_active_text() or "").strip() - if ":" in text: - prefix = text.split(":", 1)[0] - if prefix.isdigit(): - return prefix - return self.cfg.recording.get("input", "") - - def _populate_mic_sources(self): - combo: Gtk.ComboBoxText = self.widgets["input_device"] - combo.remove_all() - devices = list_input_devices() - selected_spec = self.cfg.recording.get("input") or "" - selected_device = resolve_input_device(selected_spec) - default_device = default_input_device() - selected_index = 0 - for idx, device in enumerate(devices): - label = f"{device['index']}: {device['name']}" - combo.append_text(label) - if selected_device is not None and device["index"] == selected_device: - selected_index = idx - elif selected_device is None and default_device is not None and device["index"] == default_device: - selected_index = idx - if devices: - combo.set_active(selected_index) - else: - combo.append_text("default (system)") - combo.set_active(0) - - def _build_stt_tab(self) -> Gtk.Widget: - grid = self._grid() - models = ["tiny", "base", "small", "medium", "large", "large-v2", "large-v3"] - model_value = self.cfg.transcribing.get("model", "base") - self.widgets["whisper_model"] = self._combo(models, model_value if model_value in models else "base") - device_value = (self.cfg.transcribing.get("device", "cpu") or "cpu").lower() - self.widgets["whisper_device"] = self._combo(["cpu", "cuda"], device_value if device_value in {"cpu", "cuda"} else "cpu") - self._row(grid, 0, "Model", self.widgets["whisper_model"]) - self._row(grid, 1, "Device", self.widgets["whisper_device"]) - return grid - - def _build_injection_tab(self) -> Gtk.Widget: - grid = self._grid() - backend_value = self.cfg.injection.get("backend", "clipboard") - self.widgets["injection_backend"] = self._combo(["clipboard", "injection"], backend_value) - self._row(grid, 0, "Injection Backend", self.widgets["injection_backend"]) - return grid - - def _build_ai_tab(self) -> Gtk.Widget: - grid = self._grid() - self.widgets["ai_enabled"] = Gtk.CheckButton() - self.widgets["ai_enabled"].set_active(self.cfg.ai_cleanup.get("enabled", False)) - self.widgets["ai_model"] = self._entry(self.cfg.ai_cleanup.get("model", "")) - self.widgets["ai_temperature"] = self._float_spin(float(self.cfg.ai_cleanup.get("temperature", 0.0)), 0.0, 2.0, 0.05) - self.widgets["ai_base_url"] = self._entry(self.cfg.ai_cleanup.get("base_url", "")) - self.widgets["ai_api_key"] = self._entry(self.cfg.ai_cleanup.get("api_key", "")) - self.widgets["ai_api_key"].set_visibility(False) - self._row(grid, 0, "AI Enabled", self.widgets["ai_enabled"]) - self._row(grid, 1, "AI Cleanup Model", self.widgets["ai_model"]) - self._row(grid, 2, "AI Cleanup Temperature", self.widgets["ai_temperature"]) - self._row(grid, 3, "AI Cleanup Base URL", self.widgets["ai_base_url"]) - self._row(grid, 4, "AI Cleanup API Key", self.widgets["ai_api_key"]) - # AI timeout is fixed (25s); no UI control. - return grid - - def _build_quick_run_tab(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - header = Gtk.Label(label="Bypass recording and run from text") - header.set_xalign(0.0) - box.pack_start(header, False, False, 0) - - self.quick_text = Gtk.TextView() - self.quick_text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - scroll = Gtk.ScrolledWindow() - scroll.add(self.quick_text) - scroll.set_size_request(600, 140) - box.pack_start(scroll, True, True, 0) - - opts = self._grid() - self.widgets["quick_language"] = self._entry("en") - self.widgets["quick_language"].set_editable(False) - self._row(opts, 0, "Language Hint", self.widgets["quick_language"]) - box.pack_start(opts, False, False, 0) - - steps_label = Gtk.Label(label="AI Steps (run in order)") - steps_label.set_xalign(0.0) - box.pack_start(steps_label, False, False, 0) - - self.quick_steps = Gtk.ListBox() - self.quick_steps.set_selection_mode(Gtk.SelectionMode.NONE) - self.quick_steps.set_can_focus(False) - self._add_quick_step_row( - { - "model": self.cfg.ai_cleanup.get("model", ""), - "temperature": self.cfg.ai_cleanup.get("temperature", 0.0), - "base_url": self.cfg.ai_cleanup.get("base_url", ""), - "api_key": self.cfg.ai_cleanup.get("api_key", ""), - "timeout": 25, - } - ) - box.pack_start(self.quick_steps, False, False, 0) - - step_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - add_btn = Gtk.Button(label="Add Step") - add_btn.connect("clicked", lambda *_: self._add_quick_step_row({})) - step_actions.pack_start(add_btn, False, False, 0) - box.pack_start(step_actions, False, False, 0) - - action = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - run_btn = Gtk.Button(label="Run") - run_btn.connect("clicked", self._on_quick_run) - action.pack_start(run_btn, False, False, 0) - self.widgets["quick_status"] = Gtk.Label(label="") - self.widgets["quick_status"].set_xalign(0.0) - action.pack_start(self.widgets["quick_status"], True, True, 0) - box.pack_start(action, False, False, 0) - return box - - def _add_quick_step_row(self, step: dict): - row = Gtk.ListBoxRow() - row.set_activatable(False) - row.set_selectable(False) - row.set_can_focus(False) - content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - content.set_can_focus(False) - grid = self._grid() - model_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - model_combo = Gtk.ComboBoxText() - model_entry = self._entry(step.get("model", self.cfg.ai_cleanup.get("model", ""))) - model_box.pack_start(model_combo, True, True, 0) - model_box.pack_start(model_entry, True, True, 0) - temperature = self._float_spin(step.get("temperature", self.cfg.ai_cleanup.get("temperature", 0.0)), 0.0, 2.0, 0.05) - prompt_text = Gtk.TextView() - prompt_text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - prompt_buf = prompt_text.get_buffer() - prompt_buf.set_text(step.get("prompt_text", "")) - prompt_scroll = Gtk.ScrolledWindow() - prompt_scroll.set_size_request(400, 120) - prompt_scroll.add(prompt_text) - base_url = self._entry(step.get("base_url", self.cfg.ai_cleanup.get("base_url", ""))) - api_key = self._entry(step.get("api_key", self.cfg.ai_cleanup.get("api_key", ""))) - api_key.set_visibility(False) - self._row(grid, 0, "AI Model", model_box) - self._row(grid, 1, "AI Temperature", temperature) - self._row(grid, 2, "AI Prompt", prompt_scroll) - self._row(grid, 3, "AI Base URL", base_url) - self._row(grid, 4, "AI API Key", api_key) - # AI timeout is fixed (25s); no UI control. - base_url.connect("changed", lambda *_: self._refresh_models_for_row(row)) - - controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - btn_up = Gtk.Button(label="Up") - btn_down = Gtk.Button(label="Down") - btn_remove = Gtk.Button(label="Remove") - btn_up.connect("clicked", lambda *_: self._move_step(row, -1)) - btn_down.connect("clicked", lambda *_: self._move_step(row, 1)) - btn_remove.connect("clicked", lambda *_: self.quick_steps.remove(row)) - controls.pack_start(btn_up, False, False, 0) - controls.pack_start(btn_down, False, False, 0) - controls.pack_start(btn_remove, False, False, 0) - - content.pack_start(grid, False, False, 0) - content.pack_start(controls, False, False, 0) - row.add(content) - row._lel_step_entries = { - "model_combo": model_combo, - "model_entry": model_entry, - "temperature": temperature, - "prompt_text": prompt_text, - "base_url": base_url, - "api_key": api_key, - "timeout": 25, - } - self._refresh_models_for_row(row) - self.quick_steps.add(row) - self.quick_steps.show_all() - - def _move_step(self, row: Gtk.Widget, direction: int): - children = self.quick_steps.get_children() - idx = children.index(row) - new_idx = idx + direction - if new_idx < 0 or new_idx >= len(children): - return - self.quick_steps.remove(row) - self.quick_steps.insert(row, new_idx) - self.quick_steps.show_all() - - - def _refresh_models_for_row(self, row: Gtk.Widget): - e = row._lel_step_entries - base_url = e["base_url"].get_text().strip() - api_key = e["api_key"].get_text().strip() - models = self._get_models(base_url, api_key, 25) - combo = e["model_combo"] - entry = e["model_entry"] - combo.remove_all() - if models: - for m in models: - combo.append_text(m) - combo.set_active(0) - combo.show() - entry.hide() - else: - combo.hide() - entry.show() - - def _get_models(self, base_url: str, api_key: str, timeout: int) -> list[str]: - key = f"{base_url}|{api_key}|{timeout}" - if key in self._model_cache: - return self._model_cache[key] - models = list_models(base_url, api_key, timeout) - self._model_cache[key] = models - return models - - def _on_save(self, *_args): - try: - cfg = self._collect_config() - validate(cfg) - self._write_config(cfg) - self.window.destroy() - except Exception as exc: - self._set_error(str(exc)) - - def _set_error(self, text: str): - self.error_label.set_text(text) - - def _collect_config(self) -> Config: - cfg = Config() - hotkey_text = self.widgets["hotkey_value"].get_text().strip() - cfg.daemon["hotkey"] = "" if hotkey_text == "(not set)" else hotkey_text - cfg.recording["input"] = self._selected_mic_source() - model = self.widgets["whisper_model"].get_active_text() - cfg.transcribing["model"] = (model or "base").strip() - device = self.widgets["whisper_device"].get_active_text() - cfg.transcribing["device"] = (device or "cpu").strip() - backend = self.widgets["injection_backend"].get_active_text() - cfg.injection["backend"] = (backend or "").strip() or "clipboard" - cfg.ai_cleanup["enabled"] = self.widgets["ai_enabled"].get_active() - cfg.ai_cleanup["model"] = self.widgets["ai_model"].get_text().strip() - cfg.ai_cleanup["temperature"] = float(self.widgets["ai_temperature"].get_value()) - cfg.ai_cleanup["base_url"] = self.widgets["ai_base_url"].get_text().strip() - cfg.ai_cleanup["api_key"] = self.widgets["ai_api_key"].get_text().strip() - return cfg - - def _section_label(self, text: str) -> Gtk.Label: - label = Gtk.Label(label=text) - label.set_xalign(0.0) - ctx = label.get_style_context() - ctx.add_class("section") - return label - - def _capture_hotkey(self): - dialog = Gtk.Dialog(title="Set Hotkey", transient_for=self.window, flags=0) - dialog.set_modal(True) - dialog.set_default_size(360, 120) - dialog.set_decorated(True) - dialog.set_resizable(False) - - box = dialog.get_content_area() - info = Gtk.Label(label="Press desired hotkey…\nEsc cancels, Backspace/Delete clears") - info.set_xalign(0.0) - status = Gtk.Label(label="") - status.set_xalign(0.0) - box.pack_start(info, False, False, 8) - box.pack_start(status, False, False, 8) - - def on_key(_widget, event): - keyval = event.keyval - if keyval == Gdk.KEY_Escape: - dialog.destroy() - return True - if keyval in (Gdk.KEY_BackSpace, Gdk.KEY_Delete): - self.widgets["hotkey_value"].set_text("(not set)") - dialog.destroy() - return True - - hotkey = self._format_hotkey(event) - if not hotkey: - status.set_text("Please include a modifier key.") - return True - self.widgets["hotkey_value"].set_text(hotkey) - dialog.destroy() - return True - - dialog.connect("key-press-event", on_key) - dialog.show_all() - - def _format_hotkey(self, event) -> str: - state = event.state - mods = [] - if state & Gdk.ModifierType.SUPER_MASK: - mods.append("Cmd") - if state & Gdk.ModifierType.CONTROL_MASK: - mods.append("Ctrl") - if state & Gdk.ModifierType.MOD1_MASK: - mods.append("Alt") - if state & Gdk.ModifierType.SHIFT_MASK: - mods.append("Shift") - if not mods: - return "" - - key_name = Gdk.keyval_name(event.keyval) or "" - if not key_name: - return "" - if len(key_name) == 1: - key_name = key_name.upper() - key_map = { - "Return": "Enter", - "space": "Space", - "Tab": "Tab", - "Escape": "Esc", - } - key_name = key_map.get(key_name, key_name) - return "+".join(mods + [key_name]) - - def _build_settings_tab(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - box.set_border_width(8) - - box.pack_start(self._section_label("Daemon"), False, False, 0) - box.pack_start(self._build_hotkeys_tab(), False, False, 0) - - box.pack_start(self._section_label("Recording"), False, False, 0) - box.pack_start(self._build_recording_tab(), False, False, 0) - - box.pack_start(self._section_label("Transcribing"), False, False, 0) - box.pack_start(self._build_stt_tab(), False, False, 0) - - box.pack_start(self._section_label("Injection"), False, False, 0) - box.pack_start(self._build_injection_tab(), False, False, 0) - - box.pack_start(self._section_label("AI Cleanup"), False, False, 0) - box.pack_start(self._build_ai_tab(), False, False, 0) - - scroll = Gtk.ScrolledWindow() - scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scroll.add(box) - return scroll - - def _write_config(self, cfg: Config): - self.config_path.parent.mkdir(parents=True, exist_ok=True) - data = asdict(cfg) - self.config_path.write_text(json.dumps(data, indent=2), encoding="utf-8") - - -def open_settings_window(cfg: Config, config_path: Path): - return SettingsWindow(cfg, config_path)