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.
"""


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()
[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