"""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.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from pathlib import Path
from simvx.core import (
Button,
CodeTextEdit,
Control,
EditorCamera3D,
Label,
Node,
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: EditorCamera3D = field(default_factory=lambda: EditorCamera3D(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"
# -- Tab protocol --
@property
def tab_widget(self) -> Control:
"""The widget representing this tab in a TabContainer."""
return self.placeholder
@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 EditorState/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 = EditorCamera3D(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", "inline", "embedded"
node: Node
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 --
@property
def tab_widget(self) -> Control:
"""The widget representing this tab in a TabContainer."""
return self.editor
@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."""
text = self.editor.text
if self.kind == "file" and self.node.script:
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":
self.node._script_embedded = text
self.saved_text = text
self._saved_hash = hash(text)
elif self.kind == "inline":
self.node._script_inline = text
self.saved_text = text
self._saved_hash = hash(text)
# ============================================================================
# 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] = []
self._active_index: int = -1
self.locked: bool = False
self._unsaved_dialog: UnsavedChangesDialog | None = None
self._pending_close_index: int = -1
# 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 ----------------------------------------------------------
@property
def tab_count(self) -> int:
return len(self._tabs)
@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]
def get_active_scene(self) -> SceneTabState | None:
"""Return 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) -> int:
"""Open or switch to a script tab for the given node."""
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)
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)
return idx
[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]
def get_current_editor(self) -> CodeTextEdit | None:
"""Return the active script tab's editor, if any."""
if 0 <= self._active_index < len(self._tabs):
tab = self._tabs[self._active_index]
if isinstance(tab, ScriptTabState):
return tab.editor
return None
# -- 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)
# -- 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
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
def _on_dialog_cancel(self):
"""Dialog 'Cancel' button — keep the tab open."""
self._pending_close_index = -1
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:
p = Path(node.script)
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: {node.script}"
except Exception:
text = f"# Error reading: {node.script}"
return str(p), "file", text, Path(node.script).name
if getattr(node, "_script_embedded", None):
key = f"embedded:{id(node)}"
return key, "embedded", node._script_embedded, f"{node.name} (embedded)"
if getattr(node, "_script_inline", None):
key = f"inline:{id(node)}"
return key, "inline", node._script_inline, f"{node.name} (inline)"
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)