"""Editor State — Central state manager for the editor."""
from __future__ import annotations
import math
from pathlib import Path
from simvx.core import (
Clipboard,
EditorCamera3D,
Gizmo,
Node,
SceneTree,
ScriptManager,
Selection,
ShortcutManager,
Signal,
UndoStack,
Vec2,
load_scene,
save_scene,
)
from simvx.core.file_state import FileStateMixin
from .node_ops import NodeOps
from .scene_file_ops import SceneFileOps
from .script_ops import ScriptOps
from .workspace_tabs import SceneTabState, WorkspaceTabs
[docs]
class EditorState(FileStateMixin, SceneFileOps, ScriptOps, NodeOps):
"""Central state manager holding all editor state.
Acts as the single source of truth for the editor — panels read from
and write to this object, coordinating via signals.
Scene-specific state (edited_scene, selection, undo_stack, editor_camera,
viewport_mode, _modified) is delegated to the active scene tab via
WorkspaceTabs. Fallback objects are used when no scene tab is active.
Operations are organised into mixins:
- SceneFileOps — new/open/save scene, file dialogs, recent files
- ScriptOps — attach/detach/create/save scripts
- NodeOps — node CRUD, placement mode, scene title
"""
def __init__(self):
self.project_path: Path | None = None
# Workspace tab manager
self.workspace = WorkspaceTabs()
# Fallback objects when no scene tab is active
self._fallback_scene = SceneTree(screen_size=Vec2(800, 600))
self._fallback_selection = Selection()
self._fallback_undo = UndoStack(max_size=200)
self._fallback_camera = EditorCamera3D(name="EditorCamera")
self._fallback_camera.pitch = math.radians(-35.0)
self._fallback_camera.yaw = math.radians(30.0)
self._fallback_camera.distance = 8.0
self._fallback_camera._update_transform()
# Sub-systems
self.gizmo = Gizmo()
self.shortcuts = ShortcutManager()
self.clipboard = Clipboard
# Play mode state
self.is_playing = False
self.is_paused = False
# Viewport container reference (set by EditorShell.ready)
self._viewport_container = None
self._viewport_code = None
# 3D viewport view mode: "solid", "wireframe", or "bounding"
self.view_mode_3d: str = "solid"
self.show_grid_3d: bool = True
# Mouse placement mode
self.pending_place_type: type | None = None
# Editor mode: "scene" or "script"
self.editor_mode: str = "scene"
self.script_mode_inspector_visible: bool = False
# Signals
self.scene_changed = Signal()
self.selection_changed = Signal()
self.scene_modified = Signal()
self.play_state_changed = Signal()
self.add_node_requested = Signal()
self.place_mode_changed = Signal()
self.viewport_mode_changed = Signal()
self.mode_changed = Signal() # Emitted when editor_mode changes ("scene"/"script")
self.script_changed = Signal()
self.new_scene_requested = Signal() # Emitted to show NewSceneDialog
self.preferences_requested = Signal() # Emitted to show Preferences dialog
self.about_requested = Signal() # Emitted to show About dialog
# File lifecycle signals (shared with IDE via FileStateMixin)
self._init_file_signals()
# Wire fallback selection changes via tagged proxy
def _fb_proxy():
return self.selection_changed.emit()
_fb_proxy._is_proxy = True # type: ignore[attr-defined]
self._fallback_selection.selection_changed.connect(_fb_proxy)
self._wired_selection_signal = self._fallback_selection.selection_changed
# Wire workspace tab switches to refresh panels
self.workspace.active_tab_changed.connect(self._on_tab_switched)
# Update tab modified indicators whenever scene modified state changes
self.scene_modified.connect(self.workspace._update_all_tab_titles)
# File dialog reference
self._file_dialog = None
# Recent files
self.recent_files: list[str] = []
# _wired_selection_signal is set above after fallback selection wiring
# ------------------------------------------------------------------
# Delegating properties — forward to active scene tab
# ------------------------------------------------------------------
def _active_or_last_scene(self) -> SceneTabState | None:
"""Return the active scene tab, or the last active scene tab if a script tab is selected."""
tab = self.workspace.get_active_scene()
if tab:
self._last_scene_tab = tab
return tab
return getattr(self, "_last_scene_tab", None)
@property
def edited_scene(self) -> SceneTree:
tab = self._active_or_last_scene()
return tab.scene_tree if tab else self._fallback_scene
@edited_scene.setter
def edited_scene(self, value: SceneTree):
tab = self.workspace.get_active_scene()
if tab:
tab.scene_tree = value
else:
# Create a scene tab for direct assignment (backward compat)
st = SceneTabState(scene_tree=value)
self.workspace.add_scene_tab(st)
@property
def current_scene_path(self) -> Path | None:
tab = self._active_or_last_scene()
return tab.scene_path if tab else None
@current_scene_path.setter
def current_scene_path(self, value: Path | None):
tab = self._active_or_last_scene()
if tab:
tab.scene_path = value
@property
def selection(self) -> Selection:
tab = self._active_or_last_scene()
return tab.selection if tab else self._fallback_selection
@selection.setter
def selection(self, value: Selection):
tab = self._active_or_last_scene()
if tab:
tab.selection = value
@property
def undo_stack(self) -> UndoStack:
tab = self._active_or_last_scene()
return tab.undo_stack if tab else self._fallback_undo
@undo_stack.setter
def undo_stack(self, value: UndoStack):
tab = self._active_or_last_scene()
if tab:
tab.undo_stack = value
@property
def editor_camera(self) -> EditorCamera3D:
tab = self._active_or_last_scene()
return tab.editor_camera if tab else self._fallback_camera
@editor_camera.setter
def editor_camera(self, value: EditorCamera3D):
tab = self._active_or_last_scene()
if tab:
tab.editor_camera = value
@property
def viewport_mode(self) -> str:
tab = self._active_or_last_scene()
return tab.viewport_sub_mode if tab else "3d"
@viewport_mode.setter
def viewport_mode(self, value: str):
tab = self._active_or_last_scene()
if tab:
tab.viewport_sub_mode = value
@property
def _modified(self) -> bool:
tab = self._active_or_last_scene()
return tab.modified if tab else False
@_modified.setter
def _modified(self, value: bool):
tab = self._active_or_last_scene()
if tab:
tab.modified = value
@property
def _saved_scene_data(self) -> dict | None:
tab = self._active_or_last_scene()
return tab.saved_scene_data if tab else None
@_saved_scene_data.setter
def _saved_scene_data(self, value: dict | None):
tab = self._active_or_last_scene()
if tab:
tab.saved_scene_data = value
@property
def _playing_root(self) -> Node | None:
tab = self._active_or_last_scene()
return tab.playing_root if tab else None
@_playing_root.setter
def _playing_root(self, value: Node | None):
tab = self._active_or_last_scene()
if tab:
tab.playing_root = value
# ------------------------------------------------------------------
# Tab switch handling
# ------------------------------------------------------------------
def _on_tab_switched(self):
"""Handle workspace tab switch — rewire selection signal, refresh panels."""
# Disconnect old selection proxy
old_sig = self._wired_selection_signal
if old_sig is not None:
old_sig._callbacks = [cb for cb in old_sig._callbacks if getattr(cb, "_is_proxy", False) is False]
# Connect new selection signal via a tagged proxy
new_sel = self.selection
def proxy():
return self.selection_changed.emit()
proxy._is_proxy = True # type: ignore[attr-defined]
new_sel.selection_changed.connect(proxy)
self._wired_selection_signal = new_sel.selection_changed
# Notify panels
self.scene_changed.emit()
self.selection_changed.emit()
self.viewport_mode_changed.emit()
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def project_root(self) -> str:
return str(self.project_path) if self.project_path else ""
@property
def modified(self) -> bool:
return self._modified
@modified.setter
def modified(self, value: bool):
self._modified = value
self.scene_modified.emit()
# ------------------------------------------------------------------
# Play mode (legacy methods — prefer PlayMode class)
# ------------------------------------------------------------------
[docs]
def play_scene(self):
"""Enter play mode on the active scene tab."""
if self.is_playing:
return
root = self.edited_scene.root if self.edited_scene else None
if not root:
return
# Auto-save all open script tabs
self.workspace.save_all_scripts()
# Save scene state for restoration
from simvx.core.scene import _serialize_node
self._saved_scene_data = _serialize_node(root)
self.is_playing = True
self.is_paused = False
self.workspace.locked = True
# Embed scene root in viewport
if self._viewport_container is not None:
self._viewport_container.add_child(root)
self._playing_root = root
# Load scripts
project_dir = str(self.project_path) if self.project_path else ""
loaded = ScriptManager.load_tree(root, project_dir)
for node in loaded:
try:
node.ready()
except Exception:
import logging
logging.getLogger(__name__).exception("Script ready() error on '%s'", node.name)
self.play_state_changed.emit()
[docs]
def pause_scene(self):
"""Toggle pause during play mode."""
if not self.is_playing:
return
self.is_paused = not self.is_paused
self.play_state_changed.emit()
[docs]
def stop_scene(self):
"""Exit play mode and restore the scene."""
if not self.is_playing:
return
self.is_playing = False
self.is_paused = False
self.workspace.locked = False
# Remove playing root from viewport
if self._playing_root is not None and self._viewport_container is not None:
if self._playing_root.parent is self._viewport_container:
self._viewport_container.remove_child(self._playing_root)
self._playing_root = None
# Restore scene
if self._saved_scene_data:
from simvx.core.scene import _deserialize_node
root = _deserialize_node(self._saved_scene_data)
if root:
self.edited_scene.set_root(root)
self._saved_scene_data = None
self.play_state_changed.emit()
self.scene_changed.emit()