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