"""Node manipulation operations mixin for EditorState."""
from __future__ import annotations
from typing import TYPE_CHECKING
from simvx.core import Node, Vec2
if TYPE_CHECKING:
from .state import EditorState
[docs]
class NodeOps:
"""Mixin providing node CRUD, placement, and scene-title operations.
Methods in this class are designed to be mixed into EditorState, which
provides the workspace, signals, and delegating properties they depend on.
"""
if TYPE_CHECKING:
self: EditorState # type: ignore[assignment]
# ------------------------------------------------------------------
# Node queries
# ------------------------------------------------------------------
[docs]
def get_scene_title(self) -> str:
name = self.current_scene_path.stem if self.current_scene_path else "Untitled"
return f"{name}{'*' if self._modified else ''}"
[docs]
def find_node(self, path: str) -> Node | None:
root = self.edited_scene.root if self.edited_scene else None
if not root:
return None
if not path:
return root
try:
return root[path]
except (KeyError, ValueError):
return None
# ------------------------------------------------------------------
# Undoable node mutations
# ------------------------------------------------------------------
[docs]
def rename_node(self, node: Node, new_name: str):
from simvx.core import CallableCommand
old_name = node.name
cmd = CallableCommand(
lambda: setattr(node, "name", new_name),
lambda: setattr(node, "name", old_name),
f"Rename {old_name} \u2192 {new_name}",
)
self.undo_stack.push(cmd)
self.modified = True
[docs]
def set_node_property(self, node: Node, prop: str, value):
from simvx.core import PropertyCommand
old_value = getattr(node, prop, None)
cmd = PropertyCommand(node, prop, old_value, value, f"Set {node.name}.{prop}")
self.undo_stack.push(cmd)
self.modified = True
[docs]
def add_node(self, node: Node, parent: Node | None = None):
from simvx.core import CallableCommand
target = parent or (self.edited_scene.root if self.edited_scene else None)
if not target:
return
cmd = CallableCommand(
lambda: target.add_child(node),
lambda: target.remove_child(node),
f"Add {node.name}",
)
self.undo_stack.push(cmd)
self.modified = True
[docs]
def remove_node(self, node: Node):
from simvx.core import CallableCommand
parent = node.parent
if not parent:
return
cmd = CallableCommand(
lambda: parent.remove_child(node),
lambda: parent.add_child(node),
f"Remove {node.name}",
)
self.undo_stack.push(cmd)
self.modified = True
[docs]
def duplicate_node(self, node: Node) -> Node | None:
parent = node.parent
if not parent:
return None
from simvx.core.scene import _deserialize_node, _serialize_node
data = _serialize_node(node)
clone = _deserialize_node(data)
if clone:
clone.name = f"{node.name}_copy"
self.add_node(clone, parent)
return clone
return None
# ------------------------------------------------------------------
# Mouse placement mode
# ------------------------------------------------------------------
[docs]
def enter_place_mode(self, node_class: type):
self.pending_place_type = node_class
self.place_mode_changed.emit()
[docs]
def cancel_place_mode(self):
if self.pending_place_type is not None:
self.pending_place_type = None
self.place_mode_changed.emit()
[docs]
def place_node_at(self, x: float, y: float, parent: Node | None = None):
cls = self.pending_place_type
if cls is None:
return None
self.pending_place_type = None
target = parent or (self.edited_scene.root if self.edited_scene else None)
if target is None:
self.place_mode_changed.emit()
return None
from simvx.core import CallableCommand, Node2D
node = cls(name=cls.__name__)
if isinstance(node, Node2D):
node.position = Vec2(x, y)
cmd = CallableCommand(
lambda p=target, n=node: p.add_child(n),
lambda p=target, n=node: p.remove_child(n),
f"Place {node.name} at ({x:.0f}, {y:.0f})",
)
self.undo_stack.push(cmd)
self.modified = True
self.selection.select(node)
self.place_mode_changed.emit()
return node