Newer
Older
navi-1 / clients / terminal / tui / settings.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy 2 days ago 3 KB Navi Code TUI: review fixes for Phase 5
"""Persistent TUI settings stored in ~/.navi_code/tui.json."""

from __future__ import annotations

import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any

from clients.terminal.config import settings


@dataclass
class TuiSettings:
    """User-facing TUI configuration persisted across restarts."""

    theme: str = "gnexus-dark"
    mouse: bool = True
    scroll_speed: int = 1
    diff_style: str = "unified"
    keybinds: dict[str, str] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "TuiSettings":
        field_defaults = {f.name: f.default for f in cls.__dataclass_fields__.values()}
        merged = dict(field_defaults)
        merged.update(data)
        keybinds = merged.get("keybinds", {})
        if keybinds is None or not isinstance(keybinds, dict):
            keybinds = {}
        return cls(
            theme=_coerce_str(merged.get("theme"), field_defaults["theme"]),
            mouse=_coerce_bool(merged.get("mouse"), field_defaults["mouse"]),
            scroll_speed=_coerce_int(merged.get("scroll_speed"), field_defaults["scroll_speed"]),
            diff_style=_coerce_str(merged.get("diff_style"), field_defaults["diff_style"]),
            keybinds=dict(keybinds),
        )

    @property
    def settings_dir(self) -> Path:
        return settings.state_dir

    @property
    def settings_file(self) -> Path:
        return self.settings_dir / "tui.json"

    def load(self) -> "TuiSettings":
        if not self.settings_file.exists():
            return self._save_and_return()
        try:
            with self.settings_file.open("r", encoding="utf-8") as f:
                data = json.load(f)
        except (json.JSONDecodeError, OSError):
            return self._save_and_return()
        loaded = self.from_dict(data)
        loaded._ensure_dir()
        return loaded

    def save(self) -> None:
        self._ensure_dir()
        with self.settings_file.open("w", encoding="utf-8") as f:
            json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)

    def _ensure_dir(self) -> None:
        self.settings_dir.mkdir(parents=True, exist_ok=True)

    def _save_and_return(self) -> "TuiSettings":
        self.save()
        return self


# Global lazy-loaded instance.
_tui_settings: TuiSettings | None = None


def get_tui_settings() -> TuiSettings:
    """Return the loaded TUI settings, caching them for the process lifetime."""
    global _tui_settings
    if _tui_settings is None:
        _tui_settings = TuiSettings().load()
    return _tui_settings


def reload_tui_settings() -> TuiSettings:
    """Force reload from disk and return fresh settings."""
    global _tui_settings
    _tui_settings = TuiSettings().load()
    return _tui_settings


def _coerce_bool(value: Any, default: bool) -> bool:
    """Return a boolean from bool or common string representations."""
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        lowered = value.strip().lower()
        if lowered in ("true", "yes", "1", "on"):
            return True
        if lowered in ("false", "no", "0", "off"):
            return False
    return default


def _coerce_int(value: Any, default: int) -> int:
    """Return an int from int or numeric string, falling back to default."""
    if isinstance(value, int) and not isinstance(value, bool):
        return value
    if isinstance(value, str):
        try:
            return int(value)
        except ValueError:
            pass
    return default


def _coerce_str(value: Any, default: str) -> str:
    """Return a string value if it is a non-empty string, else default."""
    if isinstance(value, str) and value:
        return value
    return default