Source code for simvx.editor.workspace_tabs

"""Workspace Tabs: Unified tab bar for scene and script tabs.

Data model and manager for the VS Code-style unified tab bar where
scene tabs and script tabs coexist. Multiple scenes can be open
simultaneously, each with its own undo/selection/camera state.
"""

import logging
import math
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol, runtime_checkable

from simvx.core import (
    Button,
    CodeTextEdit,
    Control,
    Label,
    Node,
    OrbitCamera3D,
    Panel,
    SceneTree,
    Selection,
    Signal,
    TabContainer,
    UndoStack,
    Vec2,
)
from simvx.core.io import atomic_write_text

log = logging.getLogger(__name__)

# ============================================================================
# Tab protocol
# ============================================================================
#
# All tab kinds (scene, script, untitled) implement the same lightweight
# duck-typed contract: a display widget, a name, dirty tracking, and a
# save hook. Codifying it as a Protocol gives ``WorkspaceTabs._tabs``
# proper type information and makes it explicit which methods every new
# tab kind needs to provide.

[docs] @runtime_checkable class Tab(Protocol): """Common interface every workspace tab must implement.""" tab_name: str
[docs] @property def tab_widget(self) -> Control: ...
[docs] @property def is_dirty(self) -> bool: ...
[docs] def save(self) -> None: ...
# ============================================================================ # Per-tab state dataclasses # ============================================================================ class _SceneTabPlaceholder(Control): """Invisible placeholder: exists only to give TabContainer a named child for scene tabs.""" def on_draw(self, renderer): pass # Intentionally empty: viewport panels are siblings, not children
[docs] @dataclass class SceneTabState: """Per-scene snapshot holding all scene-specific state.""" scene_tree: SceneTree scene_path: Path | None = None selection: Selection = field(default_factory=Selection) undo_stack: UndoStack = field(default_factory=lambda: UndoStack(max_size=200)) editor_camera: OrbitCamera3D = field(default_factory=lambda: OrbitCamera3D(name="EditorCamera")) viewport_sub_mode: str = "3d" modified: bool = False saved_scene_data: dict | None = None playing_root: Node | None = None placeholder: Control = field(default_factory=lambda: _SceneTabPlaceholder(name="SceneTab")) tab_name: str = "Untitled" # Live Python source tracking source_file: str | None = None source_class: type | None = None source_module: Any = None file_classification: str = "" # Identity hints for apply_runtime_diff: maps runtime child Node objects # to their original source var name at scene-load time. Survives node # renames (the runtime object is the key, so its `.name` can change # freely) and lets save_scene preserve source-var identity instead of # surfacing the rename as a remove + add seam. Built lazily on first # save when the scene was loaded from disk. identity_hints: dict = field(default_factory=dict) # -- Tab protocol --
[docs] @property def tab_widget(self) -> Control: """The widget representing this tab in a TabContainer.""" return self.placeholder
[docs] @property def is_dirty(self) -> bool: """Whether this tab has unsaved changes.""" return self.modified
[docs] def save(self) -> None: """Mark clean; actual file save is handled by State/SceneFileOps.""" self.modified = False
[docs] @classmethod def create(cls, root_type: type = Node, name: str = "Root") -> SceneTabState: """Create a fresh scene tab with sensible defaults.""" from simvx.core import Control as _Control from simvx.core import Node2D tree = SceneTree(screen_size=Vec2(800, 600)) root = root_type(name=name) tree.set_root(root) # Default sub_mode based on root type sub_mode = "2d" if issubclass(root_type, (Node2D, _Control)) else "3d" cam = OrbitCamera3D(name="EditorCamera") cam.pitch = math.radians(-35.0) cam.yaw = math.radians(30.0) cam.distance = 8.0 cam.update_transform() placeholder = _SceneTabPlaceholder(name=f"Scene:{name}") return cls( scene_tree=tree, viewport_sub_mode=sub_mode, editor_camera=cam, placeholder=placeholder, tab_name=name, )
[docs] @dataclass class ScriptTabState: """Per-script tab state.""" key: str kind: str # "file", "embedded" node: Node | None editor: CodeTextEdit saved_text: str tab_name: str _saved_hash: int = 0 # hash of saved_text for O(1) dirty check
[docs] def __post_init__(self): self._saved_hash = hash(self.saved_text)
# -- Tab protocol --
[docs] @property def tab_widget(self) -> Control: """The widget representing this tab in a TabContainer.""" return self.editor
[docs] @property def is_dirty(self) -> bool: """Whether this tab has unsaved changes (O(1) hash check).""" return hash(self.editor.text) != self._saved_hash
[docs] def save(self) -> None: """Persist the current editor text back to its source. File-backed tabs opened via ``open_file()`` (no owning node) write straight to the key path; other kinds route through the script Node. """ text = self.editor.text if self.kind == "file": if self.node is not None and not self.node.script: return p = Path(self.key) try: atomic_write_text(p, text) self.saved_text = text self._saved_hash = hash(text) except Exception as e: log.error("Failed to save script %s: %s", p, e) return elif self.kind == "embedded" and self.node is not None: self.node._script_embedded = text self.saved_text = text self._saved_hash = hash(text)
[docs] @dataclass class UntitledTabState: """Editor-only scratch buffer, VS Code "Untitled-N" pattern. Holds a Python source buffer that has not yet been saved to disk. The buffer lives purely in editor memory (this dataclass + a CodeTextEdit widget); it is **never** serialised onto a Node attribute or any scene file. On save (Ctrl+S) the editor prompts for a destination path; once written the tab is replaced with a regular file-backed ``ScriptTabState``. ``ordinal`` is the auto-assigned 1-based sequence number that produced the default tab name ``Untitled-N.py``. The :class:`WorkspaceTabs` keeps track of the next ordinal so closing and reopening Untitled tabs reuses the lowest available number. """ ordinal: int editor: CodeTextEdit tab_name: str _initial_hash: int = 0 # hash of the empty starting buffer for O(1) dirty check
[docs] def __post_init__(self): self._initial_hash = hash(self.editor.text)
# -- Tab protocol --
[docs] @property def tab_widget(self) -> Control: """The widget representing this tab in a TabContainer.""" return self.editor
[docs] @property def is_dirty(self) -> bool: """Untitled buffers are dirty as soon as the user types anything. An empty Untitled buffer is treated as clean, closing it without any input should not prompt to save. """ return hash(self.editor.text) != self._initial_hash
[docs] def save(self) -> None: """No-op: actual save routes through the editor's Save-As file dialog. Implemented for tab-protocol parity (e.g. ``WorkspaceTabs.save_all_scripts`` iterating all tabs). The promotion to a ``ScriptTabState`` happens in :meth:`WorkspaceTabs.promote_untitled_to_file` after the user picks a destination path. """ return
# ============================================================================ # WorkspaceTabs # ============================================================================
[docs] class WorkspaceTabs: """Unified tab manager for scene and script tabs.""" # Warm amber tint for modified tab text MODIFIED_TEXT_COLOUR = (0.95, 0.78, 0.35, 1.0) MODIFIED_PREFIX = "\u25cf " # "● " def __init__(self): self._tab_container: TabContainer | None = None # All tab kinds satisfy the ``Tab`` protocol, the union is kept # only so static analysers (and isinstance routing) know exactly # which concrete types appear. self._tabs: list[Tab] = [] self._active_index: int = -1 self.locked: bool = False self._unsaved_dialog: UnsavedChangesDialog | None = None self._pending_close_index: int = -1 self._close_all_pending: bool = False self._close_all_callback: Callable | None = None # Signals self.active_tab_changed = Signal() self.scene_tab_activated = Signal() self.script_tab_activated = Signal() self.tab_closed = Signal()
[docs] def bind(self, tab_container: TabContainer): """Bind to a TabContainer widget for display.""" self._tab_container = tab_container self._tab_container.show_close_buttons = True self._tab_container.tab_close_requested.connect(self._on_close_requested) self._tab_container.tab_changed.connect(self._on_tab_changed_ui) # Sync any tabs that were added before binding for tab in self._tabs: tab.tab_widget.name = tab.tab_name self._tab_container.add_child(tab.tab_widget) if self._active_index >= 0: self._tab_container.current_tab = self._active_index self._tab_container._update_layout()
# -- Properties ----------------------------------------------------------
[docs] @property def tab_count(self) -> int: return len(self._tabs)
[docs] @property def active_index(self) -> int: return self._active_index
[docs] def is_scene_tab(self, i: int) -> bool: return 0 <= i < len(self._tabs) and isinstance(self._tabs[i], SceneTabState)
[docs] def is_script_tab(self, i: int) -> bool: return 0 <= i < len(self._tabs) and isinstance(self._tabs[i], ScriptTabState)
# -- Scene tabs ----------------------------------------------------------
[docs] def add_scene_tab(self, state: SceneTabState) -> int: """Add a scene tab and return its index.""" idx = len(self._tabs) self._tabs.append(state) if self._tab_container: state.tab_widget.name = state.tab_name self._tab_container.add_child(state.tab_widget) self.set_active(idx) return idx
[docs] @property def active_scene(self) -> SceneTabState | None: """The active scene tab state, or None if a script tab is active.""" if 0 <= self._active_index < len(self._tabs): tab = self._tabs[self._active_index] if isinstance(tab, SceneTabState): return tab return None
[docs] def find_scene_tab(self, path: Path) -> int | None: """Find a scene tab by file path.""" for i, tab in enumerate(self._tabs): if isinstance(tab, SceneTabState) and tab.scene_path == path: return i return None
# -- Script tabs ---------------------------------------------------------
[docs] def open_script(self, node: Node, project_path_fn=None, line: int | None = None) -> int: """Open or switch to a script tab for the given node. When ``line`` is given (1-indexed), position the cursor at that line after opening/switching. Used for error navigation. """ key, kind, text, tab_name = self._resolve_script(node, project_path_fn) if key is None: return -1 # Already open? Switch to it. for i, tab in enumerate(self._tabs): if isinstance(tab, ScriptTabState) and tab.key == key: self.set_active(i) if line is not None: self._goto_line_in_active(line) return i editor = CodeTextEdit(text=text, name=tab_name) editor.font_size = 14.0 editor.show_line_numbers = True editor.bg_colour = (0.11, 0.11, 0.13, 1.0) script_tab = ScriptTabState( key=key, kind=kind, node=node, editor=editor, saved_text=text, tab_name=tab_name, ) idx = len(self._tabs) self._tabs.append(script_tab) if self._tab_container: self._tab_container.add_child(script_tab.tab_widget) self.set_active(idx) if line is not None: self._goto_line_in_active(line) return idx
[docs] def open_file(self, path, line: int | None = None) -> int: """Open or switch to a script tab backed by a file path. Useful for error navigation from the console: the traceback identifies a file path on disk, which may or may not be attached to a node. If a tab already exists for this path, switch to it; otherwise open a new file-backed script tab. """ from pathlib import Path p = Path(path) if not p.exists(): return -1 resolved_key = str(p.resolve()) for i, tab in enumerate(self._tabs): if isinstance(tab, ScriptTabState) and tab.key == resolved_key: self.set_active(i) if line is not None: self._goto_line_in_active(line) return i try: text = p.read_text(encoding="utf-8") except OSError: return -1 editor = CodeTextEdit(text=text, name=p.name) editor.font_size = 14.0 editor.show_line_numbers = True editor.bg_colour = (0.11, 0.11, 0.13, 1.0) script_tab = ScriptTabState( key=resolved_key, kind="file", node=None, editor=editor, saved_text=text, tab_name=p.name, ) idx = len(self._tabs) self._tabs.append(script_tab) if self._tab_container: self._tab_container.add_child(script_tab.tab_widget) self.set_active(idx) if line is not None: self._goto_line_in_active(line) return idx
def _goto_line_in_active(self, line: int) -> None: """Move the active tab's editor cursor to a 1-indexed source line.""" editor = self.current_editor if editor is None: return zero_based = max(0, int(line) - 1) line_count = len(editor._lines) if hasattr(editor, "_lines") else 0 if line_count > 0: zero_based = min(zero_based, line_count - 1) editor._cursor_line = zero_based editor._cursor_col = 0 if hasattr(editor, "_ensure_cursor_visible"): editor._ensure_cursor_visible() if hasattr(editor, "_cursor_blink"): editor._cursor_blink = 0.0
[docs] def save_all_scripts(self): """Save all dirty script tabs.""" for i, tab in enumerate(self._tabs): if isinstance(tab, ScriptTabState) and tab.is_dirty: tab.save() self._update_tab_title(i)
[docs] def save_current_script(self): """Save the active script tab, if any.""" if 0 <= self._active_index < len(self._tabs): tab = self._tabs[self._active_index] if isinstance(tab, ScriptTabState): tab.save() self._update_tab_title(self._active_index)
[docs] @property def current_editor(self) -> CodeTextEdit | None: """The active script or Untitled tab's editor, if any.""" if 0 <= self._active_index < len(self._tabs): tab = self._tabs[self._active_index] if isinstance(tab, (ScriptTabState, UntitledTabState)): return tab.editor return None
# -- Untitled scratch buffers --------------------------------------------
[docs] def is_untitled_tab(self, i: int) -> bool: return 0 <= i < len(self._tabs) and isinstance(self._tabs[i], UntitledTabState)
[docs] def untitled_tabs(self) -> list[UntitledTabState]: """All currently-open Untitled scratch buffers (in tab order).""" return [t for t in self._tabs if isinstance(t, UntitledTabState)]
def _next_untitled_ordinal(self) -> int: """Smallest unused 1-based ordinal among open Untitled tabs.""" used = {t.ordinal for t in self.untitled_tabs()} n = 1 while n in used: n += 1 return n
[docs] def new_untitled(self) -> int: """Open a fresh empty Untitled scratch buffer; return its tab index. The buffer lives purely in editor state. Promotion to a real file-backed tab happens via :meth:`promote_untitled_to_file` after the user invokes Save and picks a path through the file dialog. """ ordinal = self._next_untitled_ordinal() tab_name = f"Untitled-{ordinal}.py" editor = CodeTextEdit(text="", name=tab_name) editor.font_size = 14.0 editor.show_line_numbers = True editor.bg_colour = (0.11, 0.11, 0.13, 1.0) tab = UntitledTabState(ordinal=ordinal, editor=editor, tab_name=tab_name) idx = len(self._tabs) self._tabs.append(tab) if self._tab_container: self._tab_container.add_child(tab.tab_widget) self.set_active(idx) return idx
[docs] def promote_untitled_to_file(self, index: int, path: Path | str) -> bool: """Convert an Untitled tab at *index* into a file-backed script tab. Writes the current editor text to *path*, then replaces the :class:`UntitledTabState` slot with a fresh :class:`ScriptTabState` keyed off the resolved file path. The same ``CodeTextEdit`` widget is reused so the tab container does not need to be rebuilt. Returns ``True`` on success, ``False`` if the path could not be written (the Untitled tab is left untouched in that case). """ if not self.is_untitled_tab(index): return False tab = self._tabs[index] assert isinstance(tab, UntitledTabState) p = Path(path) try: atomic_write_text(p, tab.editor.text) except OSError as exc: log.error("Failed to save Untitled tab %s to %s: %s", tab.tab_name, p, exc) return False resolved_key = str(p.resolve()) text = tab.editor.text new_name = p.name tab.editor.name = new_name new_tab = ScriptTabState( key=resolved_key, kind="file", node=None, editor=tab.editor, saved_text=text, tab_name=new_name, ) self._tabs[index] = new_tab self._update_tab_title(index) if self._active_index == index: self.script_tab_activated.emit() return True
# -- General tab ops -----------------------------------------------------
[docs] def set_active(self, index: int): """Switch to tab at index.""" if self.locked: return if index < 0 or index >= len(self._tabs): return old = self._active_index self._active_index = index if self._tab_container: self._tab_container.current_tab = index self._tab_container._update_layout() self._update_all_tab_titles() if old != index: self.active_tab_changed.emit() if isinstance(self._tabs[index], SceneTabState): self.scene_tab_activated.emit() else: self.script_tab_activated.emit()
[docs] def close_tab(self, index: int): """Close a tab by index.""" if index < 0 or index >= len(self._tabs): return tab = self._tabs.pop(index) if self._tab_container: self._tab_container.remove_child(tab.tab_widget) self.tab_closed.emit() # Adjust active index if not self._tabs: self._active_index = -1 elif self._active_index >= len(self._tabs): self._active_index = len(self._tabs) - 1 elif self._active_index > index: self._active_index -= 1 elif self._active_index == index: self._active_index = min(index, len(self._tabs) - 1) if self._tabs and self._active_index >= 0: self.set_active(self._active_index)
[docs] def close_current_tab(self): """Close the currently active tab.""" if self._active_index >= 0: self._on_close_requested(self._active_index)
[docs] def close_all_tabs(self, on_complete: Callable | None = None): """Close all tabs sequentially, prompting for unsaved changes. Calls *on_complete* when done.""" self._close_all_callback = on_complete self._close_all_pending = True self._close_next_for_all()
def _close_next_for_all(self): """Close the next tab in the close-all sequence.""" if not self._tabs: self._close_all_pending = False cb = self._close_all_callback self._close_all_callback = None if cb: cb() return self._on_close_requested(0) # -- Dirty tracking & title indicators -----------------------------------
[docs] def is_tab_dirty(self, index: int) -> bool: """Return whether the tab at *index* has unsaved changes.""" if index < 0 or index >= len(self._tabs): return False return self._tabs[index].is_dirty
def _update_tab_title(self, index: int): """Sync the widget name (and text colour) for tab at *index* based on dirty state.""" if index < 0 or index >= len(self._tabs): return tab = self._tabs[index] dirty = tab.is_dirty tab.tab_widget.name = f"{self.MODIFIED_PREFIX}{tab.tab_name}" if dirty else tab.tab_name # Apply text colour tint via TabContainer if self._tab_container: if dirty: self._tab_container.set_tab_text_colour(index, self.MODIFIED_TEXT_COLOUR) else: self._tab_container.clear_tab_text_colour(index) def _update_all_tab_titles(self): """Refresh modified indicators on every tab.""" for i in range(len(self._tabs)): self._update_tab_title(i) def _check_dirty(self): """Lightweight poll: call from process/draw to keep indicators current.""" self._update_all_tab_titles() # -- Internal ------------------------------------------------------------ def _on_close_requested(self, index: int): """Handle close button click: prompt if dirty, otherwise close immediately.""" if not self.is_tab_dirty(index): self.close_tab(index) return # Dirty tab: show confirmation dialog tab = self._tabs[index] name = tab.tab_name self._pending_close_index = index if self._unsaved_dialog is None: self._unsaved_dialog = UnsavedChangesDialog() self._unsaved_dialog.save_requested.connect(self._on_dialog_save) self._unsaved_dialog.discard_requested.connect(self._on_dialog_discard) self._unsaved_dialog.cancelled.connect(self._on_dialog_cancel) if self._tab_container: self._tab_container.add_child(self._unsaved_dialog) self._unsaved_dialog.show_dialog(tab_name=name) def _on_dialog_save(self): """Dialog 'Save' button: save the tab, then close it.""" idx = self._pending_close_index if 0 <= idx < len(self._tabs): self._tabs[idx].save() self.close_tab(idx) self._pending_close_index = -1 if self._close_all_pending: self._close_next_for_all() def _on_dialog_discard(self): """Dialog 'Don't Save' button: close without saving.""" idx = self._pending_close_index if 0 <= idx < len(self._tabs): self.close_tab(idx) self._pending_close_index = -1 if self._close_all_pending: self._close_next_for_all() def _on_dialog_cancel(self): """Dialog 'Cancel' button: keep the tab open.""" self._pending_close_index = -1 if self._close_all_pending: self._close_all_pending = False self._close_all_callback = None def _on_tab_changed_ui(self, index: int): """Handle user clicking a tab in the TabContainer.""" if index != self._active_index and not self.locked: self.set_active(index) def _resolve_script(self, node: Node, project_path_fn=None) -> tuple: """Determine key, kind, text, and tab name for a node's script.""" if node.script: from simvx.core.script import parse_script_ref file_path, _ = parse_script_ref(node.script) p = Path(file_path) proj = project_path_fn() if project_path_fn else None if not p.is_absolute() and proj: p = proj / p try: text = p.read_text(encoding="utf-8") if p.is_file() else f"# File not found: {file_path}" except Exception: text = f"# Error reading: {file_path}" return str(p), "file", text, Path(file_path).name if getattr(node, "_script_embedded", None): key = f"embedded:{id(node)}" return key, "embedded", node._script_embedded, f"{node.name} (embedded)" return None, None, None, None
# ============================================================================ # UnsavedChangesDialog # ============================================================================
[docs] class UnsavedChangesDialog(Panel): """Modal confirmation dialog shown when closing a tab with unsaved changes.""" DIALOG_WIDTH = 300.0 DIALOG_HEIGHT = 160.0 BUTTON_HEIGHT = 32.0 BUTTON_GAP = 8.0 def __init__(self, **kwargs): super().__init__(name="UnsavedChangesDialog", **kwargs) self.save_requested = Signal() self.discard_requested = Signal() self.cancelled = Signal() self.bg_colour = (0.0, 0.0, 0.0, 0.6) self.border_width = 0 self.visible = False self._dialog_panel: Panel | None = None self._message_label: Label | None = None self._build() def _build(self): inner = Panel(name="DialogInner") inner.bg_colour = (0.18, 0.18, 0.20, 1.0) inner.border_colour = (0.35, 0.35, 0.4, 1.0) inner.border_width = 1.0 inner.size = Vec2(self.DIALOG_WIDTH, self.DIALOG_HEIGHT) self._dialog_panel = inner # Title title = Label("Unsaved Changes", name="Title") title.font_size = 14.0 title.text_colour = (0.9, 0.9, 0.9, 1.0) title.position = Vec2(16, 12) inner.add_child(title) # Message msg = Label("", name="Message") msg.font_size = 12.0 msg.text_colour = (0.7, 0.7, 0.7, 1.0) msg.position = Vec2(16, 42) self._message_label = msg inner.add_child(msg) # Buttons row: positioned from bottom btn_y = self.DIALOG_HEIGHT - self.BUTTON_HEIGHT - 16 btn_w = (self.DIALOG_WIDTH - 16 * 2 - self.BUTTON_GAP * 2) / 3 save_btn = Button("Save", name="SaveBtn") save_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT) save_btn.position = Vec2(16, btn_y) save_btn.bg_colour = (0.20, 0.57, 0.92, 1.0) save_btn.pressed.connect(self._on_save) inner.add_child(save_btn) discard_btn = Button("Don't Save", name="DiscardBtn") discard_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT) discard_btn.position = Vec2(16 + btn_w + self.BUTTON_GAP, btn_y) discard_btn.bg_colour = (0.30, 0.30, 0.33, 1.0) discard_btn.pressed.connect(self._on_discard) inner.add_child(discard_btn) cancel_btn = Button("Cancel", name="CancelBtn") cancel_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT) cancel_btn.position = Vec2(16 + (btn_w + self.BUTTON_GAP) * 2, btn_y) cancel_btn.bg_colour = (0.30, 0.30, 0.33, 1.0) cancel_btn.pressed.connect(self._on_cancel) inner.add_child(cancel_btn) self.add_child(inner)
[docs] def show_dialog(self, tab_name: str = "", parent_size: Vec2 | None = None): """Show the dialog with the given tab name in the message.""" self.visible = True if self._message_label: self._message_label.text = f"Save changes to '{tab_name}' before closing?" if parent_size: self.size = parent_size if self._dialog_panel: pw = self.size.x if self.size.x > 0 else 400 ph = self.size.y if self.size.y > 0 else 300 dw, dh = self._dialog_panel.size.x, self._dialog_panel.size.y self._dialog_panel.position = Vec2((pw - dw) / 2, (ph - dh) / 2)
def _on_save(self): self.visible = False self.save_requested.emit() def _on_discard(self): self.visible = False self.discard_requested.emit() def _on_cancel(self): self.visible = False self.cancelled.emit()
[docs] def gui_input(self, event): """Handle escape to cancel.""" if hasattr(event, "key") and event.key == "escape" and event.pressed: self._on_cancel() return True return super().gui_input(event) if hasattr(super(), "gui_input") else False
# ============================================================================ # NewSceneDialog # ============================================================================
[docs] class NewSceneDialog(Panel): """Modal overlay for choosing a new scene root type.""" ROW_HEIGHT = 40.0 DIALOG_WIDTH = 320.0 HEADER_HEIGHT = 36.0 def __init__(self, **kwargs): super().__init__(name="NewSceneDialog", **kwargs) self.type_chosen = Signal() self.bg_colour = (0.0, 0.0, 0.0, 0.6) self.border_width = 0 self._dialog_panel: Panel | None = None self._rows: list[tuple[str, type, bool]] = [] self._build() def _build(self): from simvx.core import Control as _Control from simvx.core import Node2D, Node3D self._rows = [ ("3D Scene (Default)", Node3D, True), ("3D Scene", Node3D, False), ("2D Scene", Node2D, False), ("UI Scene", _Control, False), ("Empty", Node, False), ] inner = Panel(name="DialogInner") inner.bg_colour = (0.18, 0.18, 0.20, 1.0) inner.border_colour = (0.35, 0.35, 0.4, 1.0) inner.border_width = 1.0 total_h = self.HEADER_HEIGHT + self.ROW_HEIGHT * len(self._rows) inner.size = Vec2(self.DIALOG_WIDTH, total_h) self._dialog_panel = inner # Title title = Label("New Scene", name="Title") title.font_size = 14.0 title.text_colour = (0.9, 0.9, 0.9, 1.0) title.position = Vec2(12, 8) inner.add_child(title) # Rows for i, (text, cls, pop) in enumerate(self._rows): btn = Button(text, name=f"Row_{text}") btn.size = Vec2(self.DIALOG_WIDTH - 16, self.ROW_HEIGHT - 4) btn.position = Vec2(8, self.HEADER_HEIGHT + i * self.ROW_HEIGHT + 2) btn.bg_colour = (0.22, 0.22, 0.25, 1.0) btn.pressed.connect(lambda c=cls, p=pop: self._select(c, p)) inner.add_child(btn) self.add_child(inner)
[docs] def show_dialog(self, parent_size: Vec2 | None = None): """Show the dialog centered in the parent.""" self.visible = True if parent_size: self.size = parent_size if self._dialog_panel: pw = self.size.x if self.size.x > 0 else 400 ph = self.size.y if self.size.y > 0 else 300 dw, dh = self._dialog_panel.size.x, self._dialog_panel.size.y self._dialog_panel.position = Vec2((pw - dw) / 2, (ph - dh) / 2)
def _select(self, root_type: type, populate: bool = False): self.visible = False self.type_chosen.emit(root_type, populate)
[docs] def gui_input(self, event): """Handle escape to dismiss.""" if hasattr(event, "key") and event.key == "escape" and event.pressed: self.visible = False return True return super().gui_input(event) if hasattr(super(), "gui_input") else False