Source code for simvx.editor.state

"""Editor State — Central state manager for the editor."""

import logging
import math
from pathlib import Path

from simvx.core import (
    Clipboard,
    Gizmo,
    Node,
    OrbitCamera3D,
    SceneTree,
    ScriptManager,
    Selection,
    ShortcutManager,
    Signal,
    UndoStack,
    Vec2,
)
from simvx.core.file_state import FileStateMixin

from .live_file_ops import LiveFileOps
from .node_ops import NodeOps
from .scene_file_ops import SceneFileOps
from .script_ops import ScriptOps
from .workspace_tabs import SceneTabState, ScriptTabState, WorkspaceTabs

log = logging.getLogger(__name__)


[docs] class State(FileStateMixin, LiveFileOps, 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 = OrbitCamera3D(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 # Whether the PlayMode HotReloadManager applies live script edits. # Persisted by Root via prefs.config.editor.hot_reload_enabled. self.hot_reload_enabled: bool = True # Local import: play_mode imports State for typing; breaks the cycle. from .play_mode import PlayMode self.play_mode = PlayMode(self) # Viewport container reference (set by Root.ready) self._viewport_container = None # 3D viewport view mode: "solid", "wireframe", "bounding", or "textured" self.view_mode_3d: str = "solid" self.show_grid_3d: bool = True # Debug overlay toggles self.show_collision_shapes: bool = False self.show_camera_frustums: bool = False self.show_light_radius: bool = False self.show_nav_mesh: bool = False # 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 # Live Python file state self.edited_file: str | None = None self.edited_module = None self.edited_class: type | None = None self.file_classes: list[tuple[str, type]] = [] self.file_classification: str = "" # 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 self.export_requested = Signal() # Emitted to show Export dialog self.project_settings_requested = Signal() # Emitted to show Project Settings dialog self.input_map_requested = Signal() # Emitted to show Input Map dialog self.new_project_requested = Signal() # Emitted to transition to WelcomeScreen for new project self.open_project_requested = Signal() # Emitted to transition to WelcomeScreen for open project # 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.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
[docs] @edited_scene.setter def edited_scene(self, value: SceneTree): tab = self.workspace.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
[docs] @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
[docs] @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
[docs] @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) -> OrbitCamera3D: tab = self._active_or_last_scene() return tab.editor_camera if tab else self._fallback_camera
[docs] @editor_camera.setter def editor_camera(self, value: OrbitCamera3D): 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"
[docs] @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() root = tab.playing_root if tab else None if root is None and self.is_playing: gt = self.play_mode.game_tree if gt is not None: return gt.root return root @_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 # ------------------------------------------------------------------
[docs] @property def project_root(self) -> str: return str(self.project_path) if self.project_path else ""
@property def modified(self) -> bool: return self._modified
[docs] @modified.setter def modified(self, value: bool): self._modified = value self.scene_modified.emit()
[docs] def dirty_paths(self) -> set[Path]: """Return the resolved file paths of all open buffers with unsaved changes. Combines dirty file-backed script tabs and dirty scene tabs (the latter only when the scene has been saved at least once and therefore has a path on disk). Used by the file browser's git-status dot to colour in-editor unsaved files yellow (``Status.MODIFIED_UNSAVED``). Wrapped in a try/except: this runs in the GitStatusProvider polling thread; any exception there would kill the poller, so we swallow and log instead. """ try: paths: set[Path] = set() for tab in self.workspace._tabs: if isinstance(tab, ScriptTabState) and tab.kind == "file" and tab.is_dirty: paths.add(Path(tab.key).resolve()) elif isinstance(tab, SceneTabState) and tab.modified and tab.scene_path is not None: paths.add(Path(tab.scene_path).resolve()) return paths except Exception: log.warning("dirty_paths: failed to enumerate open buffers", exc_info=True) return set()
# ------------------------------------------------------------------ # ------------------------------------------------------------------ # Play mode # ------------------------------------------------------------------
[docs] def play_scene(self): """Enter play mode on the active scene tab. Delegates to PlayMode when available (creates an isolated game tree). Falls back to the legacy embed-in-viewport approach otherwise. """ if self.is_playing: return root = self.edited_scene.root if self.edited_scene else None if not root: return # If a script tab is active, activate the scene tab being played so the # viewport is visible during play. Must happen before the workspace # lock, since set_active is a no-op once locked. ws = self.workspace scene_tab = self._active_or_last_scene() if scene_tab is not None and ws.active_scene is not scene_tab: for i, t in enumerate(ws._tabs): if t is scene_tab: ws.set_active(i) break # Auto-save all open script tabs ws.save_all_scripts() ws.locked = True project_dir = str(self.project_path) if self.project_path else "" def _load_scripts(game_root: Node): ScriptManager.load_tree(game_root, project_dir) self.play_mode.start(on_tree_created=_load_scripts)
[docs] def pause_scene(self): """Toggle pause during play mode.""" if not self.is_playing: return self.play_mode.toggle_pause()
[docs] def stop_scene(self): """Exit play mode and restore the scene.""" if not self.is_playing: return self.play_mode.stop() self.workspace.locked = False self.play_state_changed.emit() self.scene_changed.emit()