"""Project Session — Scene I/O, project settings, and recent files."""
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import Node, Node3D, SceneTree, Signal, Vec2, __version__
from simvx.core.project import ProjectSettings, save_project
from simvx.core.scene_io import SceneFile, load_scene
if TYPE_CHECKING:
from .state import State
log = logging.getLogger(__name__)
_MAX_RECENT = 10
_PROJECT_FILE = "simvx.toml"
def _meta_to_toml_settings(meta: ProjectMetadata) -> ProjectSettings:
"""Convert an ProjectMetadata to ProjectSettings for TOML serialization."""
data: dict = {
"name": meta.project_name,
"main": meta.default_scene,
"display": {"width": meta.window_width, "height": meta.window_height},
"physics": {"fps": meta.physics_fps, "gravity": meta.gravity},
}
if meta.engine_version:
data["engine"] = {"version": meta.engine_version}
return ProjectSettings(data)
[docs]
class ProjectSession:
"""Higher-level project and scene operations.
All public methods accept an State so the session stays
stateless with respect to the scene — easy to test and re-entrant.
"""
def __init__(self) -> None:
self.open_file_requested = Signal()
self.save_file_requested = Signal()
self.error_occurred = Signal()
self.settings = ProjectMetadata()
self._recent_files: list[str] = []
# -- Scene lifecycle --------------------------------------------------
[docs]
def new_scene(self, state: State) -> None:
"""Create a fresh scene with a single root Node3D."""
root = Node3D(name="Root")
state.edited_scene = SceneTree(screen_size=Vec2(800, 600))
state.edited_scene.set_root(root)
state.current_scene_path = None
state.selection.clear()
state.undo_stack.clear()
state._modified = False
state.scene_changed.emit()
# -- Open -------------------------------------------------------------
[docs]
def open_scene(self, state: State) -> None:
"""Emit open_file_requested so the editor shows a FileDialog."""
self.open_file_requested.emit()
def _do_open_scene(self, state: State, path: str | Path) -> bool:
"""Load a scene from *path*. Returns True on success."""
path = Path(path)
if not path.exists() or not path.is_file():
self._error(f"Scene file not found: {path}")
return False
try:
root = load_scene(str(path))
except Exception as exc:
self._error(f"Failed to load scene: {exc}")
return False
if root is None:
self._error("Scene file produced an empty node tree.")
return False
state.edited_scene = SceneTree(screen_size=Vec2(800, 600))
state.edited_scene.set_root(root)
state.current_scene_path = path
state.selection.clear()
state.undo_stack.clear()
state._modified = False
self.add_recent(str(path))
state.scene_changed.emit()
return True
# -- Save -------------------------------------------------------------
[docs]
def save_scene(self, state: State) -> bool:
"""Save directly if path exists, otherwise trigger save-as."""
if state.current_scene_path:
return self._do_save_scene(state, state.current_scene_path)
self.save_scene_as(state)
return False
[docs]
def save_scene_as(self, state: State) -> None:
"""Emit save_file_requested so the editor shows a FileDialog."""
self.save_file_requested.emit()
def _do_save_scene(self, state: State, path: str | Path) -> bool:
"""Persist the current scene to *path*. Returns True on success."""
path = Path(path)
root = state.edited_scene.root if state.edited_scene else None
if root is None:
self._error("No scene to save.")
return False
try:
path.parent.mkdir(parents=True, exist_ok=True)
SceneFile.from_runtime(root).save(path)
except Exception as exc:
self._error(f"Failed to save scene: {exc}")
return False
state.current_scene_path = path
state._modified = False
state.scene_modified.emit()
self.add_recent(str(path))
return True
# -- Recent files -----------------------------------------------------
[docs]
def add_recent(self, path: str) -> None:
"""Add path to the front of the recent list (dedup, max 10)."""
resolved = str(Path(path).resolve())
if resolved in self._recent_files:
self._recent_files.remove(resolved)
self._recent_files.insert(0, resolved)
self._recent_files = self._recent_files[:_MAX_RECENT]
[docs]
@property
def recent_files(self) -> list[str]:
"""Ordered recent-files list (defensive copy)."""
return list(self._recent_files)
[docs]
def clear_recent_files(self) -> None:
"""Clear the recent-files list."""
self._recent_files.clear()
# -- Project I/O ------------------------------------------------------
[docs]
def load_project(self, path: str | Path) -> bool:
"""Load simvx.toml from directory *path* (or a direct file path)."""
import tomllib
path = Path(path)
pf = path / _PROJECT_FILE if path.is_dir() else path
if not pf.exists():
self._error(f"Project file not found: {pf}")
return False
try:
raw = tomllib.loads(pf.read_text())
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
self._error(f"Invalid project file: {exc}")
return False
# Templates use a nested [project] section; the canonical writer puts
# name/main at the top level. Accept either.
section = raw.get("project", {})
data: dict = {}
name = section.get("name", section.get("project_name", raw.get("name")))
if name is not None:
data["project_name"] = name
main = section.get("default_scene", section.get("main", raw.get("main")))
if main is not None:
data["default_scene"] = main
display = raw.get("display", {})
if "width" in display:
data["window_width"] = display["width"]
if "height" in display:
data["window_height"] = display["height"]
physics = raw.get("physics", {})
if "fps" in physics:
data["physics_fps"] = physics["fps"]
if "gravity" in physics:
data["gravity"] = physics["gravity"]
engine = raw.get("engine", {})
if "version" in engine:
data["engine_version"] = engine["version"]
self.settings = ProjectMetadata.from_dict(data)
return True
[docs]
def save_project(self, path: str | Path) -> bool:
"""Save simvx.toml into directory *path*. Returns True on success."""
path = Path(path)
pf = path / _PROJECT_FILE if path.is_dir() else path
# Auto-set engine version on save
self.settings.engine_version = __version__
try:
toml_settings = _meta_to_toml_settings(self.settings)
save_project(toml_settings, pf)
except OSError as exc:
self._error(f"Failed to save project: {exc}")
return False
return True
# -- Window title -----------------------------------------------------
[docs]
def get_window_title(self, state: State) -> str:
"""Return 'SimVX Editor - scene_name*' (asterisk if modified)."""
name = state.current_scene_path.stem if state.current_scene_path else "Untitled"
mod = "*" if state.modified else ""
return f"SimVX Editor - {name}{mod}"
[docs]
@staticmethod
def get_scene_node_count(state: State) -> int:
"""Count every node in the current scene (including root)."""
root = state.edited_scene.root if state.edited_scene else None
if root is None:
return 0
return _count_nodes(root)
# -- Internal ---------------------------------------------------------
def _error(self, msg: str) -> None:
"""Log a warning and emit the error_occurred signal."""
log.warning(msg)
self.error_occurred.emit(msg)
def _count_nodes(node: Node) -> int:
"""Recursively count *node* and all descendants."""
return 1 + sum(_count_nodes(child) for child in node.children)