Remove settings window

This commit is contained in:
Thales Maciel 2026-02-24 10:55:46 -03:00
parent b6c0fc0793
commit f826617ed4
No known key found for this signature in database
GPG key ID: 33112E6833C34679
4 changed files with 4 additions and 583 deletions

View file

@ -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

View file

@ -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

View file

@ -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__":

View file

@ -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)