Source code for simvx.editor.app

"""SimVX Game Editor — self-hosted on simvx.core.ui + Vulkan.

Professional editor shell using DockContainer layout, EditorTheme,
and engine-native widgets. No PySide6/Qt dependencies.

Usage:
    from simvx.editor.app import EditorShell, launch
    launch()   # or: simvx-editor from CLI
"""


from __future__ import annotations

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
_TOOLBAR_H = 36.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

    # -- Public API --

    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)

    # -- Popup protocol --

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

    # -- Drawing --

    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

        # Semi-transparent backdrop
        renderer.draw_filled_rect(0, 0, sw, sh, _ABOUT_OVERLAY)

        # Centred dialog box
        dx = (sw - _ABOUT_W) / 2
        dy = (sh - _ABOUT_H) / 2
        renderer.draw_filled_rect(dx, dy, _ABOUT_W, _ABOUT_H, _ABOUT_BG)
        renderer.draw_rect_coloured(dx, dy, _ABOUT_W, _ABOUT_H, _ABOUT_BORDER)

        # Content
        scale = 1.0
        renderer.draw_text_coloured("SimVX Engine", dx + 20, dy + 18, 1.3, _ABOUT_TEXT)
        renderer.draw_text_coloured(
            "A Godot-inspired game engine in pure Python.", dx + 20, dy + 54, scale * 0.85, _ABOUT_DIM
        )
        renderer.draw_text_coloured(
            "Node-based scene hierarchy with Vulkan rendering.", dx + 20, dy + 76, scale * 0.85, _ABOUT_DIM
        )
        renderer.draw_text_coloured(
            "GPU-driven forward renderer, multi-draw indirect.", dx + 20, dy + 98, scale * 0.85, _ABOUT_DIM
        )
        renderer.draw_text_coloured("Click or press Escape to close.", dx + 20, dy + 130, scale * 0.75, _ABOUT_DIM)


