Source code for simvx.editor.panels.project_settings

"""Project Settings Panel -- Editor UI for project configuration.

Provides a categorized settings dialog that reads and writes project
configuration to ``project.simvx`` (JSON format).  Covers display,
input mapping, physics, rendering, and autoload settings.

Layout:
    +---------------------------------------------+
    | Project Settings           [Save] [Revert]  |
    |---------------------------------------------|
    | v Display                                   |
    |   Resolution  W [1280] H [ 720]             |
    |   Fullscreen  [ ]   VSync [x]               |
    |   MSAA        [4x          v]               |
    |---------------------------------------------|
    | v Input Map                                  |
    |   move_left   [A]  [+] [-]                  |
    |   move_right  [D]  [+] [-]                  |
    |   jump        [Space] [+] [-]               |
    |   [+ Add Action]                            |
    |---------------------------------------------|
    | v Physics                                    |
    |   Gravity     [===|-------] 9.8             |
    |   Physics FPS [=|--------] 60              |
    |---------------------------------------------|
    | v Rendering                                  |
    |   Bloom       [ ]   Shadows [x]             |
    |   Shadow Res  [1024       v]                |
    |---------------------------------------------|
    | v Autoload                                   |
    |   GlobalAudio  res://audio_manager.py  [x]  |
    |   [+ Add Autoload]  [Up] [Down]             |
    +---------------------------------------------+
"""


from __future__ import annotations

from __future__ import annotations

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

from simvx.core import (
    Control,
    Signal,
    Vec2,
)
from simvx.core.ui.theme import em, get_theme

log = logging.getLogger(__name__)

__all__ = ["ProjectSettingsPanel", "ProjectConfig"]

# ============================================================================
# Colours / Layout
# ============================================================================

_BG = (0.13, 0.13, 0.13, 1.0)
_HEADER_BG = (0.10, 0.10, 0.10, 1.0)
_SECTION_BG = (0.16, 0.16, 0.16, 1.0)
_SECTION_HEADER = (0.12, 0.12, 0.12, 1.0)
_ROW_BG = (0.15, 0.15, 0.15, 1.0)
_ROW_ALT = (0.17, 0.17, 0.17, 1.0)
_TEXT = (0.85, 0.85, 0.85, 1.0)
_TEXT_DIM = (0.55, 0.55, 0.55, 1.0)
_ACCENT = (0.3, 0.7, 1.0, 1.0)
_BTN = (0.22, 0.22, 0.22, 1.0)
_BTN_HOVER = (0.30, 0.30, 0.30, 1.0)
_REMOVE = (0.8, 0.3, 0.3, 1.0)
_SEPARATOR = (0.25, 0.25, 0.25, 1.0)
_SAVED_COLOUR = (0.3, 0.85, 0.35, 1.0)

_HEADER_HEIGHT = 32.0
_SECTION_H = 26.0
def _row_h() -> float:
    return em(2.18)


def _padding() -> float:
    return em(0.55)


def _label_w() -> float:
    return em(7.27)
_FONT = 11.0 / 14.0

_MSAA_OPTIONS = ["Off", "2x", "4x", "8x"]
_MSAA_VALUES = [0, 2, 4, 8]
_SHADOW_RES_OPTIONS = ["512", "1024", "2048", "4096"]


# ============================================================================
# ProjectConfig -- Data model for project.simvx
# ============================================================================


