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