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