[docs] @dataclass class InputAction: """A single input action with bound keys/buttons.""" name: str keys: list[str] = field(default_factory=list)
[docs] def to_dict(self) -> dict: return {"name": self.name, "keys": list(self.keys)}
[docs] @classmethod def from_dict(cls, data: dict) -> InputAction: return cls(name=data["name"], keys=list(data.get("keys", [])))
[docs] @dataclass class AutoloadEntry: """An autoload script entry.""" name: str path: str enabled: bool = True
[docs] def to_dict(self) -> dict: return {"name": self.name, "path": self.path, "enabled": self.enabled}
[docs] @classmethod def from_dict(cls, data: dict) -> AutoloadEntry: return cls(name=data["name"], path=data["path"], enabled=data.get("enabled", True))
[docs] @dataclass class ProjectConfig: """Full project configuration stored in project.simvx (JSON).""" # Display window_width: int = 1280 window_height: int = 720 fullscreen: bool = False vsync: bool = True msaa: int = 4 # 0, 2, 4, 8 # Physics gravity: float = 9.8 physics_fps: int = 60 # Rendering bloom_enabled: bool = False shadows_enabled: bool = True shadow_resolution: int = 1024 # Input actions input_actions: list[InputAction] = field(default_factory=list) # Autoloads autoloads: list[AutoloadEntry] = field(default_factory=list) # Project metadata project_name: str = "Untitled" default_scene: str = ""
[docs] def to_dict(self) -> dict: return { "project_name": self.project_name, "default_scene": self.default_scene, "window_width": self.window_width, "window_height": self.window_height, "fullscreen": self.fullscreen, "vsync": self.vsync, "msaa": self.msaa, "gravity": self.gravity, "physics_fps": self.physics_fps, "bloom_enabled": self.bloom_enabled, "shadows_enabled": self.shadows_enabled, "shadow_resolution": self.shadow_resolution, "input_actions": [a.to_dict() for a in self.input_actions], "autoloads": [a.to_dict() for a in self.autoloads], }
[docs] @classmethod def from_dict(cls, data: dict) -> ProjectConfig: cfg = cls() for key in ( "project_name", "default_scene", "window_width", "window_height", "fullscreen", "vsync", "msaa", "gravity", "physics_fps", "bloom_enabled", "shadows_enabled", "shadow_resolution", ): if key in data: setattr(cfg, key, data[key]) cfg.input_actions = [InputAction.from_dict(a) for a in data.get("input_actions", [])] cfg.autoloads = [AutoloadEntry.from_dict(a) for a in data.get("autoloads", [])] return cfg
[docs] def save(self, path: str | Path) -> bool: """Write config to a JSON file.""" try: p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(json.dumps(self.to_dict(), indent=2) + "\n") return True except OSError as exc: log.error("Failed to save project settings: %s", exc) return False
[docs] @classmethod def load(cls, path: str | Path) -> ProjectConfig | None: """Load config from a JSON file.""" try: p = Path(path) if not p.exists(): return None data = json.loads(p.read_text()) return cls.from_dict(data) except (json.JSONDecodeError, OSError) as exc: log.error("Failed to load project settings: %s", exc) return None
# ============================================================================ # _Section -- Collapsible section data # ============================================================================ @dataclass class _Section: title: str collapsed: bool = False # ============================================================================ # ProjectSettingsPanel # ============================================================================
[docs] class ProjectSettingsPanel(Control): """Project settings editor panel. Reads/writes project configuration to ``project.simvx`` (JSON format). Categories: Display, Input Map, Physics, Rendering, Autoload. Args: editor_state: The central EditorState (optional). config: An existing ProjectConfig to edit (creates new if None). """ def __init__(self, editor_state=None, config: ProjectConfig | None = None, **kwargs): super().__init__(**kwargs) self.state = editor_state self.config = config or ProjectConfig() self.bg_colour = _BG self.size = Vec2(500, 600) # Section collapse state self._sections = [ _Section("Display"), _Section("Input Map"), _Section("Physics"), _Section("Rendering"), _Section("Autoload"), ] # Track unsaved changes self._dirty = False self._saved_snapshot: dict = self.config.to_dict() # Scroll state self._scroll_y: float = 0.0 # Signals self.settings_saved = Signal() self.settings_changed = Signal() # ==================================================================== # Config access # ====================================================================
[docs] def mark_dirty(self): self._dirty = True self.settings_changed.emit()
[docs] def revert(self): """Revert to the last saved state.""" self.config = ProjectConfig.from_dict(self._saved_snapshot) self._dirty = False
[docs] def save(self, path: str | Path): """Save settings to disk.""" if self.config.save(path): self._saved_snapshot = self.config.to_dict() self._dirty = False self.settings_saved.emit() return True return False
[docs] def load(self, path: str | Path) -> bool: """Load settings from disk.""" cfg = ProjectConfig.load(path) if cfg: self.config = cfg self._saved_snapshot = cfg.to_dict() self._dirty = False return True return False
# ==================================================================== # Input map management # ====================================================================
[docs] def add_input_action(self, name: str) -> InputAction: """Add a new input action.""" action = InputAction(name=name) self.config.input_actions.append(action) self.mark_dirty() return action
[docs] def remove_input_action(self, name: str) -> bool: """Remove an input action by name.""" for i, action in enumerate(self.config.input_actions): if action.name == name: self.config.input_actions.pop(i) self.mark_dirty() return True return False
[docs] def bind_key(self, action_name: str, key: str) -> bool: """Bind a key to an input action.""" for action in self.config.input_actions: if action.name == action_name: if key not in action.keys: action.keys.append(key) self.mark_dirty() return True return False
[docs] def unbind_key(self, action_name: str, key: str) -> bool: """Unbind a key from an input action.""" for action in self.config.input_actions: if action.name == action_name: if key in action.keys: action.keys.remove(key) self.mark_dirty() return True return False
# ==================================================================== # Autoload management # ====================================================================
[docs] def add_autoload(self, name: str, path: str) -> AutoloadEntry: """Add an autoload entry.""" entry = AutoloadEntry(name=name, path=path) self.config.autoloads.append(entry) self.mark_dirty() return entry
[docs] def remove_autoload(self, name: str) -> bool: """Remove an autoload by name.""" for i, entry in enumerate(self.config.autoloads): if entry.name == name: self.config.autoloads.pop(i) self.mark_dirty() return True return False
[docs] def reorder_autoload(self, name: str, direction: int) -> bool: """Move an autoload up (-1) or down (+1).""" for i, entry in enumerate(self.config.autoloads): if entry.name == name: new_i = i + direction if 0 <= new_i < len(self.config.autoloads): self.config.autoloads[i], self.config.autoloads[new_i] = ( self.config.autoloads[new_i], self.config.autoloads[i], ) self.mark_dirty() return True return False return False
# ==================================================================== # Drawing # ====================================================================
[docs] def draw(self, renderer): gx, gy, gw, gh = self.get_global_rect() renderer.draw_filled_rect(gx, gy, gw, gh, _BG) renderer.push_clip(gx, gy, gw, gh) y = gy y = self._draw_header(renderer, gx, y, gw) # Scrollable content content_h = gh - (y - gy) renderer.push_clip(gx, y, gw, content_h) cy = y - self._scroll_y for _i, section in enumerate(self._sections): cy = self._draw_section_header(renderer, gx, cy, gw, section) if not section.collapsed: if section.title == "Display": cy = self._draw_display_section(renderer, gx, cy, gw) elif section.title == "Input Map": cy = self._draw_input_section(renderer, gx, cy, gw) elif section.title == "Physics": cy = self._draw_physics_section(renderer, gx, cy, gw) elif section.title == "Rendering": cy = self._draw_rendering_section(renderer, gx, cy, gw) elif section.title == "Autoload": cy = self._draw_autoload_section(renderer, gx, cy, gw) renderer.pop_clip() renderer.pop_clip()
def _draw_header(self, renderer, x, y, w) -> float: renderer.draw_filled_rect(x, y, w, _HEADER_HEIGHT, _HEADER_BG) renderer.draw_text("Project Settings", x + _padding(), y + 9, _TEXT, _FONT) dirty_indicator = " (modified)" if self._dirty else "" if dirty_indicator: dw = renderer.text_width(dirty_indicator, _FONT) renderer.draw_text(dirty_indicator, x + w - dw - _padding(), y + 9, _ACCENT, _FONT) renderer.draw_filled_rect(x, y + _HEADER_HEIGHT - 1, w, 1, _SEPARATOR) return y + _HEADER_HEIGHT def _draw_section_header(self, renderer, x, y, w, section: _Section) -> float: renderer.draw_filled_rect(x, y, w, _SECTION_H, _SECTION_HEADER) arrow = ">" if section.collapsed else "v" renderer.draw_text(f"{arrow} {section.title}", x + _padding(), y + 6, _TEXT, _FONT) renderer.draw_filled_rect(x, y + _SECTION_H - 1, w, 1, _SEPARATOR) return y + _SECTION_H def _draw_row(self, renderer, x, y, w, label: str, value: str, alt: bool = False) -> float: bg = _ROW_ALT if alt else _ROW_BG renderer.draw_filled_rect(x, y, w, _row_h(), bg) renderer.draw_text(label, x + _padding(), y + 5, _TEXT_DIM, _FONT) renderer.draw_text(value, x + _label_w(), y + 5, _TEXT, _FONT) return y + _row_h() def _draw_display_section(self, renderer, x, y, w) -> float: y = self._draw_row(renderer, x, y, w, "Resolution", f"{self.config.window_width} x {self.config.window_height}") y = self._draw_row(renderer, x, y, w, "Fullscreen", "Yes" if self.config.fullscreen else "No", alt=True) y = self._draw_row(renderer, x, y, w, "VSync", "On" if self.config.vsync else "Off") msaa_label = f"{self.config.msaa}x" if self.config.msaa > 0 else "Off" y = self._draw_row(renderer, x, y, w, "MSAA", msaa_label, alt=True) return y def _draw_input_section(self, renderer, x, y, w) -> float: if not self.config.input_actions: renderer.draw_filled_rect(x, y, w, _row_h(), _ROW_BG) renderer.draw_text("No input actions defined", x + _padding(), y + 5, _TEXT_DIM, _FONT) return y + _row_h() for i, action in enumerate(self.config.input_actions): keys_str = ", ".join(action.keys) if action.keys else "(unbound)" y = self._draw_row(renderer, x, y, w, action.name, keys_str, alt=i % 2 == 1) return y def _draw_physics_section(self, renderer, x, y, w) -> float: y = self._draw_row(renderer, x, y, w, "Gravity", f"{self.config.gravity:.1f}") y = self._draw_row(renderer, x, y, w, "Physics FPS", str(self.config.physics_fps), alt=True) return y def _draw_rendering_section(self, renderer, x, y, w) -> float: y = self._draw_row(renderer, x, y, w, "Bloom", "On" if self.config.bloom_enabled else "Off") y = self._draw_row(renderer, x, y, w, "Shadows", "On" if self.config.shadows_enabled else "Off", alt=True) y = self._draw_row(renderer, x, y, w, "Shadow Res", str(self.config.shadow_resolution)) return y def _draw_autoload_section(self, renderer, x, y, w) -> float: if not self.config.autoloads: renderer.draw_filled_rect(x, y, w, _row_h(), _ROW_BG) renderer.draw_text("No autoloads", x + _padding(), y + 5, _TEXT_DIM, _FONT) return y + _row_h() for i, entry in enumerate(self.config.autoloads): status = "ON" if entry.enabled else "OFF" y = self._draw_row(renderer, x, y, w, entry.name, f"{entry.path} [{status}]", alt=i % 2 == 1) return y # ==================================================================== # Input handling # ==================================================================== def _on_gui_input(self, event): gx, gy, gw, gh = self.get_global_rect() if not hasattr(event, "position"): return ex, ey = event.position if not (gx <= ex <= gx + gw and gy <= ey <= gy + gh): return # Section header collapse toggle if hasattr(event, "pressed") and event.pressed and getattr(event, "button", 0) == 1: header_y = gy + _HEADER_HEIGHT - self._scroll_y for section in self._sections: if header_y <= ey < header_y + _SECTION_H: section.collapsed = not section.collapsed return header_y += _SECTION_H if not section.collapsed: header_y += self._section_content_height(section) # Scroll if hasattr(event, "delta"): _, dy = event.delta if isinstance(event.delta, tuple) else (0, event.delta) self._scroll_y = max(0.0, self._scroll_y - dy * 20.0) def _section_content_height(self, section: _Section) -> float: """Estimate height of a section's content.""" if section.title == "Display": return _row_h() * 4 elif section.title == "Input Map": return _row_h() * max(1, len(self.config.input_actions)) elif section.title == "Physics": return _row_h() * 2 elif section.title == "Rendering": return _row_h() * 3 elif section.title == "Autoload": return _row_h() * max(1, len(self.config.autoloads)) return 0.0