Source code for simvx.editor.project

"""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] @dataclass class ProjectMetadata: """Persistent project-level configuration stored in simvx.toml.""" project_name: str = "Untitled Project" default_scene: str = "" physics_fps: int = 60 window_width: int = 1280 window_height: int = 720 gravity: float = 9.8 engine_version: str = ""
[docs] @classmethod def from_dict(cls, data: dict) -> ProjectMetadata: """Create from a dict, ignoring unknown keys for forward compat.""" known = {f.name for f in cls.__dataclass_fields__.values()} return cls(**{k: v for k, v in data.items() if k in known})
[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)