Source code for simvx.editor.scene_file_ops

"""Scene file operations mixin for State."""

from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import Node
from simvx.core.scene_io import SceneFile, SceneModule, load_scene, parse_source

from .scene_diff import _canonical_var_names, apply_runtime_diff
from .workspace_tabs import SceneTabState, UntitledTabState

if TYPE_CHECKING:
    from .state import State


[docs] class SceneFileOps: """Mixin providing scene lifecycle and file dialog operations. Methods in this class are designed to be mixed into State, which provides the workspace, signals, and delegating properties they depend on. """ # Typed self for IDE support — at runtime this is always State. if TYPE_CHECKING: self: State # type: ignore[assignment]
[docs] def new_scene(self, root_type: type = Node, *, populate: bool = False): """Create a new scene tab with the given root type.""" name = root_type.__name__ if root_type is not Node else "Root" tab = SceneTabState.create(root_type=root_type, name=name) if populate: from .default_scenes import populate_default_scene populate_default_scene(tab.scene_tree.root) self.workspace.add_scene_tab(tab) self.scene_changed.emit()
[docs] def open_scene(self, path: str | Path): """Load a scene from disk into the active tab, or a new tab.""" path = Path(path) if not path.exists(): return root = load_scene(str(path)) if not root: return # Check if already open — reload in place existing = self.workspace.find_scene_tab(path) if existing is not None: self.workspace.set_active(existing) # Load into the active scene tab (overwrite its state) tab = self.workspace.active_scene if tab: tab.scene_tree.set_root(root) tab.scene_path = path tab.tab_name = path.stem tab.selection.clear() tab.undo_stack.clear() tab.modified = False else: new_tab = SceneTabState.create(root_type=type(root), name=root.name) new_tab.scene_tree.set_root(root) new_tab.scene_path = path new_tab.tab_name = path.stem self.workspace.add_scene_tab(new_tab) tab = new_tab # Capture identity hints — at load time each runtime child's # canonical var name matches its source var name; the dict-by- # identity preserves that mapping across subsequent renames so # save can issue a SceneClass.rename_child rather than a remove # + add seam. tab.identity_hints = _build_initial_identity_hints(root) self._add_recent(str(path)) self.scene_changed.emit()
[docs] def save_scene(self, path: str | Path | None = None, *, force: bool = False): """Save the current scene. Dispatches based on the active workspace tab: * **Untitled scratch buffer**: opens a file-save dialog. On accept the buffer's text is written to disk and the tab is promoted to a regular file-backed script tab. * **Script tab** (file or embedded): forwards to ``workspace.save_current_script``. * **Scene tab** (or no active tab): falls through to the scene-save path. Scene save: For an existing on-disk source: parse it, reconcile the runtime tree against the parsed source via :func:`apply_runtime_diff`, and write back via :meth:`SceneFile.save` — preserving comments, blank lines, hand-written code, and import ordering. For a brand-new scene (path didn't exist or wasn't set): emit greenfield via :meth:`SceneFile.from_runtime` + ``save``. Folder scenes (when ``path`` is a directory) route through :class:`SceneModule`. ``force=True`` skips the unresolved-reference warning dialog (used by the dialog's "Save anyway" callback to avoid re-prompting). """ # Dispatch on active tab kind when no explicit path is given. if path is None: ws = self.workspace idx = ws.active_index if 0 <= idx < ws.tab_count: if ws.is_untitled_tab(idx): self._show_save_untitled_dialog(idx) return False if ws.is_script_tab(idx): ws.save_current_script() return True save_path = Path(path) if path else self.current_scene_path if not save_path: self._show_save_as_dialog() return False root = self.edited_scene.root if self.edited_scene else None if not root: return False # Unresolved-reference warning: scene references class names that only # exist in unsaved Untitled buffers (or otherwise have no on-disk file # yet). Allow saving anyway via the dialog — the .py file is still # syntactically valid; the import will fail at runtime until the user # saves the buffer to a real path that the scene file can import. if not force: unresolved = self._unresolved_class_names(root) if unresolved: self._show_unsaved_class_warning(unresolved, save_path) return False active_tab = self.workspace.active_scene hints = active_tab.identity_hints if active_tab is not None else None if save_path.is_dir() or SceneModule.is_folder_scene(save_path): sm = SceneModule.load(save_path) apply_runtime_diff(sm.root.scene_class(), root, identity_hints=hints) sm.save() elif save_path.exists(): sf = SceneFile.load(save_path) apply_runtime_diff(sf.scene_class(), root, identity_hints=hints) sf.save() else: SceneFile.from_runtime(root).save(save_path) # Refresh the identity hints to reflect post-save state: every # current runtime child's canonical var now matches the on-disk # source, so reset the mapping for subsequent edits/renames. if active_tab is not None: active_tab.identity_hints = _build_initial_identity_hints(root) self.current_scene_path = save_path self._modified = False self._add_recent(str(save_path)) self.scene_modified.emit() return True
# ------------------------------------------------------------------ # Unresolved-reference detection # ------------------------------------------------------------------ def _unresolved_class_names(self, root: Node) -> list[str]: """Return class names referenced in *root*'s tree that lack a saved file. A class is considered "unsaved" when its name appears in an open :class:`UntitledTabState`'s top-level ``class`` statements. Built-in engine classes and project classes already on disk are always considered resolved — they are saved by definition. The check is intentionally name-based rather than module-based: an Untitled buffer has no real ``__module__`` we could reach via ``importlib``, so the only stable signal we have is the source text. """ unsaved = self._unsaved_class_names() if not unsaved: return [] seen: set[str] = set() result: list[str] = [] for node in [root, *root.find_all(Node)]: cls_name = type(node).__name__ if cls_name in unsaved and cls_name not in seen: seen.add(cls_name) result.append(cls_name) return result def _unsaved_class_names(self) -> set[str]: """Top-level class names defined in any open Untitled scratch buffer.""" names: set[str] = set() for tab in self.workspace._tabs: if not isinstance(tab, UntitledTabState): continue try: tree = parse_source(tab.editor.text) except Exception: continue if tree.errors: continue for cls in tree.iter_classes(): names.add(cls.name.value) return names def _show_unsaved_class_warning(self, names: list[str], save_path: Path) -> None: """Open the modal warning, hooked to retry the save on confirm.""" dialog = getattr(self, "_unsaved_class_warning_dialog", None) if dialog is None: return # no UI wired (e.g. headless test that didn't install one) dialog.show_for( class_names=names, on_confirm=lambda: self.save_scene(save_path, force=True), ) def _show_save_untitled_dialog(self, index: int) -> None: """Prompt the user for a destination, then promote the Untitled tab.""" dlg = self._file_dialog if dlg is None: return dlg.file_selected.clear() def _on_chosen(p): self.workspace.promote_untitled_to_file(index, p) dlg.file_selected.connect(_on_chosen) start = ( str(self.project_path / "src") if self.project_path is not None else None ) dlg.show(mode="save", path=start, filter="*.py") def _show_open_dialog(self): if not self._file_dialog: return self._file_dialog.file_selected.clear() self._file_dialog.file_selected.connect(self.open_scene) start = str(self.current_scene_path.parent) if self.current_scene_path else None self._file_dialog.show(mode="open", path=start, filter="*.py") def _show_save_as_dialog(self): if not self._file_dialog: return self._file_dialog.file_selected.clear() self._file_dialog.file_selected.connect(lambda p: self.save_scene(p)) start = str(self.current_scene_path) if self.current_scene_path else None self._file_dialog.show(mode="save", path=start, filter="*.py") def _add_recent(self, path: str): if path in self.recent_files: self.recent_files.remove(path) self.recent_files.insert(0, path) self.recent_files = self.recent_files[:10]
def _build_initial_identity_hints(root: Node) -> dict[Node, str]: """Map each top-level runtime child to its canonical source var name. At scene-load time the runtime tree mirrors the on-disk source, so canonical var names derived from current ``.name`` match the source var names exactly. The returned dict survives subsequent renames (``Node.name`` may change but the dict is keyed by Node identity) and feeds :func:`simvx.editor.scene_diff.apply_runtime_diff` so save can issue an in-place rename instead of remove + add. """ children = list(root.children) if not children: return {} var_names = _canonical_var_names(children) return dict(zip(children, var_names, strict=True))