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