Source code for simvx.core.config

"""Unified application configuration for SimVX.

Merges editor and IDE settings into a single ``~/.config/simvx/config.json``
file with ``general``, ``editor``, and ``ide`` sections.  Legacy per-tool
config files (``editor.json``, ``ide.json``) are auto-migrated on first load.
"""


from __future__ import annotations

import json
import logging
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path

log = logging.getLogger(__name__)

CONFIG_DIR = Path.home() / ".config" / "simvx"
CONFIG_FILE = CONFIG_DIR / "config.json"


# ---------------------------------------------------------------------------
# Section dataclasses
# ---------------------------------------------------------------------------


[docs] @dataclass class GeneralConfig: """Settings shared by both editor and IDE.""" theme_preset: str = "dark" font_size: float = 11.0 window_width: int = 1600 window_height: int = 900 recent_files: list[str] = field(default_factory=list) recent_folders: list[str] = field(default_factory=list) recent_projects: list[dict] = field(default_factory=list) custom_shortcuts: dict[str, str] = field(default_factory=dict)
[docs] @dataclass class EditorSection: """Editor-specific settings.""" dock_layout: dict = field(default_factory=dict) show_grid: bool = True grid_size: float = 1.0 grid_subdivisions: int = 4 snap_enabled: bool = False snap_size: float = 0.5 auto_save_interval: int = 300
[docs] @dataclass class IDESection: """IDE-specific settings.""" tab_size: int = 4 insert_spaces: bool = True show_line_numbers: bool = True show_minimap: bool = True show_code_folding: bool = True show_indent_guides: bool = True auto_save: bool = False format_on_save: bool = True sidebar_width: int = 250 bottom_panel_height: int = 200 sidebar_visible: bool = True bottom_panel_visible: bool = True lsp_enabled: bool = True lsp_command: str = "pylsp" lsp_args: list[str] = field(default_factory=list) lint_enabled: bool = True lint_on_save: bool = True lint_command: str = "ruff check --output-format=json" format_command: str = "ruff format" python_path: str = "" venv_path: str = "" auto_detect_venv: bool = True debug_adapter: str = "debugpy" keybindings: dict[str, str] = field(default_factory=dict)
# --------------------------------------------------------------------------- # AppConfig # --------------------------------------------------------------------------- # Fields that live in GeneralConfig, keyed by their legacy name -> general field name. _EDITOR_TO_GENERAL = { "theme_name": "theme_preset", "font_size": "font_size", "window_width": "window_width", "window_height": "window_height", "recent_files": "recent_files", "custom_shortcuts": "custom_shortcuts", } _IDE_TO_GENERAL = { "theme_preset": "theme_preset", "font_size": "font_size", "window_width": "window_width", "window_height": "window_height", "recent_files": "recent_files", "recent_folders": "recent_folders", "keybindings": "custom_shortcuts", } _EDITOR_SECTION_FIELDS = {f.name for f in fields(EditorSection)} _IDE_SECTION_FIELDS = {f.name for f in fields(IDESection)}
[docs] @dataclass class AppConfig: """Unified config with sections, persisted in ``~/.config/simvx/config.json``.""" general: GeneralConfig = field(default_factory=GeneralConfig) editor: EditorSection = field(default_factory=EditorSection) ide: IDESection = field(default_factory=IDESection) # -- Persistence ---------------------------------------------------------
[docs] def load(self) -> None: """Load from ``config.json``, falling back to legacy migration.""" if CONFIG_FILE.exists(): try: data = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return self._apply_dict(data) else: self._migrate_legacy()
[docs] def save(self) -> None: """Write current config to ``config.json``.""" CONFIG_DIR.mkdir(parents=True, exist_ok=True) data = { "general": asdict(self.general), "editor": asdict(self.editor), "ide": asdict(self.ide), } CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
# -- Recent helpers ------------------------------------------------------
[docs] def add_recent_file(self, path: str) -> None: if path in self.general.recent_files: self.general.recent_files.remove(path) self.general.recent_files.insert(0, path) self.general.recent_files = self.general.recent_files[:20]
[docs] def add_recent_folder(self, path: str) -> None: if path in self.general.recent_folders: self.general.recent_folders.remove(path) self.general.recent_folders.insert(0, path) self.general.recent_folders = self.general.recent_folders[:10]
# -- Internal helpers ---------------------------------------------------- def _apply_dict(self, data: dict) -> None: """Apply a sectioned dict to this config.""" if "general" in data: _update_dataclass(self.general, data["general"]) if "editor" in data: _update_dataclass(self.editor, data["editor"]) if "ide" in data: _update_dataclass(self.ide, data["ide"]) def _migrate_legacy(self) -> None: """Read legacy ``editor.json`` and ``ide.json`` into unified sections.""" editor_file = CONFIG_DIR / "editor.json" ide_file = CONFIG_DIR / "ide.json" if editor_file.exists(): try: data = json.loads(editor_file.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): data = {} self._migrate_editor_data(data) if ide_file.exists(): try: data = json.loads(ide_file.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): data = {} self._migrate_ide_data(data, prefer_over_editor=not editor_file.exists()) def _migrate_editor_data(self, data: dict) -> None: """Map legacy editor fields into general + editor sections.""" for legacy_key, general_key in _EDITOR_TO_GENERAL.items(): if legacy_key in data: setattr(self.general, general_key, data[legacy_key]) for key, value in data.items(): if key in _EDITOR_SECTION_FIELDS: setattr(self.editor, key, value) def _migrate_ide_data(self, data: dict, *, prefer_over_editor: bool) -> None: """Map legacy IDE fields into general + ide sections. For overlapping general fields, only overwrite if the editor file was not present (``prefer_over_editor=True``). """ for legacy_key, general_key in _IDE_TO_GENERAL.items(): if legacy_key in data: if prefer_over_editor: setattr(self.general, general_key, data[legacy_key]) for key, value in data.items(): if key in _IDE_SECTION_FIELDS: setattr(self.ide, key, value)
def _update_dataclass(obj: object, data: dict) -> None: """Set fields on *obj* from *data*, skipping unknown keys.""" for key, value in data.items(): if hasattr(obj, key): setattr(obj, key, value)