"""Live Python file operations mixin for State."""
import importlib.util
import inspect
import logging
import os
import traceback
from pathlib import Path
from typing import TYPE_CHECKING, Any
from simvx.core import Node, SceneTree, Vec2
from .workspace_tabs import SceneTabState
if TYPE_CHECKING:
from types import ModuleType
from .state import State
log = logging.getLogger(__name__)
[docs]
class LiveFileOps:
"""Mixin for opening Python scene files and working with live Node objects.
Replaces JSON-based scene loading with a workflow where the editor imports
Python files, identifies Node subclasses, instantiates them, and manipulates
live objects. Properties are read from instances via ``get_properties()``.
"""
if TYPE_CHECKING:
self: State # type: ignore[assignment]
[docs]
def open_file(self, path: str | Path) -> None:
"""Open a Python file, import it, and instantiate its primary Node class."""
path = Path(path).resolve()
if not path.exists():
log.error("File not found: %s", path)
return
if path.suffix != ".py":
log.error("Not a Python file: %s", path)
return
module = self._import_module(path)
if module is None:
return
classification = self.classify_file(module, str(path))
classes = self.find_node_classes(module)
primary = self.get_primary_class(module, str(path))
if primary is None:
log.warning("No Node subclass found in %s", path)
return
instance = self.instantiate_class(primary)
if instance is None:
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)
tab = self.workspace.active_scene
if tab:
tab.scene_tree.set_root(instance)
tab.scene_path = path
tab.tab_name = path.stem
tab.source_file = str(path)
tab.source_class = primary
tab.source_module = module
tab.file_classification = classification
tab.selection.clear()
tab.undo_stack.clear()
tab.modified = False
else:
new_tab = self._build_live_tab(instance, path, primary, module, classification)
self.workspace.add_scene_tab(new_tab)
# Update state-level tracking
self.edited_file = str(path)
self.edited_module = module
self.edited_class = primary
self.file_classes = classes
self.file_classification = classification
self._add_recent(str(path))
self.scene_changed.emit()
[docs]
def instantiate_class(self, cls: type) -> Node | None:
"""Safely instantiate a Node subclass. Returns None on error."""
try:
return cls()
except Exception:
log.error("Failed to instantiate %s:\n%s", cls.__name__, traceback.format_exc())
# Create an error placeholder
placeholder = Node(name=f"{cls.__name__} (error)")
placeholder._script_error = True
return placeholder
[docs]
def classify_file(self, module: ModuleType, file_path: str) -> str:
"""Classify a Python file. Returns 'main', 'scene', or 'node'."""
classes = self.find_node_classes(module)
class_names = {name for name, _ in classes}
# 'main' -- has class named Main
if "Main" in class_names:
return "main"
# 'scene' -- class name matches filename (case-insensitive)
stem = Path(file_path).stem.lower()
for name, _ in classes:
if name.lower() == stem:
return "scene"
# 'node' -- everything else
return "node"
[docs]
def find_node_classes(self, module: ModuleType) -> list[tuple[str, type]]:
"""Find all Node subclasses defined in a module."""
result = []
for name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, Node) and obj is not Node and obj.__module__ == module.__name__:
result.append((name, obj))
return result
[docs]
def get_primary_class(self, module: ModuleType, file_path: str) -> type | None:
"""Get the primary class to instantiate from a module.
Priority: Main > filename match > sole class > None
"""
classes = self.find_node_classes(module)
if not classes:
return None
class_dict = {name: cls for name, cls in classes}
# Priority 1: class named Main
if "Main" in class_dict:
return class_dict["Main"]
# Priority 2: class name matches filename (case-insensitive)
stem = Path(file_path).stem.lower()
for name, cls in classes:
if name.lower() == stem:
return cls
# Priority 3: sole class
if len(classes) == 1:
return classes[0][1]
return None
def _build_live_tab(self, instance: Node, path: Path, cls: type, module: Any, classification: str) -> SceneTabState:
"""Build a SceneTabState from an already-instantiated live node."""
import math
from simvx.core import Control as _Control
from simvx.core import Node2D, OrbitCamera3D
tree = SceneTree(screen_size=Vec2(800, 600))
tree.set_root(instance)
sub_mode = "2d" if isinstance(instance, (Node2D, _Control)) else "3d"
cam = OrbitCamera3D(name="EditorCamera")
cam.pitch = math.radians(-35.0)
cam.yaw = math.radians(30.0)
cam.distance = 8.0
cam._update_transform()
from .workspace_tabs import _SceneTabPlaceholder
return SceneTabState(
scene_tree=tree,
viewport_sub_mode=sub_mode,
editor_camera=cam,
placeholder=_SceneTabPlaceholder(name=f"Scene:{instance.name}"),
tab_name=path.stem,
scene_path=path,
source_file=str(path),
source_class=cls,
source_module=module,
file_classification=classification,
)
def _import_module(self, path: Path) -> ModuleType | None:
"""Import a Python file as a module."""
module_name = f"_simvx_live_.{path.stem}"
spec = importlib.util.spec_from_file_location(module_name, str(path))
if spec is None or spec.loader is None:
log.error("Cannot create module spec for %s", path)
return None
try:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
except Exception:
log.error("Failed to import %s:\n%s", path, traceback.format_exc())
return None
[docs]
class FileWatcher:
"""Polls file mtimes to detect external modifications.
Usage::
watcher = FileWatcher()
watcher.watch("/path/to/player.py")
# Periodically:
changed = watcher.check() # returns list of changed paths
"""
def __init__(self):
self._watched: dict[str, float] = {} # path -> last known mtime
[docs]
def watch(self, path: str | Path) -> None:
"""Start watching a file. Records its current mtime."""
p = str(Path(path).resolve())
try:
self._watched[p] = os.stat(p).st_mtime
except OSError:
self._watched[p] = 0.0
[docs]
def unwatch(self, path: str | Path) -> None:
"""Stop watching a file."""
self._watched.pop(str(Path(path).resolve()), None)
[docs]
def watch_directory(self, directory: str | Path, suffix: str = ".py") -> None:
"""Watch all files with given suffix in a directory tree."""
d = Path(directory)
if d.is_dir():
for f in d.rglob(f"*{suffix}"):
self.watch(f)
[docs]
def check(self) -> list[str]:
"""Check all watched files for mtime changes. Returns list of changed paths."""
changed: list[str] = []
for path, last_mtime in list(self._watched.items()):
try:
current_mtime = os.stat(path).st_mtime
except OSError:
continue # file deleted or inaccessible
if current_mtime != last_mtime:
self._watched[path] = current_mtime
changed.append(path)
return changed
[docs]
def clear(self) -> None:
"""Stop watching all files."""
self._watched.clear()