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

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

log = logging.getLogger(__name__)

# ============================================================================
# Per-tab state dataclasses
# ============================================================================

class _SceneTabPlaceholder(Control):
    """Invisible placeholder — exists only to give TabContainer a named child for scene tabs."""

    def 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: p.parent.mkdir(parents=True, exist_ok=True) p.write_text(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 self._tabs: list[SceneTabState | ScriptTabState | UntitledTabState] = [] 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() 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: p.parent.mkdir(parents=True, exist_ok=True) p.write_text(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() 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