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