Source code for simvx.editor.shell

"""Editor Shell — clean layout matching the spec's panel design.

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.shell import EditorShell
    launch()   # or: simvx-editor from CLI
"""

import logging

from simvx.core import (
    Control,
    DockContainer,
    DockPanel,
    FileDialog,
    HBoxContainer,
    Label,
    MenuBar,
    Node,
    Node3D,
    Panel,
    SplitContainer,
    TabContainer,
    ToolbarButton,
    VBoxContainer,
    Vec2,
)

from .command_palette import EditorCommandPalette, register_editor_commands
from .menus import build_menu_bar, register_shortcuts
from .preferences import EditorPreferences
from .preferences_dialog import PreferencesDialog
from .state import EditorState
from .workspace_tabs import NewSceneDialog

log = logging.getLogger(__name__)

# Layout constants
_MENUBAR_H = 28.0
_STATUS_H = 20.0

# About dialog constants
_ABOUT_W = 360.0
_ABOUT_H = 160.0
_ABOUT_BG = (0.16, 0.16, 0.18, 1.0)
_ABOUT_BORDER = (0.35, 0.35, 0.38, 1.0)
_ABOUT_OVERLAY = (0.0, 0.0, 0.0, 0.4)
_ABOUT_TEXT = (0.92, 0.92, 0.94, 1.0)
_ABOUT_DIM = (0.55, 0.55, 0.58, 1.0)

class _AboutOverlay(Control):
    """Minimal modal about dialog for the SimVX editor."""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.visible = False
        self.z_index = 2000

    def show_dialog(self):
        self.visible = True
        self.set_focus()
        if self._tree:
            self._tree.push_popup(self)

    def hide_dialog(self):
        if not self.visible:
            return
        self.visible = False
        self.release_focus()
        if self._tree:
            self._tree.pop_popup(self)

    def is_popup_point_inside(self, point) -> bool:
        return self.visible

    def popup_input(self, event):
        if not self.visible:
            return
        if hasattr(event, "key") and event.key and event.pressed:
            if event.key in ("escape", "enter"):
                self.hide_dialog()
                return
        if getattr(event, "button", 0) == 1 and event.pressed:
            self.hide_dialog()

    def dismiss_popup(self):
        self.hide_dialog()

    def draw(self, renderer):
        pass

    def draw_popup(self, renderer):
        if not self.visible:
            return
        ss = self._get_parent_size()
        sw, sh = ss.x, ss.y
        renderer.draw_rect((0, 0), (sw, sh), colour=_ABOUT_OVERLAY, filled=True)
        dx = (sw - _ABOUT_W) / 2
        dy = (sh - _ABOUT_H) / 2
        renderer.draw_rect((dx, dy), (_ABOUT_W, _ABOUT_H), colour=_ABOUT_BG, filled=True)
        renderer.draw_rect((dx, dy), (_ABOUT_W, _ABOUT_H), colour=_ABOUT_BORDER)
        scale = 1.0
        renderer.draw_text("SimVX Engine", (dx + 20, dy + 18), colour=_ABOUT_TEXT, scale=1.3)
        renderer.draw_text(
            "A Godot-inspired game engine in pure Python.",
            (dx + 20, dy + 54), colour=_ABOUT_DIM, scale=scale * 0.85,
        )
        renderer.draw_text(
            "Node-based scene hierarchy with Vulkan rendering.",
            (dx + 20, dy + 76), colour=_ABOUT_DIM, scale=scale * 0.85,
        )
        renderer.draw_text(
            "GPU-driven forward renderer, multi-draw indirect.",
            (dx + 20, dy + 98), colour=_ABOUT_DIM, scale=scale * 0.85,
        )
        renderer.draw_text(
            "Click or press Escape to close.",
            (dx + 20, dy + 130), colour=_ABOUT_DIM, scale=scale * 0.75,
        )

