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