Source code for simvx.editor.root

"""Editor Root — the top-level node of the editor scene tree.

Owns the editor's panel layout (top bar, dock container, output, status bar)
and wires high-level signals (theme changes, play state, project switching,
autosave recovery, command palette) to the appropriate dialogs and panels.

Top bar: menus (left) + play controls (right). No separate toolbar.
Viewport tools live INSIDE the viewport tab, not globally.
Left panel: tabbed (Scene Tree | File Browser).
Center: tabbed (Viewport | Code Editor | ...).
Right panel: Inspector.
Bottom: Output (collapsible).
Status bar at bottom.

Usage:
    from simvx.editor.root import Root
    launch()   # or: simvx-editor from CLI
"""

import logging

from simvx.core import (
    Control,  # noqa: F401  -- re-exported by `from simvx.editor.root import *` historically
    DockContainer,
    DockPanel,
    FileDialog,
    HBoxContainer,
    Label,
    MenuBar,
    Node,
    Node3D,
    Panel,
    SplitContainer,
    TabContainer,
    ToolbarButton,
    VBoxContainer,
    Vec2,
)

from .about_dialog import AboutDialog
from .autosave import Autosave, AutosaveRecoveryDialog, format_recovery_message
from .command_palette import CommandPalette, register_editor_commands
from .game_render_hook import GameRenderHook
from .menus import build_menu_bar, register_shortcuts
from .config import Config
from .preferences_dialog import PreferencesDialog
from .state import State
from .workspace_tabs import NewSceneDialog

log = logging.getLogger(__name__)

_MENUBAR_H = 28.0
_STATUS_H = 20.0


