"""Persistent IDE settings stored in ~/.config/simvx/ide.json.
``IDEConfig`` delegates field storage to the unified ``AppConfig`` system
while preserving backward-compatible save/load using the legacy flat JSON
format. All colours come from the ``AppTheme`` singleton.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from simvx.core.config import AppConfig
log = logging.getLogger(__name__)
CONFIG_DIR = Path.home() / ".config" / "simvx"
CONFIG_FILE = CONFIG_DIR / "ide.json"
[docs]
class IDEConfig:
"""IDE preferences with JSON persistence.
All fields delegate to ``AppConfig.general`` and ``AppConfig.ide``
sections. Colours are provided by the ``AppTheme`` singleton (see
``simvx.core.ui.theme``).
"""
def __init__(self) -> None:
self._config = AppConfig()
# IDE uses a narrower default window than the editor.
self._config.general.window_width = 1400
# -- General section properties ------------------------------------------
@property
def font_size(self) -> int:
return int(self._config.general.font_size)
@font_size.setter
def font_size(self, value: int) -> None:
self._config.general.font_size = float(value)
@property
def window_width(self) -> int:
return self._config.general.window_width
@window_width.setter
def window_width(self, value: int) -> None:
self._config.general.window_width = value
@property
def window_height(self) -> int:
return self._config.general.window_height
@window_height.setter
def window_height(self, value: int) -> None:
self._config.general.window_height = value
@property
def recent_files(self) -> list[str]:
return self._config.general.recent_files
@recent_files.setter
def recent_files(self, value: list[str]) -> None:
self._config.general.recent_files = value
@property
def recent_folders(self) -> list[str]:
return self._config.general.recent_folders
@recent_folders.setter
def recent_folders(self, value: list[str]) -> None:
self._config.general.recent_folders = value
@property
def theme_preset(self) -> str:
return self._config.general.theme_preset
@theme_preset.setter
def theme_preset(self, value: str) -> None:
self._config.general.theme_preset = value
# -- IDE section properties ----------------------------------------------
@property
def tab_size(self) -> int:
return self._config.ide.tab_size
@tab_size.setter
def tab_size(self, value: int) -> None:
self._config.ide.tab_size = value
@property
def insert_spaces(self) -> bool:
return self._config.ide.insert_spaces
@insert_spaces.setter
def insert_spaces(self, value: bool) -> None:
self._config.ide.insert_spaces = value
@property
def show_line_numbers(self) -> bool:
return self._config.ide.show_line_numbers
@show_line_numbers.setter
def show_line_numbers(self, value: bool) -> None:
self._config.ide.show_line_numbers = value
@property
def show_minimap(self) -> bool:
return self._config.ide.show_minimap
@show_minimap.setter
def show_minimap(self, value: bool) -> None:
self._config.ide.show_minimap = value
@property
def show_code_folding(self) -> bool:
return self._config.ide.show_code_folding
@show_code_folding.setter
def show_code_folding(self, value: bool) -> None:
self._config.ide.show_code_folding = value
@property
def show_indent_guides(self) -> bool:
return self._config.ide.show_indent_guides
@show_indent_guides.setter
def show_indent_guides(self, value: bool) -> None:
self._config.ide.show_indent_guides = value
@property
def auto_save(self) -> bool:
return self._config.ide.auto_save
@auto_save.setter
def auto_save(self, value: bool) -> None:
self._config.ide.auto_save = value
@property
def format_on_save(self) -> bool:
return self._config.ide.format_on_save
@format_on_save.setter
def format_on_save(self, value: bool) -> None:
self._config.ide.format_on_save = value
@property
def sidebar_width(self) -> int:
return self._config.ide.sidebar_width
@sidebar_width.setter
def sidebar_width(self, value: int) -> None:
self._config.ide.sidebar_width = value
@property
def bottom_panel_height(self) -> int:
return self._config.ide.bottom_panel_height
@bottom_panel_height.setter
def bottom_panel_height(self, value: int) -> None:
self._config.ide.bottom_panel_height = value
@property
def sidebar_visible(self) -> bool:
return self._config.ide.sidebar_visible
@sidebar_visible.setter
def sidebar_visible(self, value: bool) -> None:
self._config.ide.sidebar_visible = value
@property
def bottom_panel_visible(self) -> bool:
return self._config.ide.bottom_panel_visible
@bottom_panel_visible.setter
def bottom_panel_visible(self, value: bool) -> None:
self._config.ide.bottom_panel_visible = value
@property
def lsp_enabled(self) -> bool:
return self._config.ide.lsp_enabled
@lsp_enabled.setter
def lsp_enabled(self, value: bool) -> None:
self._config.ide.lsp_enabled = value
@property
def lsp_command(self) -> str:
return self._config.ide.lsp_command
@lsp_command.setter
def lsp_command(self, value: str) -> None:
self._config.ide.lsp_command = value
@property
def lsp_args(self) -> list[str]:
return self._config.ide.lsp_args
@lsp_args.setter
def lsp_args(self, value: list[str]) -> None:
self._config.ide.lsp_args = value
@property
def lint_enabled(self) -> bool:
return self._config.ide.lint_enabled
@lint_enabled.setter
def lint_enabled(self, value: bool) -> None:
self._config.ide.lint_enabled = value
@property
def lint_on_save(self) -> bool:
return self._config.ide.lint_on_save
@lint_on_save.setter
def lint_on_save(self, value: bool) -> None:
self._config.ide.lint_on_save = value
@property
def lint_command(self) -> str:
return self._config.ide.lint_command
@lint_command.setter
def lint_command(self, value: str) -> None:
self._config.ide.lint_command = value
@property
def format_command(self) -> str:
return self._config.ide.format_command
@format_command.setter
def format_command(self, value: str) -> None:
self._config.ide.format_command = value
@property
def python_path(self) -> str:
return self._config.ide.python_path
@python_path.setter
def python_path(self, value: str) -> None:
self._config.ide.python_path = value
@property
def venv_path(self) -> str:
return self._config.ide.venv_path
@venv_path.setter
def venv_path(self, value: str) -> None:
self._config.ide.venv_path = value
@property
def auto_detect_venv(self) -> bool:
return self._config.ide.auto_detect_venv
@auto_detect_venv.setter
def auto_detect_venv(self, value: bool) -> None:
self._config.ide.auto_detect_venv = value
@property
def debug_adapter(self) -> str:
return self._config.ide.debug_adapter
@debug_adapter.setter
def debug_adapter(self, value: str) -> None:
self._config.ide.debug_adapter = value
@property
def keybindings(self) -> dict[str, str]:
return self._config.ide.keybindings
@keybindings.setter
def keybindings(self, value: dict[str, str]) -> None:
self._config.ide.keybindings = value
# -- Persistence (legacy flat format) ------------------------------------
# All property names that should be serialized.
_PROP_NAMES = (
"font_size", "tab_size", "insert_spaces", "show_line_numbers",
"show_minimap", "show_code_folding", "show_indent_guides", "auto_save", "format_on_save",
"window_width", "window_height", "sidebar_width", "bottom_panel_height",
"sidebar_visible", "bottom_panel_visible", "lsp_enabled", "lsp_command",
"lsp_args", "lint_enabled", "lint_on_save", "lint_command", "format_command",
"python_path", "venv_path", "auto_detect_venv", "debug_adapter",
"recent_files", "recent_folders", "keybindings", "theme_preset",
)
[docs]
def load(self) -> None:
if not CONFIG_FILE.exists():
return
try:
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
for key, value in data.items():
if hasattr(self, key):
current = getattr(self, key)
if isinstance(current, tuple) and isinstance(value, list):
setattr(self, key, tuple(value))
else:
setattr(self, key, value)
[docs]
def save(self) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
data: dict = {}
for name in self._PROP_NAMES:
data[name] = getattr(self, name)
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
# -- Recent files/folders ------------------------------------------------
[docs]
def add_recent_file(self, path: str) -> None:
if path in self.recent_files:
self.recent_files.remove(path)
self.recent_files.insert(0, path)
self.recent_files = self.recent_files[:20]
[docs]
def add_recent_folder(self, path: str) -> None:
if path in self.recent_folders:
self.recent_folders.remove(path)
self.recent_folders.insert(0, path)
self.recent_folders = self.recent_folders[:10]
# -- Virtual environment detection ---------------------------------------
[docs]
@staticmethod
def detect_venv(project_root: str) -> str | None:
"""Detect a virtual environment in *project_root*.
Checks common venv directory names (.venv, venv, .env) for a Python
interpreter. Returns the absolute path to the venv directory, or None.
"""
if not project_root:
return None
root = Path(project_root)
for name in (".venv", "venv", ".env"):
python = root / name / "bin" / "python"
if python.is_file():
return str(root / name)
return None
[docs]
def get_python_command(self, project_root: str) -> str:
"""Return the Python interpreter path to use for this project.
Priority: explicit ``python_path`` setting > auto-detected venv > system ``python``.
"""
if self.python_path:
return self.python_path
venv = self._resolve_venv(project_root)
if venv:
return str(Path(venv) / "bin" / "python")
return "python"
[docs]
def get_env(self, project_root: str) -> dict[str, str]:
"""Build a subprocess environment dict with the detected venv activated."""
env = os.environ.copy()
venv = self._resolve_venv(project_root)
if venv:
venv_bin = str(Path(venv) / "bin")
env["VIRTUAL_ENV"] = venv
env["PATH"] = venv_bin + os.pathsep + env.get("PATH", "")
env.pop("PYTHONHOME", None)
return env
def _resolve_venv(self, project_root: str) -> str | None:
"""Return the venv path from explicit config or auto-detection."""
if self.venv_path:
return self.venv_path
if self.auto_detect_venv:
return self.detect_venv(project_root)
return None
# -- Theme presets -------------------------------------------------------
_THEME_FACTORIES: dict[str, str] | None = None
@staticmethod
def _get_factories() -> dict:
from simvx.core.ui.theme import AppTheme
return {
"dark": AppTheme.dark, "abyss": AppTheme.abyss, "midnight": AppTheme.midnight,
"light": AppTheme.light, "monokai": AppTheme.monokai,
"solarised_dark": AppTheme.solarised_dark, "nord": AppTheme.nord,
}
[docs]
def apply_theme(self, preset: str):
"""Apply a theme preset and update the global singleton."""
from simvx.core.ui.theme import set_theme
factories = self._get_factories()
factory = factories.get(preset)
if not factory:
return
self.theme_preset = preset
set_theme(factory())
self.save()
[docs]
def get_theme(self):
"""Return an ``AppTheme`` matching the current preset and set the global singleton."""
from simvx.core.ui.theme import set_theme
factories = self._get_factories()
theme = factories.get(self.theme_preset, factories["dark"])()
set_theme(theme)
return theme