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