Source code for simvx.core.ui.dock

"""DockPanel / DockContainer — dockable panel layout system.

Provides an IDE-style docking layout where panels can be placed at
cardinal positions (left, right, top, bottom, center) using a binary
split tree of SplitContainers.
"""


from __future__ import annotations

import logging

from ..descriptors import Property
from ..math.types import Vec2
from .core import Control, ThemeColour, ThemeSize, ThemeStyleBox
from .split import SplitContainer
from .widgets import Panel

log = logging.getLogger(__name__)

__all__ = ["DockPanel", "DockContainer"]


# ============================================================================
# DockPanel — a titled panel that can be placed inside a DockContainer
# ============================================================================


[docs] class DockPanel(Panel): """Panel with a title bar header and a content area. The title bar height is theme-driven (``dock_title_h``). Content is positioned below the title bar. Use set_content() to assign a child control as the panel body. Example: dock_panel = DockPanel(name="Inspector") dock_panel.set_content(some_control) """ title = Property("", hint="Panel title text") title_bg_colour = ThemeColour("dock_title_bg") title_text_colour = ThemeColour("dock_title_text") bg_colour = ThemeColour("bg") border_colour = ThemeColour("border") style_title = ThemeStyleBox("dock_title_style") title_height = ThemeSize("dock_title_h", default=24.0) def __init__(self, title: str = "", **kwargs): super().__init__(**kwargs) self.title = title or self.name self._content: Control | None = None
[docs] def set_content(self, control: Control): """Set the content control displayed below the title bar. If a previous content control exists it is removed first. The content is added as a child and automatically positioned. Args: control: The Control to display inside this panel. """ if self._content is not None: self.remove_child(self._content) self._content = control self.add_child(control) self._layout_content()
def _layout_content(self): """Position and size the content control below the title bar.""" if self._content is None: return from .containers import Container th = round(self.title_height) _, _, w, h = self.get_rect() Container._place(self._content, 0, th, round(max(0, w)), round(max(0, h - th)))
[docs] def process(self, dt: float): current_size = (self.size.x, self.size.y) if current_size != getattr(self, "_last_size_dp", None): self._last_size_dp = current_size self._layout_content()
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() th = self.title_height # Content area background (skip if fully transparent) if len(self.bg_colour) < 4 or self.bg_colour[3] > 0: renderer.draw_filled_rect(x, y + th, w, max(0, h - th), self.bg_colour) # Title bar via StyleBox or flat fallback title_box = self.style_title if title_box is not None: title_box.draw(renderer, x, y, w, th) else: renderer.draw_filled_rect(x, y, w, th, self.title_bg_colour) # Title text if self.title: scale = 13.0 / 14.0 text_w = renderer.text_width(self.title, scale) text_x = x + (w - text_w) / 2 text_y = y + (th - 13.0) / 2 renderer.draw_text_coloured(self.title, text_x, text_y, scale, self.title_text_colour) # Title bar bottom border renderer.draw_line_coloured(x, y + th, x + w, y + th, self.border_colour) # Outer border renderer.draw_rect_coloured(x, y, w, h, self.border_colour)
# ============================================================================ # DockContainer — manages a layout of DockPanels via SplitContainers # ============================================================================
[docs] class DockContainer(Control): """IDE-style docking container that arranges DockPanels using splits. Panels are added with a position hint ("center", "left", "right", "top", "bottom"). The first panel becomes the center; subsequent panels wrap the existing content in a SplitContainer. Example: dock = DockContainer() dock.size = Vec2(1200, 800) scene_panel = DockPanel(title="Scene") inspector = DockPanel(title="Inspector") console = DockPanel(title="Console") dock.add_panel(scene_panel, "center") dock.add_panel(inspector, "right") dock.add_panel(console, "bottom") """ bg_colour = ThemeColour("bg_dark") def __init__(self, **kwargs): super().__init__(**kwargs) self._root_split: SplitContainer | None = None self._root_panel: DockPanel | None = None self._panels: list[DockPanel] = [] # ------------------------------------------------------------------ public
[docs] def add_panel(self, panel: DockPanel, position: str = "center"): """Add a DockPanel at the given position. Args: panel: The DockPanel to add. position: One of "center", "left", "right", "top", "bottom". "left"/"right" create a vertical split. "top"/"bottom" create a horizontal split. """ self._panels.append(panel) # First panel — becomes center directly if self._root_split is None and self._root_panel is None: self._root_panel = panel self.add_child(panel) self._update_layout() return # Determine split orientation and panel ordering if position in ("left", "right"): vertical = True elif position in ("top", "bottom"): vertical = False else: # "center" or unknown — replace existing content if self._root_panel is not None: self.remove_child(self._root_panel) self._root_panel = panel self.add_child(panel) elif self._root_split is not None: # Add as a tab or replace — for now, wrap with a new split self._wrap_with_split(panel, vertical=True, new_first=False) self._update_layout() return self._wrap_with_split( panel, vertical=vertical, new_first=(position in ("left", "top")), ) self._update_layout()
[docs] def save_layout(self) -> dict: """Serialize the current dock layout to a dictionary. Returns: A dict describing the split tree and panel names, suitable for JSON serialization. """ if self._root_panel is not None: return {"type": "panel", "name": self._root_panel.title} if self._root_split is not None: return self._serialize_node(self._root_split) return {}
[docs] def restore_layout(self, data: dict): """Restore a previously saved layout. Panels are looked up by title from the internal _panels list. Any panels not found in the data are ignored. Args: data: Layout dictionary as returned by save_layout(). """ # Remove current structure if self._root_panel is not None: self.remove_child(self._root_panel) self._root_panel = None if self._root_split is not None: self.remove_child(self._root_split) self._root_split = None # Build from data panel_map = {p.title: p for p in self._panels} node = self._deserialize_node(data, panel_map) if node is None: return if isinstance(node, DockPanel): self._root_panel = node self.add_child(node) elif isinstance(node, SplitContainer): self._root_split = node self._root_panel = None self.add_child(node) self._update_layout()
# ----------------------------------------------------------------- private def _wrap_with_split(self, new_panel: DockPanel, vertical: bool, new_first: bool): """Wrap the current root content with a SplitContainer. The existing content becomes one child and new_panel becomes the other. """ # Determine initial split ratio if new_first: ratio = 0.25 # new panel gets smaller portion else: ratio = 0.75 # existing content keeps larger portion split = SplitContainer(vertical=vertical, split_ratio=ratio) # Remove current root from our children existing: Control if self._root_panel is not None: self.remove_child(self._root_panel) existing = self._root_panel self._root_panel = None elif self._root_split is not None: self.remove_child(self._root_split) existing = self._root_split else: existing = Panel() # Add children in correct order if new_first: split.add_child(new_panel) split.add_child(existing) else: split.add_child(existing) split.add_child(new_panel) self._root_split = split self.add_child(split) def _update_layout(self): """Size the root content to fill this container.""" from .containers import Container _, _, w, h = self.get_rect() if self._root_panel is not None: Container._place(self._root_panel, 0, 0, w, h) if self._root_split is not None: Container._place(self._root_split, 0, 0, w, h)
[docs] def process(self, dt: float): current_size = (self.size.x, self.size.y) if current_size != getattr(self, "_last_size_dc", None): self._last_size_dc = current_size self._update_layout()
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() if len(self.bg_colour) < 4 or self.bg_colour[3] > 0: renderer.draw_filled_rect(x, y, w, h, self.bg_colour)
# ---------------------------------------------------------- serialization def _serialize_node(self, node: Control) -> dict: """Recursively serialize a split tree node.""" if isinstance(node, DockPanel): return {"type": "panel", "name": node.title} if isinstance(node, SplitContainer): first, second = node._get_panels() return { "type": "split", "vertical": node.vertical, "ratio": node.split_ratio, "first": self._serialize_node(first) if first else {}, "second": self._serialize_node(second) if second else {}, } return {} def _deserialize_node(self, data: dict, panel_map: dict[str, DockPanel]) -> Control | None: """Recursively rebuild a split tree from serialized data.""" if not data: return None node_type = data.get("type", "") if node_type == "panel": name = data.get("name", "") panel = panel_map.get(name) return panel if node_type == "split": vertical = data.get("vertical", True) ratio = data.get("ratio", 0.5) split = SplitContainer(vertical=vertical, split_ratio=ratio) first = self._deserialize_node(data.get("first", {}), panel_map) second = self._deserialize_node(data.get("second", {}), panel_map) if first is not None: split.add_child(first) if second is not None: split.add_child(second) return split return None