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