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