Source code for simvx.editor.state

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