[docs] class Root(Node): """Top-level node of the editor scene tree. Layout (top to bottom): TopBar (28 px) — menus left, play buttons right DockContainer (fills remainder minus output and status) Left dock: TabContainer [Scene Tree, File Browser] Center: TabContainer [Viewport, ...] Right dock: Inspector panel OutputPanel (collapsible via SplitContainer) StatusBar (20 px) """ def __init__(self, project_path: str | None = None, **kwargs): kwargs.setdefault("name", "Root") super().__init__(**kwargs) self._project_path = project_path self.state = State() self.prefs = Config() self.prefs.load() self.theme = self.prefs.get_theme() # Layout references (populated in ready) self._root_panel: Panel | None = None self._vbox: VBoxContainer | None = None self._top_bar: Panel | None = None self._menu_bar: MenuBar | None = None self._dock: DockContainer | None = None self._status_bar = None self._file_dialog: FileDialog | None = None self._win_w: float = 1600.0 self._win_h: float = 900.0 self._new_scene_dialog: NewSceneDialog | None = None self._command_palette: CommandPalette | None = None self._preferences_dialog: PreferencesDialog | None = None self._about_dialog: AboutDialog | None = None self._export_dialog = None self._project_settings_dialog = None self._input_map_dialog = None # Panel references self._left_tabs: TabContainer | None = None self._center_tabs: TabContainer | None = None self._center_wrapper: Panel | None = None self._output_panel = None self._scene_tree_content = None self._file_browser_content = None self._inspector_content = None self._viewport_3d = None self._viewport_2d = None self._code_editor_tab = None self._play_box = None self._console_panel = None self._center_split = None # Mode switcher buttons self._mode_switcher = None self._btn_3d: ToolbarButton | None = None self._btn_2d: ToolbarButton | None = None self._btn_code: ToolbarButton | None = None # Play controls (built in _build_top_bar) self._play_controls = None self.state.hot_reload_enabled = bool(self.prefs.config.editor.hot_reload_enabled) # File watcher for hot reload from .live_file_ops import FileWatcher self._watcher = FileWatcher() self._watcher_timer = 0.0 # Autosave / crash recovery self._autosave = Autosave(self.state) self._recovery_dialog: AutosaveRecoveryDialog | None = None # Game render hook (engine pre_render extension for offscreen viewports) self._render_hook: GameRenderHook | None = None # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------
[docs] def ready(self): """Build the full editor UI tree.""" self.state.new_scene(root_type=Node3D, populate=True) if self._tree: ss = self._tree.screen_size if hasattr(ss, "x"): self._win_w, self._win_h = float(ss.x), float(ss.y) else: self._win_w, self._win_h = float(ss[0]), float(ss[1]) self._root_panel = self.add_child(Panel(name="RootPanel")) self._root_panel.bg_colour = self.theme.colours["bg_dark"] self._root_panel.border_width = 0 self._root_panel.size = Vec2(self._win_w, self._win_h) self._vbox = self._root_panel.add_child(VBoxContainer(name="MainVBox")) self._vbox.separation = 0 self._vbox.size = Vec2(self._win_w, self._win_h) self._build_top_bar() self._build_main_area() self._build_status_bar() self._file_dialog = FileDialog(name="FileDialog") self._file_dialog.visible = False self._root_panel.add_child(self._file_dialog) self.state._file_dialog = self._file_dialog self.state._viewport_container = self._viewport_3d self.state.viewport_mode_changed.connect(self._update_viewport_visibility) self.state.workspace.active_tab_changed.connect(self._update_viewport_visibility) self.state.viewport_mode_changed.connect(self._sync_mode_buttons) self.state.workspace.active_tab_changed.connect(self._sync_mode_buttons) self.state.new_scene_requested.connect(self._show_new_scene_dialog) self.state.new_project_requested.connect(self._on_new_project) self.state.open_project_requested.connect(self._on_open_project) self._resize_layout() register_shortcuts(self.state) self._command_palette = CommandPalette(state=self.state, name="CommandPalette") self._root_panel.add_child(self._command_palette) register_editor_commands(self._command_palette, self.state) self.state.shortcuts.register( "command_palette", "Ctrl+Shift+P", lambda: self._command_palette.toggle(), description="Command Palette" ) self._preferences_dialog = PreferencesDialog( prefs=self.prefs, on_theme_changed=self._on_theme_changed, name="PreferencesDialog" ) self._root_panel.add_child(self._preferences_dialog) self.state.preferences_requested.connect(self._show_preferences) self._about_dialog = AboutDialog(name="AboutDialog") self._root_panel.add_child(self._about_dialog) self.state.about_requested.connect(self._show_about) from .export_dialog import ExportDialog self._export_dialog = ExportDialog(state=self.state, name="ExportDialog") self._root_panel.add_child(self._export_dialog) self.state.export_requested.connect(self._show_export_dialog) from .project_settings_dialog import ProjectSettingsDialog self._project_settings_dialog = ProjectSettingsDialog(state=self.state, name="ProjectSettingsDialog") self._root_panel.add_child(self._project_settings_dialog) self.state.project_settings_requested.connect(self._show_project_settings) from .input_map_dialog import InputMapDialog self._input_map_dialog = InputMapDialog(state=self.state, name="InputMapDialog") self._root_panel.add_child(self._input_map_dialog) self.state.input_map_requested.connect(self._show_input_map) from .unsaved_class_warning_dialog import UnsavedClassWarningDialog self._unsaved_class_warning_dialog = UnsavedClassWarningDialog(name="UnsavedClassWarningDialog") self._root_panel.add_child(self._unsaved_class_warning_dialog) # Expose so SceneFileOps.save_scene can pop the warning when needed. self.state._unsaved_class_warning_dialog = self._unsaved_class_warning_dialog # First-run tour (only when running in a real App, not in test harness) if self._tree and hasattr(self._tree, "app") and self._tree.app is not None: try: tour_done = getattr(self.prefs.config.editor, "tour_completed", False) if not tour_done: from .hints import TourGuide self._tour = TourGuide(name="Tour") self._root_panel.add_child(self._tour) self._tour.tour_completed.connect(lambda: self._mark_tour_completed()) self._tour.start() except Exception: log.debug("First-run tour failed to start", exc_info=True) if self._project_path: from pathlib import Path from .project import ProjectSession pm = ProjectSession() if pm.load_project(self._project_path): self.state.project_path = Path(self._project_path) self._watcher.watch_directory(self._project_path) ds = pm.settings.default_scene if ds: scene_path = Path(self._project_path) / ds if scene_path.exists(): pm._do_open_scene(self.state, scene_path) # Recovery prompt — runs after the project (if any) has loaded so we # can compare autosave mtime against the live scene file. self._maybe_show_recovery_dialog() # Install offscreen render hook for game/textured viewport self._render_hook = GameRenderHook(self.state, self._play_mode) self._render_hook.attach(self._tree, self._viewport_3d, self._viewport_2d)
[docs] def process(self, dt: float): """Per-frame update — track window resize and update status.""" if self._tree: ss = self._tree.screen_size w = float(ss.x) if hasattr(ss, "x") else float(ss[0]) h = float(ss.y) if hasattr(ss, "y") else float(ss[1]) if w != self._win_w or h != self._win_h: self._win_w, self._win_h = w, h if self._root_panel: self._root_panel.size = Vec2(w, h) if self._vbox: self._vbox.size = Vec2(w, h) self._resize_layout() self._layout_center_overlays() if hasattr(self, "_play_mode"): self._play_mode.update(dt) self._update_profiler(dt) # Hot reload: poll for external file changes (~1s interval) self._watcher_timer += dt if self._watcher_timer >= 1.0: self._watcher_timer = 0.0 for changed_path in self._watcher.check(): log.info("File changed externally: %s — reloading", changed_path) self.state.open_file(changed_path) if self._autosave is not None: self._autosave.poll() if self._status_bar and hasattr(self._status_bar, "update_mouse_pos"): from simvx.core import Input mx, my = Input.mouse_position self._status_bar.update_mouse_pos(mx, my)
def _update_profiler(self, dt: float) -> None: """Drive the editor's profiler panel. In play mode, :class:`PlayMode` already times phases and commits a frame via the attached profiler. In edit mode we record the editor's own frame time directly so users still see live FPS for their UI. Per-pass GPU timings (``engine.gpu_phase_times``) are forwarded to the profiler regardless of mode so the GPU section stays live. """ panel = getattr(self, "_profiler_panel", None) if panel is None or not hasattr(panel, "record_frame"): return self._forward_gpu_phase_times(panel) if getattr(self.state, "is_playing", False): panel.source_label = "Game" return panel.source_label = "Editor" panel.record_frame(dt, self.state.edited_scene) def _forward_gpu_phase_times(self, panel) -> None: """Copy ``engine.gpu_phase_times`` into the panel's profiler. The renderer publishes a per-pass dict (label -> ms) each frame; we mirror it into ring buffers so the GPU section can draw sparklines. Silent no-op when the engine isn't a Vulkan one (e.g. headless editor tests with a mock app). """ app = getattr(self._tree, "app", None) if self._tree else None engine = getattr(app, "_engine", None) if app else None gpu_times = getattr(engine, "gpu_phase_times", None) if not gpu_times: return prof = panel.profiler for label, ms in gpu_times.items(): prof.record_gpu_phase(label, ms) # ------------------------------------------------------------------ # Panel access # ------------------------------------------------------------------
[docs] @property def scene_tree_panel(self): return self._scene_tree_content
[docs] @property def inspector_panel(self): return self._inspector_content
@property def _script_tabs(self): """Accessor for script tab operations — delegates to workspace.""" return self.state.workspace # ------------------------------------------------------------------ # Layout sizing # ------------------------------------------------------------------ def _resize_layout(self): """Update child sizes to fill the window.""" if not self._vbox: return if self._top_bar: self._top_bar.size = Vec2(self._win_w, _MENUBAR_H) if self._menu_bar: self._menu_bar.size = Vec2(self._win_w * 0.6, _MENUBAR_H) if self._mode_switcher: mode_w = self._mode_switcher.size.x self._mode_switcher.position = Vec2((self._win_w - mode_w) / 2, 2) if self._play_box: play_w = self._play_box.size.x self._play_box.position = Vec2(self._win_w - play_w - 8, 2) main_h = self._win_h - _MENUBAR_H - _STATUS_H if self._dock: self._dock.size = Vec2(self._win_w, main_h) if self._status_bar: self._status_bar.size = Vec2(self._win_w, _STATUS_H) self._layout_center_overlays() # ------------------------------------------------------------------ # Top bar # ------------------------------------------------------------------ def _build_top_bar(self): """Build the unified top bar with menus on the left and play controls on the right.""" top_bar = Panel(name="TopBar") top_bar.bg_colour = (0.11, 0.11, 0.12, 1.0) top_bar.border_width = 0 top_bar.size = Vec2(self._win_w, _MENUBAR_H) self._menu_bar = build_menu_bar(self.state) self._menu_bar.size = Vec2(self._win_w * 0.6, _MENUBAR_H) self._menu_bar.bg_colour = (0, 0, 0, 0) top_bar.add_child(self._menu_bar) btn_h = _MENUBAR_H - 4 mode_box = HBoxContainer(name="ModeSwitcher") mode_box.separation = 2 self._btn_3d = ToolbarButton("3D", on_press=lambda: self._on_mode("3d"), name="Mode3D") self._btn_3d.size = Vec2(44, btn_h) self._btn_3d.active = True self._btn_2d = ToolbarButton("2D", on_press=lambda: self._on_mode("2d"), name="Mode2D") self._btn_2d.size = Vec2(44, btn_h) self._btn_code = ToolbarButton("Code", on_press=lambda: self._on_mode("code"), name="ModeCode") self._btn_code.size = Vec2(52, btn_h) mode_box.add_child(self._btn_3d) mode_box.add_child(self._btn_2d) mode_box.add_child(self._btn_code) top_bar.add_child(mode_box) mode_w = 44 + 44 + 52 + 4 mode_box.position = Vec2((self._win_w - mode_w) / 2, 2) mode_box.size = Vec2(mode_w, btn_h) self._mode_switcher = mode_box # PlayMode is constructed in State.__init__; root retains a # direct reference for the per-frame update call. self._play_mode = self.state.play_mode from .panels.play_controls import PlayControlBar self._play_controls = PlayControlBar(self.state, self._play_mode, self.prefs, button_height=btn_h) top_bar.add_child(self._play_controls) play_w = self._play_controls.get_minimum_size().x self._play_controls.position = Vec2(self._win_w - play_w - 8, 2) self._play_controls.size = Vec2(play_w, btn_h) self._play_box = self._play_controls self._top_bar = top_bar self._vbox.add_child(top_bar) # ------------------------------------------------------------------ # Main area: DockContainer with 3 panels + bottom output # ------------------------------------------------------------------ def _build_main_area(self): """Build the DockContainer with left tabs, center (viewport+output), and inspector. The output panel lives INSIDE the center column as a vertical split, so left and right panels extend full height. """ dock = DockContainer(name="MainDock") dock.size = Vec2(self._win_w, self._win_h - _MENUBAR_H - _STATUS_H) center_panel = self._build_center_panel() dock.add_panel(center_panel, "center") left_panel = self._build_left_panel() dock.add_panel(left_panel, "left") # Right: Properties — full height self._inspector_content = self._make_panel_or_fallback( "properties", "PropertiesPanel", "Properties", (0.14, 0.14, 0.15, 1.0) ) inspector_panel = DockPanel(title="Properties", name="PropertiesDock") inspector_panel.set_content(self._inspector_content) dock.add_panel(inspector_panel, "right") self._dock = dock self.state._dock_container = dock self._vbox.add_child(dock) def _build_left_panel(self) -> DockPanel: """Build left panel with tabbed Scene Tree and File Browser.""" left_dock = DockPanel(title="Scene", name="LeftDock") self._left_tabs = TabContainer(name="LeftTabs") self._left_tabs.tab_height = 26.0 self._left_tabs.font_size = 12.0 self._scene_tree_content = self._make_panel_or_fallback( "scene_tree", "SceneTreePanel", "Scene Tree", (0.14, 0.14, 0.15, 1.0) ) self._scene_tree_content.name = "Scene Tree" self._left_tabs.add_child(self._scene_tree_content) self._file_browser_content = self._make_panel_or_fallback( "file_browser", "FileBrowserPanel", "File Browser", (0.14, 0.14, 0.15, 1.0) ) self._file_browser_content.name = "File Browser" self._left_tabs.add_child(self._file_browser_content) left_dock.set_content(self._left_tabs) return left_dock def _build_center_panel(self) -> DockPanel: """Build center area: workspace tabs + viewport overlays (top) + output/profiler (bottom).""" center = DockPanel(title="Viewport", name="CenterDock") center.bg_colour = self.theme.viewport_bg split = SplitContainer(vertical=False, split_ratio=0.75, name="CenterVSplit") self._center_wrapper = Panel(name="CenterWrapper") self._center_wrapper.bg_colour = self.theme.viewport_bg self._center_wrapper.border_width = 0 self._center_tabs = TabContainer(name="CenterTabs") self._center_tabs.tab_height = 28.0 self._center_tabs.font_size = 13.0 self._center_tabs.show_close_buttons = True self._center_tabs.show_new_tab_button = True self._center_tabs.tab_bg_colour = (0, 0, 0, 0) self._center_tabs.tab_active_colour = self.theme.viewport_bg self._center_tabs.tab_hover_colour = (1.0, 1.0, 1.0, 0.06) self._center_tabs.tab_text_colour = self.theme.colours.get("text_dim", (0.5, 0.5, 0.5, 1.0)) self._center_tabs.tab_active_text_colour = (1.0, 1.0, 1.0, 1.0) self._center_tabs.border_colour = (1.0, 1.0, 1.0, 0.08) self._center_wrapper.add_child(self._center_tabs) self.state.workspace.bind(self._center_tabs) self._center_tabs.new_tab_requested.connect(self._show_new_scene_dialog) self._viewport_3d = self._make_panel_or_fallback( "scene3d_view", "Scene3DView", "3D", self.theme.viewport_bg ) self._center_wrapper.add_child(self._viewport_3d) self._viewport_2d = self._make_panel_or_fallback( "scene2d_view", "Scene2DView", "2D", self.theme.viewport_bg ) self._viewport_2d.visible = False self._center_wrapper.add_child(self._viewport_2d) from .panels.code_tab import CodeEditorTab self._code_editor_tab = CodeEditorTab(editor_state=self.state, name="CodeTab") self._code_editor_tab.visible = False self._center_wrapper.add_child(self._code_editor_tab) split.add_child(self._center_wrapper) self._bottom_tabs = TabContainer(name="BottomTabs") self._bottom_tabs.tab_height = 24.0 self._bottom_tabs.font_size = 12.0 self._bottom_tabs.tab_bg_colour = (0, 0, 0, 0) self._bottom_tabs.tab_active_colour = (0.14, 0.14, 0.15, 1.0) self._bottom_tabs.tab_hover_colour = (1.0, 1.0, 1.0, 0.06) self._bottom_tabs.tab_text_colour = self.theme.colours.get("text_dim", (0.5, 0.5, 0.5, 1.0)) self._bottom_tabs.tab_active_text_colour = (1.0, 1.0, 1.0, 1.0) self._bottom_tabs.border_colour = (1.0, 1.0, 1.0, 0.08) self._output_panel = self._make_panel_or_fallback( "console", "ConsolePanel", "Output", (0.08, 0.08, 0.09, 1.0) ) self._output_panel.name = "Output" self._console_panel = self._output_panel self._bottom_tabs.add_child(self._output_panel) self._profiler_panel = self._make_panel_or_fallback( "profiler_panel", "ProfilerPanel", "Profiler", (0.12, 0.12, 0.13, 1.0) ) self._profiler_panel.name = "Profiler" self._bottom_tabs.add_child(self._profiler_panel) if hasattr(self, "_play_mode") and hasattr(self._profiler_panel, "profiler"): self._play_mode.attach_profiler(self._profiler_panel.profiler) split.add_child(self._bottom_tabs) self._center_split = split center.set_content(split) return center # ------------------------------------------------------------------ # Status bar # ------------------------------------------------------------------ def _build_status_bar(self): from .panels.status_bar import StatusBar self._status_bar = StatusBar(editor_state=self.state, name="StatusBar") self._vbox.add_child(self._status_bar) # ------------------------------------------------------------------ # Panel factories # ------------------------------------------------------------------ def _make_panel_or_fallback( self, module_name, class_name, fallback_label, bg=(0.14, 0.14, 0.15, 1.0), no_editor_state=False ): """Try to import and instantiate a panel class; fall back to a labelled Panel.""" try: import importlib mod = importlib.import_module(f"simvx.editor.panels.{module_name}") cls = getattr(mod, class_name) if no_editor_state: return cls(name=class_name) return cls(editor_state=self.state, name=class_name) except Exception: panel = Panel(name=fallback_label) panel.bg_colour = bg header = panel.add_child(Label(fallback_label, name=f"{fallback_label}Header")) header.text_colour = (0.56, 0.56, 0.58, 1.0) header.font_size = 12.0 header.position = Vec2(8, 4) return panel # ------------------------------------------------------------------ # Preferences / About # ------------------------------------------------------------------ def _show_preferences(self): if self._preferences_dialog: self._preferences_dialog.show_dialog() def _show_about(self): if self._about_dialog: self._about_dialog.show_dialog() def _show_export_dialog(self): if self._export_dialog: self._export_dialog.show_dialog(Vec2(self._win_w, self._win_h)) def _show_project_settings(self): if self._project_settings_dialog: self._project_settings_dialog.show_dialog(Vec2(self._win_w, self._win_h)) def _show_input_map(self): if self._input_map_dialog: self._input_map_dialog.show_dialog(Vec2(self._win_w, self._win_h)) def _mark_tour_completed(self): """Record that the first-run tour has been completed.""" try: self.prefs.config.editor.tour_completed = True self.prefs.save() except Exception: log.debug("Could not save tour completion", exc_info=True) def _on_theme_changed(self): self.theme = self.prefs.get_theme() if self._root_panel: self._root_panel.bg_colour = self.theme.colours["bg_dark"] if hasattr(self, "_inspector_content") and self._inspector_content: if hasattr(self._inspector_content, "_rebuild"): self._inspector_content._rebuild() # ------------------------------------------------------------------ # Viewport mode & visibility # ------------------------------------------------------------------ def _on_mode(self, mode: str): """Handle mode button press — set viewport mode and emit signal.""" self.state.viewport_mode = mode self.state.viewport_mode_changed.emit() def _update_viewport_visibility(self): """Show/hide viewport overlays based on active tab type and mode.""" ws = self.state.workspace is_script = ws.is_script_tab(ws.active_index) if is_script: if self._viewport_3d: self._viewport_3d.visible = False if self._viewport_2d: self._viewport_2d.visible = False if self._code_editor_tab: self._code_editor_tab.visible = False else: mode = self.state.viewport_mode if self._viewport_3d: self._viewport_3d.visible = (mode == "3d") if self._viewport_2d: self._viewport_2d.visible = (mode == "2d") if self._code_editor_tab: self._code_editor_tab.visible = (mode == "code") if mode == "code": tab = ws.active_scene if tab and tab.source_file: self._code_editor_tab.open_file(tab.source_file) def _sync_mode_buttons(self): """Update mode button active/disabled state to reflect current tab and mode.""" ws = self.state.workspace is_script = ws.is_script_tab(ws.active_index) mode = self.state.viewport_mode if self._btn_3d: self._btn_3d.active = not is_script and mode == "3d" self._btn_3d.disabled = is_script if self._btn_2d: self._btn_2d.active = not is_script and mode == "2d" self._btn_2d.disabled = is_script if self._btn_code: self._btn_code.active = is_script or mode == "code" def _layout_center_overlays(self): """Position viewport overlays to fill the tab content area (below tab bar).""" if not self._center_wrapper or not self._center_tabs: return ww = float(self._center_wrapper.size.x) if hasattr(self._center_wrapper.size, "x") else float(self._center_wrapper.size[0]) wh = float(self._center_wrapper.size.y) if hasattr(self._center_wrapper.size, "y") else float(self._center_wrapper.size[1]) self._center_tabs.position = Vec2(0, 0) self._center_tabs.size = Vec2(ww, wh) tab_h = self._center_tabs.tab_height pos = Vec2(0, tab_h) sz = Vec2(ww, max(0.0, wh - tab_h)) for panel in (self._viewport_3d, self._viewport_2d, self._code_editor_tab): if panel: panel.position = pos panel.size = sz # ------------------------------------------------------------------ # New scene dialog # ------------------------------------------------------------------ def _show_new_scene_dialog(self): if not self._new_scene_dialog: self._new_scene_dialog = NewSceneDialog() self._new_scene_dialog.type_chosen.connect(self._on_new_scene_type_chosen) if self._root_panel: self._root_panel.add_child(self._new_scene_dialog) self._new_scene_dialog.show_dialog(Vec2(self._win_w, self._win_h)) def _on_new_scene_type_chosen(self, root_type: type, populate: bool = False): self.state.new_scene(root_type=root_type, populate=populate) # ------------------------------------------------------------------ # Project management # ------------------------------------------------------------------ def _on_new_project(self): """Handle New Project request — check unsaved work, then transition to WelcomeScreen.""" self._check_unsaved_then(self._transition_to_welcome) def _on_open_project(self): """Handle Open Project request — check unsaved work, then transition. If state has a pending switch project path (from Recent Projects submenu), switch directly to that project instead of going to WelcomeScreen. """ pending = getattr(self.state, "_pending_switch_project", None) if pending: self.state._pending_switch_project = None self._check_unsaved_then(lambda: self._switch_to_project(pending)) else: self._check_unsaved_then(self._transition_to_welcome) def _check_unsaved_then(self, callback): """If any tabs have unsaved changes, prompt via close-all; otherwise invoke callback directly.""" has_dirty = any(self.state.workspace.is_tab_dirty(i) for i in range(self.state.workspace.tab_count)) if has_dirty: self.state.workspace.close_all_tabs(on_complete=callback) else: callback() def _transition_to_welcome(self): """Transition from editor back to WelcomeScreen.""" if not self._tree: return if self.state.is_playing: self.state.stop_scene() platform_win = getattr(self._tree, "_platform_window", None) if platform_win: try: import glfw glfw.set_window_size(platform_win, 1024, 680) glfw.set_window_title(platform_win, "SimVX") except Exception: # justified: GLFW window-resize call may fail before the window backend is fully initialised pass from .welcome import WelcomeScreen self._tree.change_scene(WelcomeScreen()) def _switch_to_project(self, project_path: str): """Switch directly to a different project by creating a new Root.""" if not self._tree: return if self.state.is_playing: self.state.stop_scene() self._tree.change_scene(Root(project_path=project_path)) # ------------------------------------------------------------------ # Autosave / crash recovery # ------------------------------------------------------------------ def _maybe_show_recovery_dialog(self) -> None: """Inspect the autosave file and prompt the user when a recovery is due.""" if self._autosave is None: return scene_path = self.state.current_scene_path if scene_path is None: return envelope = self._autosave.check_recovery(project_path=scene_path) if envelope is None: return if self._recovery_dialog is None: self._recovery_dialog = AutosaveRecoveryDialog(name="AutosaveRecoveryDialog") if self._root_panel: self._root_panel.add_child(self._recovery_dialog) self._recovery_dialog.restore_requested.connect( lambda: self._on_restore_autosave(envelope) ) self._recovery_dialog.discard_requested.connect(self._on_discard_autosave) self._recovery_dialog.show_dialog( format_recovery_message(envelope), parent_size=Vec2(self._win_w, self._win_h), ) def _on_restore_autosave(self, envelope: dict) -> None: if self._autosave is None: return try: root = self._autosave.restore(envelope) except Exception: log.exception("Failed to restore autosave") return scene = self.state.edited_scene scene.set_root(root) self.state.modified = True self.state.scene_changed.emit() def _on_discard_autosave(self) -> None: if self._autosave is not None: self._autosave.discard() # ------------------------------------------------------------------ # Layout persistence # ------------------------------------------------------------------
[docs] def save_layout(self) -> dict: layout = {} if self._dock: layout["dock"] = self._dock.save_layout() return layout
[docs] def restore_layout(self, data: dict): if self._dock and "dock" in data: self._dock.restore_layout(data["dock"])