[docs] class EditorShell(Node): """Main editor shell — the top-level layout container (new spec layout). 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", "EditorShell") super().__init__(**kwargs) self._project_path = project_path self.state = EditorState() self.prefs = EditorPreferences() 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: EditorCommandPalette | None = None self._preferences_dialog: PreferencesDialog | None = None self._about_dialog: _AboutOverlay | 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 mode self._play_btn: ToolbarButton | None = None self._pause_btn: ToolbarButton | None = None self._step_btn: ToolbarButton | None = None self._stop_btn: ToolbarButton | None = None self._hot_reload_btn: ToolbarButton | None = None # Hot-reload preference loaded from disk -> live state -> toolbar. 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 # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------
[docs] def ready(self): """Build the full editor UI tree.""" self.state.new_scene(root_type=Node3D, populate=True) # Window dimensions from scene tree 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]) # Root panel fills the window 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) # Main vertical layout self._vbox = self._root_panel.add_child(VBoxContainer(name="MainVBox")) self._vbox.separation = 0 self._vbox.size = Vec2(self._win_w, self._win_h) # Build sections top-to-bottom self._build_top_bar() self._build_main_area() self._build_status_bar() # Shared file dialog 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 # Give EditorState references self.state._viewport_container = self._viewport_3d # Connect viewport/tab signals to update visibility and mode buttons 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) # Wire new scene dialog self.state.new_scene_requested.connect(self._show_new_scene_dialog) # Wire project management signals self.state.new_project_requested.connect(self._on_new_project) self.state.open_project_requested.connect(self._on_open_project) # Apply initial sizes and register shortcuts self._resize_layout() register_shortcuts(self.state) # Command palette self._command_palette = EditorCommandPalette(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" ) # Preferences dialog 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) # About dialog self._about_dialog = _AboutOverlay(name="AboutDialog") self._root_panel.add_child(self._about_dialog) self.state.about_requested.connect(self._show_about) # Export dialog 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) # Project Settings 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) # Input Map dialog 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) # 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) # Load project if specified if self._project_path: from pathlib import Path from .project import ProjectManager pm = ProjectManager() if pm.load_project(self._project_path): self.state.project_path = Path(self._project_path) # Start watching project files for hot reload 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) # Install offscreen render hook for game/textured viewport self._install_offscreen_render_hook()
def _install_offscreen_render_hook(self): """Install the pre_render callback for offscreen viewport rendering. Handles both play-mode game rendering and edit-mode textured view. Installed once during ready() — not per play/stop cycle. """ if not self._tree or not hasattr(self._tree, "app"): return app = self._tree.app if app is None or not hasattr(app, "_engine"): return self._wire_game_pre_render(app)
[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() # Position viewport overlays to track the center wrapper size self._layout_center_overlays() # Drive play mode scene processing if hasattr(self, "_play_mode"): self._play_mode.update(dt) # Feed the profiler panel with a frame sample. During play mode, # PlayMode drives phase timings and node sampling directly; we only # need to fall back to a raw total-time sample in edit mode. 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) # Update status bar mouse position if self._status_bar and hasattr(self._status_bar, "update_mouse_pos"): from simvx.core import Input mx, my = Input.get_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. """ panel = getattr(self, "_profiler_panel", None) if panel is None or not hasattr(panel, "record_frame"): return if getattr(self.state, "is_playing", False): panel.source_label = "Game" # PlayMode already called record_frame/end_frame when enabled. return panel.source_label = "Editor" panel.record_frame(dt, self.state.edited_scene) # ------------------------------------------------------------------ # 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.""" from simvx.core.ui.containers import Container _place = Container._place w, h = self._win_w, self._win_h dock_h = h - _MENUBAR_H - _STATUS_H if self._top_bar: _place(self._top_bar, self._top_bar.position.x, self._top_bar.position.y, w, _MENUBAR_H) if self._mode_switcher: mode_w = self._mode_switcher.size.x self._mode_switcher.position = Vec2((w - mode_w) / 2, 2) if self._play_box: play_w = self._play_box.size.x self._play_box.position = Vec2(w - play_w - 8, 2) if self._dock: _place(self._dock, self._dock.position.x, self._dock.position.y, w, dock_h) if hasattr(self._dock, '_update_layout'): self._dock._update_layout() if self._status_bar: _place(self._status_bar, self._status_bar.position.x, self._status_bar.position.y, w, _STATUS_H) # ------------------------------------------------------------------ # Top bar: menus (left) + play buttons (right) # ------------------------------------------------------------------ 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) # Menu bar on the left 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) # transparent — top bar bg shows through top_bar.add_child(self._menu_bar) # Mode switcher (3D / 2D / Code) centered in the top 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 # 3 buttons + 2 gaps mode_box.position = Vec2((self._win_w - mode_w) / 2, 2) mode_box.size = Vec2(mode_w, btn_h) self._mode_switcher = mode_box # Play controls on the right play_box = HBoxContainer(name="PlayControls") play_box.separation = 2 def _btn_size(text): return Vec2(max(50, len(text) * 9 + 16), btn_h) self._play_btn = ToolbarButton("Play", on_press=self._on_play, name="PlayButton") self._play_btn.size = _btn_size("Play") self._play_btn.tooltip = "Run the current scene (F5)" self._pause_btn = ToolbarButton("Pause", on_press=self._on_pause, name="PauseButton") self._pause_btn.size = _btn_size("Pause") self._pause_btn.tooltip = "Pause / resume play mode (F7)" self._pause_btn.disabled = True self._step_btn = ToolbarButton("Step", on_press=self._on_step, name="StepButton") self._step_btn.size = _btn_size("Step") self._step_btn.tooltip = "Advance one frame (only while paused)" self._step_btn.disabled = True self._stop_btn = ToolbarButton("Stop", on_press=self._on_stop, name="StopButton") self._stop_btn.size = _btn_size("Stop") self._stop_btn.tooltip = "Stop play mode and restore the scene (F6)" self._stop_btn.disabled = True self._hot_reload_btn = ToolbarButton( "Reload", on_press=self._on_toggle_hot_reload, name="HotReloadButton" ) self._hot_reload_btn.size = _btn_size("Reload") self._hot_reload_btn.tooltip = "Hot reload (live script updates during play)" play_box.add_child(self._play_btn) play_box.add_child(self._pause_btn) play_box.add_child(self._step_btn) play_box.add_child(self._stop_btn) play_box.add_child(self._hot_reload_btn) top_bar.add_child(play_box) # Position play controls at right side of top bar play_w = play_box.get_minimum_size().x play_box.position = Vec2(self._win_w - play_w - 8, 2) play_box.size = Vec2(play_w, btn_h) self._play_box = play_box # PlayMode is constructed in EditorState.__init__; shell retains a # direct reference for the per-frame update call. self._play_mode = self.state.play_mode self.state.play_state_changed.connect(self._update_play_buttons) self.state.play_state_changed.connect(self._on_play_state_changed) # Initial visual state — reflects loaded hot-reload preference. self._update_play_buttons() 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: viewport + output in a vertical split center_panel = self._build_center_panel() dock.add_panel(center_panel, "center") # Left: tabbed (Scene Tree | File Browser) — full height left_panel = self._build_left_panel() dock.add_panel(left_panel, "left") # Right: Inspector — full height self._inspector_content = self._make_panel_or_fallback( "inspector", "InspectorPanel", "Inspector", (0.14, 0.14, 0.15, 1.0) ) inspector_panel = DockPanel(title="Inspector", name="InspectorDock") 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 # Scene Tree tab 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) # File Browser tab 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 # Vertical split: viewport on top (75%), bottom tools (25%) split = SplitContainer(vertical=False, split_ratio=0.75, name="CenterVSplit") # Top: wrapper holding tab bar + overlaid viewport panels self._center_wrapper = Panel(name="CenterWrapper") self._center_wrapper.bg_colour = self.theme.viewport_bg self._center_wrapper.border_width = 0 # Tab bar managed by WorkspaceTabs 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) # Bind workspace to the tab container (syncs pre-existing scene tab) self.state.workspace.bind(self._center_tabs) self._center_tabs.new_tab_requested.connect(self._show_new_scene_dialog) # Viewport panels — overlaid on the tab content area, NOT tab children self._viewport_3d = self._make_panel_or_fallback( "viewport3d", "Viewport3DPanel", "3D", self.theme.viewport_bg ) self._center_wrapper.add_child(self._viewport_3d) self._viewport_2d = self._make_panel_or_fallback( "viewport2d", "Viewport2DPanel", "2D", self.theme.viewport_bg ) self._viewport_2d.visible = False self._center_wrapper.add_child(self._viewport_2d) # Code editor overlay for "code" mode on scene tabs 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) # Bottom: tabbed tool area [Output, Profiler] 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) # Share the profiler with play_mode so per-node timings accumulate. 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: # Script tabs: hide all overlays — CodeTextEdit shown by TabContainer 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") # Open the scene's source file in code mode if mode == "code": tab = ws.get_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() # Resize window to welcome screen dimensions 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 EditorShell.""" if not self._tree: return if self.state.is_playing: self.state.stop_scene() self._tree.change_scene(EditorShell(project_path=project_path)) # ------------------------------------------------------------------ # Play controls # ------------------------------------------------------------------ _PLAY_ACTIVE_BG = (0.15, 0.4, 0.15, 1.0) _PAUSE_ACTIVE_BG = (0.45, 0.35, 0.1, 1.0) _HOT_RELOAD_ACTIVE_BG = (0.18, 0.32, 0.5, 1.0) def _on_play(self): self.state.play_scene() def _on_pause(self): self.state.pause_scene() def _on_stop(self): self.state.stop_scene() def _on_step(self): """Advance the game tree by exactly one frame while paused.""" if not (self.state.is_playing and self.state.is_paused): return self.state.is_paused = False try: self._play_mode.update(1.0 / 60.0) finally: self.state.is_paused = True # Keep button state (active_bg / disabled flags) consistent with the # paused-after-step transition without re-emitting play_state_changed. self._update_play_buttons() def _on_toggle_hot_reload(self): """Flip the hot-reload preference and persist to ``~/.config/simvx/config.json``.""" new_value = not self.state.hot_reload_enabled self._play_mode.set_hot_reload_enabled(new_value) self.prefs.config.editor.hot_reload_enabled = new_value try: self.prefs.save() except OSError: log.exception("Could not persist hot-reload preference") self._update_play_buttons() def _on_play_state_changed(self): """Create/destroy game viewport renderer when play mode starts/stops.""" if self.state.is_playing: self._create_game_viewport() else: self._play_mode.destroy_game_viewport() def _create_game_viewport(self): """Create the GPU game viewport for rendering the game scene offscreen.""" if not self._tree or not hasattr(self._tree, "app"): return app = self._tree.app if app is None or not hasattr(app, "_engine"): return engine = app._engine # Get viewport dimensions from the center panel vp = self._viewport_3d if vp is not None: vx, vy, vw, vh = vp.get_global_rect() w, h = max(int(vw), 64), max(int(vh), 64) else: w, h = 800, 600 self._play_mode.create_game_viewport(engine, w, h) # Set the game tree's screen size and viewport rect to match the offscreen target, # so the renderer uses the correct dimensions instead of the full window size. game_tree = self._play_mode.game_tree if game_tree is not None: game_tree.screen_size = (w, h) sx, sy = engine.content_scale game_tree.play_viewport_rect = (0, 0, w / sx, h / sy) def _wire_game_pre_render(self, app): """Extend the engine's pre_render callback to render the game scene offscreen. The game scene is submitted to the renderer (replacing viewport data temporarily), rendered to the offscreen GameViewportRenderer target, then the original pre_render runs for the editor scene. Also handles the edit-mode textured viewport (same pattern but with the edited scene instead of the game tree). """ engine = app._engine adapter = app.scene_adapter # Save the original pre_render callback original_pre_render = engine.pre_render_callback def _game_pre_render(cmd): # Run the editor's pre_render FIRST so its SSBO writes (including # hdr_output=1 for HDR pass) are committed before we render the # game. Running this after our game render would overwrite any # per-game SSBO flags we set (hdr_output=0 for in-shader tonemap) # because the command buffer executes in submission order but # reads the CURRENT host-coherent SSBO value at draw time. if original_pre_render: original_pre_render(cmd) # --- Game viewport (play mode) --- gvp = self._play_mode.game_viewport if (gvp is not None and gvp.ready and self.state.is_playing and adapter is not None): game_tree = self._play_mode.game_tree if game_tree is not None and game_tree.root is not None: # Collect game tree 2D geometry without disturbing editor Draw2D from simvx.graphics.draw2d import Draw2D game_batches = None with Draw2D._isolated(): game_tree.draw(Draw2D) game_batches = Draw2D._get_batches() adapter.render_to_target( cmd, gvp, game_tree, draw2d_batches=game_batches, ) return # --- Edit-mode offscreen viewports (not playing) --- if not self.state.is_playing and adapter is not None: edited = self.state.edited_scene if edited is not None and edited.root is not None: # 3D textured viewport — use editor orbit camera if self.state.view_mode_3d == "textured" and self._viewport_3d is not None: evp = getattr(self._viewport_3d, "_edit_viewport", None) if evp is not None: adapter.render_to_target(cmd, evp, edited, camera=self.state.editor_camera) # 2D shaded viewport — use editor camera if present vp2d = self._viewport_2d if vp2d is not None and getattr(vp2d, "_view_mode", None) == "shaded": evp2 = getattr(vp2d, "_edit_viewport", None) if evp2 is not None: adapter.render_to_target( cmd, evp2, edited, camera=self.state.editor_camera, screen_size=(float(evp2.width), float(evp2.height)), ) engine.pre_render_callback = _game_pre_render self._original_pre_render = original_pre_render def _update_play_buttons(self): """Update play/pause/step/stop and hot-reload button visual state.""" playing = self.state.is_playing paused = self.state.is_paused self._play_btn.disabled = False self._play_btn.active = playing self._play_btn.active_bg_colour = self._PLAY_ACTIVE_BG if playing else None self._play_btn.queue_redraw() self._pause_btn.disabled = not playing self._pause_btn.active = paused self._pause_btn.active_bg_colour = self._PAUSE_ACTIVE_BG if paused else None self._pause_btn.queue_redraw() # Step is only meaningful while playback is paused. if self._step_btn is not None: self._step_btn.disabled = not (playing and paused) self._step_btn.active = False self._step_btn.active_bg_colour = None self._step_btn.queue_redraw() self._stop_btn.disabled = not playing self._stop_btn.active = False self._stop_btn.active_bg_colour = None self._stop_btn.queue_redraw() if self._hot_reload_btn is not None: enabled = bool(self.state.hot_reload_enabled) self._hot_reload_btn.active = enabled self._hot_reload_btn.active_bg_colour = self._HOT_RELOAD_ACTIVE_BG if enabled else None self._hot_reload_btn.queue_redraw() # ------------------------------------------------------------------ # 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"])