"""Project Session: Scene I/O, project settings, and recent files."""
import logging
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from simvx.core import Node, Node3D, SceneTree, Signal, Vec2, __version__
from simvx.core.project import ProjectSettings, load_project, save_project
from simvx.core.scene_io import load_scene
from .scene_file_ops import record_source_baseline
if TYPE_CHECKING:
from .state import State
log = logging.getLogger(__name__)
__all__ = ["ProjectMetadata", "ProjectSession", "read_project_settings"]
_MAX_RECENT = 10
_PROJECT_FILE = "simvx.toml"
[docs]
def read_project_settings(toml_path: Path) -> tuple[ProjectSettings, str | None]:
"""Load ``simvx.toml`` from *toml_path*, reporting why it could not be read.
Returns ``(settings, error)``. On failure the settings are defaults and
*error* is a message to show the user, which lets a caller refuse to write
over a file it was unable to parse instead of replacing the user's document
with the defaults it fell back to.
"""
def unreadable(exc: Exception) -> tuple[ProjectSettings, str]:
settings = ProjectSettings()
settings.project_path = str(toml_path)
return settings, f"{toml_path.name} could not be read, so it will not be overwritten: {exc}"
if toml_path.is_file():
try:
return load_project(toml_path), None
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ValueError) as exc:
log.warning("Cannot read %s: %s", toml_path, exc)
return unreadable(exc)
except Exception as exc:
# A project file is hand-editable, so it can hold any shape at all.
# Whatever comes out of the loader, the caller has to be able to
# report it: raising here would take a menu command down with it.
log.exception("Unexpected error reading %s", toml_path)
return unreadable(exc)
settings = ProjectSettings()
settings.project_path = str(toml_path)
return settings, None
def _meta_to_toml_settings(meta: ProjectMetadata, base: dict[str, Any] | None = None) -> ProjectSettings:
"""Convert a ProjectMetadata to ProjectSettings for TOML serialization.
*base* is the document the metadata came from. Only the handful of fields
ProjectMetadata models are overwritten, so sections the editor's project
view has no widgets for (rendering, input, singletons, a game's own tables)
survive a load-and-save.
"""
data: dict[str, Any] = dict(base or {})
data["name"] = meta.project_name
data["main"] = meta.default_scene
data["display"] = {**data.get("display", {}), "width": meta.window_width, "height": meta.window_height}
data["physics"] = {**data.get("physics", {}), "fps": meta.physics_fps, "gravity": meta.gravity}
if meta.engine_version:
data["engine"] = {**data.get("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()
# The document the current project was loaded from, kept so that saving
# rewrites only the fields the editor owns.
self._document: dict[str, Any] = {}
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
tab = state.workspace.active_scene
if tab is not None:
# The tab's tree was just replaced wholesale, so whatever it held
# about the old one describes objects that no longer exist.
record_source_baseline(tab, root, 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*.
The same save the editor's own menu runs: worked out in full first and
written second, so an existing file keeps the author's comments, blank
lines and hand-written code instead of being replaced with a fresh
emission of the tree. Writing this one from scratch was silently
destroying every one of those on a session that opened a scene and saved
it through this path.
Returns True when the file carries the whole scene. A save that could
not carry all of it is written anyway, reported through
:attr:`error_occurred` and answers False: the editor keeps the user's
work rather than refusing the save outright, and the tab stays modified.
A plan that would rewrite the author's ``__init__`` goes through the
editor's own prompt and is written only once the user accepts, which
also answers False -- nothing has been written yet.
"""
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)
plan = state.plan_save(path)
except Exception as exc:
self._error(f"Failed to save scene: {exc}")
return False
if plan is None:
self._error("No scene to save.")
return False
for entry in plan.report:
self._error(f"{path.name} cannot be written back exactly as it stands: {entry}")
try:
written = state._ask_or_commit(plan)
except Exception as exc:
self._error(f"Failed to save scene: {exc}")
return False
self.add_recent(str(path))
return written
# -- 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)."""
path = Path(path)
pf = path / _PROJECT_FILE if path.is_dir() else path
if not pf.exists():
self._document = {}
self._error(f"Project file not found: {pf}")
return False
try:
raw = tomllib.loads(pf.read_text())
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
self._document = {}
self._error(f"Invalid project file: {exc}")
return False
# name/main live at the top level. Files the older project templates
# generated spell them inside a [project] table; read those too, and
# let the canonical spelling win when a file carries both.
# A section may be any shape at all in a hand-edited file: read through
# the ones that are tables and leave the rest to fail validation on save.
def table(key: str) -> dict:
value = raw.get(key)
return value if isinstance(value, dict) else {}
section = table("project")
data: dict = {}
name = raw.get("name", section.get("name", section.get("project_name")))
if name is not None:
data["project_name"] = name
main = raw.get("main", section.get("main", section.get("default_scene")))
if main is not None:
data["default_scene"] = main
display = table("display")
if "width" in display:
data["window_width"] = display["width"]
if "height" in display:
data["window_height"] = display["height"]
physics = table("physics")
if "fps" in physics:
data["physics_fps"] = physics["fps"]
if "gravity" in physics:
data["gravity"] = physics["gravity"]
engine = table("engine")
if "version" in engine:
data["engine_version"] = engine["version"]
self.settings = ProjectMetadata.from_dict(data)
self._document = raw
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, self._document)
save_project(toml_settings, pf)
except (OSError, ValueError) 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)