[docs] class EditorShell(Node): """Main editor UI — Godot-style layout with dockable panels. Layout (top to bottom): MenuBar (28 px) Toolbar (36 px) DockContainer (fills remainder) Left dock: Scene Tree panel Center: WorkspaceTabs (top) + ViewportOverlay + Bottom tabs Right dock: Inspector panel StatusBar (20 px) """ def __init__(self, project_path: str | None = None, **kwargs): super().__init__(name="EditorShell", **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._menu_bar: MenuBar | None = None self._toolbar: Panel | 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._ide_bridge = None self._basic_code_panel = None 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 # ------------------------------------------------------------------ # 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_menu_bar() self._build_toolbar() self._build_dock_area() self._build_status_bar() # Shared file dialog (hidden until shown by File > Open / Save As) 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 a reference to the viewport container self.state._viewport_container = self._viewport_container self.state._viewport_code = getattr(self, "_viewport_code", None) # Connect viewport mode signal so menu items toggle panel visibility self.state.viewport_mode_changed.connect(lambda: self._set_viewport_mode(self.state.viewport_mode)) # Sync code viewport when selection changes or scripts change self.state.selection_changed.connect(self._on_selection_for_code) self.state.script_changed.connect(self._on_selection_for_code) # Wire workspace tab signals for viewport switching self.state.workspace.scene_tab_activated.connect(self._on_scene_tab_activated) self.state.workspace.script_tab_activated.connect(self._on_script_tab_activated) # Wire new scene dialog self.state.new_scene_requested.connect(self._show_new_scene_dialog) # Apply initial sizes and register shortcuts self._resize_layout() register_shortcuts(self.state) # Command palette (Ctrl+Shift+P) 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 (Edit > Preferences) self._preferences_dialog = PreferencesDialog( prefs=self.prefs, on_theme_changed=self._on_theme_changed, name="PreferencesDialog" ) self._root_panel.add_child(self._preferences_dialog) # Wire preferences signal 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) # Try to load IDE bridge self._try_load_ide_bridge() # Set script file browser root (project path or CWD) if hasattr(self, "_script_file_browser"): from pathlib import Path root = self.state.project_path or Path.cwd() self._script_file_browser.set_root(root) self.state.scene_changed.connect(self._sync_script_browser_root) # 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) 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)
[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() # Keep viewport panels and workspace tab bar sized to fill their container. # This runs every frame because vc.size depends on ancestor layout # (SplitContainer) which processes after EditorShell. self._resize_viewports() # Drive play mode scene processing if hasattr(self, "_play_mode"): self._play_mode.update(dt) if self._tree and self.state.is_playing: vc = getattr(self, "_viewport_container", None) if vc: gx, gy, gw, gh = vc.get_global_rect() self._tree.overlay_offset = (gx, gy) self._tree.play_viewport_rect = (gx, gy, gw, gh) elif self._tree: self._tree.overlay_offset = (0.0, 0.0) self._tree.play_viewport_rect = None # 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) # Poll IDE bridge if getattr(self, "_ide_bridge", None): self._ide_bridge.poll(dt)
# ------------------------------------------------------------------ # Panel access # ------------------------------------------------------------------ @property def scene_tree_panel(self): return getattr(self, "_scene_tree_content", None) @property def inspector_panel(self): return getattr(self, "_inspector_content", None) # ------------------------------------------------------------------ # 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 - _TOOLBAR_H - _STATUS_H if self._menu_bar: _place(self._menu_bar, self._menu_bar.position.x, self._menu_bar.position.y, w, _MENUBAR_H) if self._toolbar: _place(self._toolbar, self._toolbar.position.x, self._toolbar.position.y, w, _TOOLBAR_H) pad = 8.0 if hasattr(self, "_tb_left"): _place(self._tb_left, pad, 2, self._tb_left.size.x, self._tb_left.size.y) if hasattr(self, "_tb_center"): children = list(self._tb_center.children) cw = sum(c.size.x for c in children) + self._tb_center.separation * max(0, len(children) - 1) _place(self._tb_center, round((w - cw) / 2), 2, self._tb_center.size.x, self._tb_center.size.y) if hasattr(self, "_tb_right"): children = list(self._tb_right.children) rw = sum(c.size.x for c in children) + self._tb_right.separation * max(0, len(children) - 1) _place(self._tb_right, round(w - rw - pad), 2, self._tb_right.size.x, self._tb_right.size.y) if self._dock: _place(self._dock, self._dock.position.x, self._dock.position.y, w, dock_h) if self._status_bar: _place(self._status_bar, self._status_bar.position.x, self._status_bar.position.y, w, _STATUS_H) self._resize_viewports() # ------------------------------------------------------------------ # Menu bar # ------------------------------------------------------------------ def _build_menu_bar(self): self._menu_bar = build_menu_bar(self.state) self._menu_bar.size = Vec2(self._win_w, _MENUBAR_H) self._vbox.add_child(self._menu_bar) # ------------------------------------------------------------------ # Toolbar def _resize_viewports(self): """Size viewport panels to fill their container. Uses _place to skip no-ops.""" if not hasattr(self, "_viewport_container") or not self._viewport_container: return from simvx.core.ui.containers import Container _place = Container._place vc = self._viewport_container tab_h = 28.0 if hasattr(self, "_workspace_tab_bar") and self._workspace_tab_bar.visible else 0.0 vp_h = max(0, vc.size.y - tab_h) for vp in ( getattr(self, "_viewport_3d", None), getattr(self, "_viewport_2d", None), getattr(self, "_script_split", None), ): if vp: _place(vp, 0, tab_h, vc.size.x, vp_h) if hasattr(self, "_workspace_tab_bar"): _place(self._workspace_tab_bar, 0, 0, vc.size.x, tab_h) # ------------------------------------------------------------------ def _build_toolbar(self): from .play_mode import PlayMode toolbar = Panel(name="EditorToolbar") toolbar.bg_colour = (0.11, 0.11, 0.12, 1.0) toolbar.border_width = 0 toolbar.size = Vec2(self._win_w, _TOOLBAR_H) btn_h = _TOOLBAR_H - 4 def _btn_size(text): return Vec2(max(60, len(text) * 10 + 20), btn_h) # --- Left group: gizmo tools --- self._tb_left = HBoxContainer(name="ToolbarLeft") self._tb_left.separation = 2 for text, mode in (("Move", "translate"), ("Rotate", "rotate"), ("Scale", "scale")): btn = ToolbarButton(text, toggle_mode=True, group="gizmo", on_press=lambda m=mode: self._set_gizmo_mode(m)) btn.size = _btn_size(text) self._tb_left.add_child(btn) toolbar.add_child(self._tb_left) # --- Center group: viewport mode (3D + 2D + Script) --- self._tb_center = HBoxContainer(name="ToolbarCenter") self._tb_center.separation = 2 self._3d_btn = ToolbarButton( "3D", toggle_mode=True, group="viewport", on_press=lambda: self._set_viewport_mode("3d") ) self._3d_btn.size = _btn_size("3D") self._2d_btn = ToolbarButton( "2D", toggle_mode=True, group="viewport", on_press=lambda: self._set_viewport_mode("2d") ) self._2d_btn.size = _btn_size("2D") self._script_btn = ToolbarButton( "Script", toggle_mode=True, group="viewport", on_press=lambda: self._set_viewport_mode("code") ) self._script_btn.size = _btn_size("Script") self._tb_center.add_child(self._3d_btn) self._tb_center.add_child(self._2d_btn) self._tb_center.add_child(self._script_btn) toolbar.add_child(self._tb_center) # Maximize viewport toggle (between center buttons and play controls) self._maximize_btn = ToolbarButton("\u2922", on_press=self._on_toggle_maximize) # ⤢ self._maximize_btn.size = _btn_size("\u2922") self._maximize_btn.tooltip = "Maximize viewport" self._tb_center.add_child(self._maximize_btn) self._viewport_maximized = False # --- Right group: play controls --- self._tb_right = HBoxContainer(name="ToolbarRight") self._tb_right.separation = 2 self._play_btn = ToolbarButton("Play", on_press=self._on_play) self._play_btn.size = _btn_size("Play") self._pause_btn = ToolbarButton("Pause", on_press=self._on_pause) self._pause_btn.size = _btn_size("Pause") self._pause_btn.disabled = True self._stop_btn = ToolbarButton("Stop", on_press=self._on_stop) self._stop_btn.size = _btn_size("Stop") self._stop_btn.disabled = True self._tb_right.add_child(self._play_btn) self._tb_right.add_child(self._pause_btn) self._tb_right.add_child(self._stop_btn) toolbar.add_child(self._tb_right) # PlayMode integration self._play_mode = PlayMode(self.state) self.state.play_state_changed.connect(self._update_play_buttons) self._toolbar = toolbar self._vbox.add_child(toolbar) # ------------------------------------------------------------------ # Dock area (main content) # ------------------------------------------------------------------ def _build_dock_area(self): """Build the DockContainer with scene tree, viewport, inspector, and bottom tabs.""" dock = DockContainer(name="MainDock") dock.size = Vec2(self._win_w, self._win_h - _MENUBAR_H - _TOOLBAR_H - _STATUS_H) # Center: viewport + bottom tabs via a vertical split center_panel = self._build_center_panel() dock.add_panel(center_panel, "center") # Left: scene tree self._scene_tree_content = self._make_panel_or_fallback( "scene_tree", "SceneTreePanel", "Scene", (0.14, 0.14, 0.15, 1.0) ) scene_panel = DockPanel(title="Scene", name="SceneTreePanel") scene_panel.set_content(self._scene_tree_content) dock.add_panel(scene_panel, "left") # Right: inspector 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="InspectorPanel") 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_center_panel(self) -> DockPanel: """Build center area: workspace tabs + viewport overlay + bottom tabs.""" center = DockPanel(title="Viewport", name="CenterDock") center.bg_colour = self.theme.viewport_bg self._center_dock = center split = SplitContainer(vertical=False, split_ratio=0.75, name="CenterVSplit") # Top: viewport panel with workspace tab bar as overlay self._viewport_3d = self._make_panel_or_fallback( "viewport3d", "Viewport3DPanel", "3D Viewport", self.theme.viewport_bg ) self._viewport_2d = self._make_panel_or_fallback( "viewport2d", "Viewport2DPanel", "2D Viewport", self.theme.viewport_bg ) self._viewport_2d.visible = False from simvx.core.ui import CodeEditorPanel from simvx.core.ui.file_browser import FileBrowserPanel self._viewport_code = CodeEditorPanel(name="Code") self._viewport_code.visible = True self._viewport_code.file_opened.connect(self.state.file_opened.emit) self._viewport_code.file_saved.connect(self.state.file_saved.emit) self._viewport_code.file_closed.connect(self.state.file_closed.emit) self._viewport_code.active_file_changed.connect(self.state.active_file_changed.emit) self._basic_code_panel = self._viewport_code # Script mode layout: file tree | code editor (side by side) self._script_file_browser = FileBrowserPanel( title="Scripts", show_icons=True, drag_enabled=False, show_scene_actions=True, name="ScriptFileBrowser", ) self._script_file_browser.file_opened.connect(self._on_file_opened_from_browser) self._script_file_browser.file_activated.connect(self._on_file_opened_from_browser) self._script_split = SplitContainer(vertical=True, split_ratio=0.2, name="ScriptSplit") self._script_split.visible = False self._script_split.add_child(self._script_file_browser) self._script_split.add_child(self._viewport_code) self._viewport_container = Panel(name="ViewportPanel") self._viewport_container.bg_colour = self.theme.viewport_bg self._viewport_container.add_child(self._viewport_3d) self._viewport_container.add_child(self._viewport_2d) self._viewport_container.add_child(self._script_split) # Workspace tab bar sits inside the viewport container at top self._workspace_tab_bar = TabContainer(name="WorkspaceTabs") self._workspace_tab_bar.tab_height = 28.0 self._workspace_tab_bar.font_size = 13.0 # Transparent bar background — parent viewport panel shows through. # Only the active tab draws its own background to visually connect to the viewport. self._workspace_tab_bar.tab_bg_colour = (0, 0, 0, 0) self._workspace_tab_bar.tab_active_colour = self.theme.viewport_bg self._workspace_tab_bar.tab_hover_colour = (1.0, 1.0, 1.0, 0.06) self._workspace_tab_bar.tab_text_colour = self.theme.colours.get("text_dim", (0.5, 0.5, 0.5, 1.0)) self._workspace_tab_bar.tab_active_text_colour = (1.0, 1.0, 1.0, 1.0) self._workspace_tab_bar.border_colour = (1.0, 1.0, 1.0, 0.08) self.state.workspace.bind(self._workspace_tab_bar) self._viewport_container.add_child(self._workspace_tab_bar) split.add_child(self._viewport_container) # Bottom: tabbed panels self._bottom_tabs = TabContainer(name="BottomTabs") console = self._make_panel_or_fallback("console", "ConsolePanel", "Console", (0.08, 0.08, 0.09, 1.0)) console.name = "Console" self._console_panel = console self._console_tab_index = 0 self._bottom_tabs.add_child(console) if hasattr(console, "severity_changed"): console.severity_changed.connect(self._on_console_severity_changed) self._bottom_tabs.tab_changed.connect(self._on_bottom_tab_changed) fs = self._make_panel_or_fallback("filesystem", "FileSystemPanel", "Files", (0.08, 0.08, 0.09, 1.0)) fs.name = "Files" if hasattr(fs, "file_opened"): fs.file_opened.connect(self._on_file_opened_from_browser) self._bottom_tabs.add_child(fs) anim = self._make_panel_or_fallback("animation", "AnimationPanel", "Animation", (0.08, 0.08, 0.09, 1.0)) anim.name = "Animation" self._bottom_tabs.add_child(anim) split.add_child(self._bottom_tabs) 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) def _try_load_ide_bridge(self): try: from .ide_bridge import IDEBridgePlugin self._ide_bridge = IDEBridgePlugin( editor_state=self.state, bottom_tabs=self._bottom_tabs, code_panel=getattr(self, "_basic_code_panel", None), ) self._ide_bridge.activate() log.info("IDE bridge loaded successfully") except ImportError: self._ide_bridge = None except Exception: log.exception("Failed to load IDE bridge") self._ide_bridge = None # ------------------------------------------------------------------ # Panel factories # ------------------------------------------------------------------ def _make_dock_panel(self, module_name, class_name, fallback_label, bg=(0.14, 0.14, 0.15, 1.0)) -> DockPanel: panel = DockPanel(title=fallback_label, name=class_name) content = self._make_panel_or_fallback(module_name, class_name, fallback_label, bg) panel.set_content(content) return panel 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: 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 # ------------------------------------------------------------------ def _show_preferences(self): """Show the Preferences dialog overlay.""" if self._preferences_dialog: self._preferences_dialog.show_dialog() def _show_about(self): """Show the About SimVX dialog overlay.""" if self._about_dialog: self._about_dialog.show_dialog() def _on_theme_changed(self): """Callback when theme/font is changed via preferences — refresh editor.""" self.theme = self.prefs.get_theme() if self._root_panel: self._root_panel.bg_colour = self.theme.colours["bg_dark"] # Rebuild inspector to pick up new font size / theme colours if hasattr(self, "_inspector_content") and self._inspector_content: if hasattr(self._inspector_content, "_rebuild"): self._inspector_content._rebuild() # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def _set_gizmo_mode(self, mode: str): from simvx.core import GizmoMode modes = {"translate": GizmoMode.TRANSLATE, "rotate": GizmoMode.ROTATE, "scale": GizmoMode.SCALE} self.state.gizmo.mode = modes.get(mode, GizmoMode.TRANSLATE) def _set_viewport_mode(self, mode: str): """Switch viewport mode between 3D, 2D, and Script.""" self.state.viewport_mode = mode if hasattr(self, "_viewport_3d") and hasattr(self, "_viewport_2d"): self._viewport_3d.visible = mode == "3d" self._viewport_2d.visible = mode == "2d" if hasattr(self, "_script_split"): self._script_split.visible = mode == "code" def _on_scene_tab_activated(self): """Scene tab selected — show appropriate viewport, enable viewport buttons.""" if hasattr(self, "_3d_btn"): self._3d_btn.disabled = False self._2d_btn.disabled = False if hasattr(self, "_script_btn"): self._script_btn.disabled = False self._set_viewport_mode(self.state.viewport_mode) def _on_script_tab_activated(self): """Script tab selected — hide all viewports, disable viewport buttons.""" if hasattr(self, "_viewport_3d"): self._viewport_3d.visible = False if hasattr(self, "_viewport_2d"): self._viewport_2d.visible = False if hasattr(self, "_script_split"): self._script_split.visible = False if hasattr(self, "_3d_btn"): self._3d_btn.disabled = True self._2d_btn.disabled = True if hasattr(self, "_script_btn"): self._script_btn.disabled = True def _on_selection_for_code(self): """Open script tab when a node with a script is selected.""" if self.state.is_playing: return node = self.state.selection.primary if node is None and self.state.edited_scene: node = self.state.edited_scene.root if node is None: return if node.script or getattr(node, "_script_inline", None) or getattr(node, "_script_embedded", None): self.state.workspace.open_script(node, project_path_fn=lambda: self.state.project_path) def _sync_script_browser_root(self): """Update script file browser root when the project path changes.""" if hasattr(self, "_script_file_browser") and self.state.project_path: self._script_file_browser.set_root(self.state.project_path) def _on_file_opened_from_browser(self, path: str): """Open a file in the code editor and switch to Script mode.""" if self._basic_code_panel: self._basic_code_panel.open_file(path) self._set_viewport_mode("code") def _show_new_scene_dialog(self): """Show the NewSceneDialog overlay.""" 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): """Handle NewSceneDialog selection.""" self.state.new_scene(root_type=root_type, populate=populate) # ------------------------------------------------------------------ # Console severity → tab colour # ------------------------------------------------------------------ _SEVERITY_COLOURS = { 1: (0.3, 0.5, 0.8, 1.0), 2: (0.9, 0.7, 0.2, 1.0), 3: (0.9, 0.3, 0.3, 1.0), } def _on_console_severity_changed(self): sev = getattr(self._console_panel, "worst_severity", 0) colour = self._SEVERITY_COLOURS.get(sev) self._bottom_tabs.set_tab_colour(self._console_tab_index, colour) def _on_bottom_tab_changed(self, index: int): if index == self._console_tab_index and hasattr(self._console_panel, "reset_severity"): self._console_panel.reset_severity() # ------------------------------------------------------------------ # Play controls # ------------------------------------------------------------------ _PLAY_ACTIVE_BG = (0.15, 0.4, 0.15, 1.0) _PAUSE_ACTIVE_BG = (0.45, 0.35, 0.1, 1.0) def _on_toggle_maximize(self): """Toggle viewport between maximized (fills dock area) and normal.""" self._viewport_maximized = not self._viewport_maximized maximized = self._viewport_maximized self._maximize_btn.text = "\u2923" if maximized else "\u2922" # ⤣ / ⤢ self._maximize_btn.tooltip = "Restore viewport" if maximized else "Maximize viewport" # Hide/show side panels and bottom tabs if hasattr(self, "_dock") and self._dock: for panel_name in ("left", "right"): panel = self._dock._panels.get(panel_name) if panel: panel.visible = not maximized # Hide bottom tabs split if hasattr(self, "_bottom_tabs") and self._bottom_tabs: self._bottom_tabs.visible = not maximized if hasattr(self._bottom_tabs, "parent") and self._bottom_tabs.parent: self._bottom_tabs.parent.visible = not maximized 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 _update_play_buttons(self): """Update play/pause/stop button disabled + active 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._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._stop_btn.disabled = not playing self._stop_btn.active = False self._stop_btn.active_bg_colour = None # Viewport border colour if hasattr(self, "_viewport_container") and self._viewport_container: border_colour = self._play_mode.get_border_colour() if border_colour: self._viewport_container.border_colour = border_colour self._viewport_container.border_width = 2.0 else: self._viewport_container.border_width = 0.0 # Hide editor viewport panels and tab bar during play mode if hasattr(self, "_workspace_tab_bar"): self._workspace_tab_bar.visible = not playing if hasattr(self, "_viewport_3d"): if playing: self._viewport_3d.visible = False self._viewport_2d.visible = False if hasattr(self, "_script_split"): self._script_split.visible = False _t = (0, 0, 0, 0) for attr in ("_viewport_container", "_center_dock", "_dock", "_root_panel"): w = getattr(self, attr, None) if w and hasattr(w, "bg_colour"): w.bg_colour = _t else: self._set_viewport_mode(self.state.viewport_mode) _vp_bg = self.theme.viewport_bg if hasattr(self, "_viewport_container") and self._viewport_container: self._viewport_container.bg_colour = _vp_bg if hasattr(self, "_center_dock") and self._center_dock: self._center_dock.bg_colour = _vp_bg if hasattr(self, "_dock") and self._dock: self._dock.bg_colour = (0.11, 0.11, 0.12, 1.0) if hasattr(self, "_root_panel") and self._root_panel: self._root_panel.bg_colour = self.theme.colours["bg_dark"] # ------------------------------------------------------------------ # 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"])
# ------------------------------------------------------------------ # Backward compatibility: _script_tabs property # ------------------------------------------------------------------ @property def _script_tabs(self): """Backward-compatible accessor — returns workspace for script tab operations.""" return self.state.workspace
[docs] def launch(project_path: str | None = None, backend: str | None = None): """Editor entry point — launched via ``simvx-editor`` command. Args: project_path: If given, skip the welcome screen and open this project directly. backend: Windowing backend ("glfw", "sdl3", or None for auto-detect). """ from simvx.graphics import App if project_path: app = App(title="SimVX Editor", width=1600, height=900, physics_fps=60, vsync=True, backend=backend) app.run(EditorShell(project_path=project_path)) else: from .welcome import WelcomeScreen app = App(title="SimVX", width=1024, height=680, vsync=True, backend=backend) app.run(WelcomeScreen())