"""SplitContainer — two-panel split with draggable divider."""
from __future__ import annotations
import logging
from ..descriptors import Property, Signal
from ..math.types import Vec2
from .containers import Container # also provides Container._place
from .core import Control, ThemeColour
log = logging.getLogger(__name__)
__all__ = ["SplitContainer"]
_DIVIDER_GRAB_MARGIN = 4.0
[docs]
class SplitContainer(Container):
"""Two-panel split container with a draggable divider.
When vertical=True (default), panels are arranged side by side (left | right).
When vertical=False, panels are stacked top / bottom.
Only the first two children are laid out; additional children are ignored.
Example:
split = SplitContainer(vertical=True, split_ratio=0.3)
split.add_child(Panel()) # left panel (30%)
split.add_child(Panel()) # right panel (70%)
split.split_changed.connect(lambda r: print(f"ratio: {r}"))
"""
split_ratio = Property(0.5, range=(0.0, 1.0), hint="Split position (0-1)")
divider_colour = ThemeColour("divider")
divider_hover_colour = ThemeColour("divider_hover")
def __init__(self, vertical: bool = True, split_ratio: float = 0.5, **kwargs):
super().__init__(**kwargs)
self.vertical = vertical
self.split_ratio = max(0.05, min(0.95, split_ratio))
self.divider_width = 6.0
self._dragging = False
self._hover_divider = False
self.split_changed = Signal()
self.size = Vec2(400, 300)
def _get_panels(self) -> tuple[Control | None, Control | None]:
"""Return the first two Control children."""
controls = [c for c in self.children if isinstance(c, Control)]
first = controls[0] if len(controls) > 0 else None
second = controls[1] if len(controls) > 1 else None
return first, second
def _divider_rect(self) -> tuple[float, float, float, float]:
"""Return (x, y, w, h) of the divider in screen space."""
x, y, w, h = self.get_global_rect()
half = round(self.divider_width / 2)
if self.vertical:
dx = x + round(w * self.split_ratio) - half
return (dx, y, self.divider_width, h)
else:
dy = y + round(h * self.split_ratio) - half
return (x, dy, w, self.divider_width)
def _point_on_divider(self, pos) -> bool:
"""Check if a point is within the divider grab area."""
dx, dy, dw, dh = self._divider_rect()
px = pos.x if hasattr(pos, "x") else pos[0]
py = pos.y if hasattr(pos, "y") else pos[1]
return (
dx - _DIVIDER_GRAB_MARGIN <= px <= dx + dw + _DIVIDER_GRAB_MARGIN
and dy - _DIVIDER_GRAB_MARGIN <= py <= dy + dh + _DIVIDER_GRAB_MARGIN
)
[docs]
def get_minimum_size(self) -> Vec2:
first, second = self._get_panels()
m1 = first.get_minimum_size() if first else Vec2(0, 0)
m2 = second.get_minimum_size() if second else Vec2(0, 0)
if self.vertical:
w = m1.x + m2.x + self.divider_width
h = max(m1.y, m2.y)
else:
w = max(m1.x, m2.x)
h = m1.y + m2.y + self.divider_width
return Vec2(max(self.min_size.x, w), max(self.min_size.y, h))
def _update_layout(self):
first, second = self._get_panels()
_, _, w, h = self.get_rect()
half = round(self.divider_width / 2)
place = Container._place
if self.vertical:
split_px = round(w * self.split_ratio)
rh = round(h)
if first:
place(first, 0, 0, max(0, split_px - half), rh)
if second:
place(second, split_px + half, 0, max(0, w - split_px - half), rh)
else:
split_px = round(h * self.split_ratio)
rw = round(w)
if first:
place(first, 0, 0, rw, max(0, split_px - half))
if second:
place(second, 0, split_px + half, rw, max(0, h - split_px - half))
def _on_gui_input(self, event):
if event.button == 1:
if event.pressed and self._point_on_divider(event.position):
self._dragging = True
self.grab_mouse()
elif not event.pressed and self._dragging:
self._dragging = False
self.release_mouse()
if not self._hover_divider:
self._update_cursor(False)
if self._dragging and event.position:
x, y, w, h = self.get_global_rect()
if self.vertical:
px = event.position.x if hasattr(event.position, "x") else event.position[0]
ratio = (px - x) / w if w > 0 else 0.5
else:
py = event.position.y if hasattr(event.position, "y") else event.position[1]
ratio = (py - y) / h if h > 0 else 0.5
new_ratio = max(0.05, min(0.95, ratio))
if new_ratio != self.split_ratio:
self.split_ratio = new_ratio
self._update_layout()
self.split_changed(self.split_ratio)
# Update hover state for visual feedback and cursor shape
if event.position and not self._dragging:
was_hover = self._hover_divider
self._hover_divider = self._point_on_divider(event.position)
if self._hover_divider != was_hover:
self._update_cursor(self._hover_divider)
[docs]
def process(self, dt: float):
super().process(dt)
# When hovering the divider but no longer receiving gui_input (mouse moved
# onto a child panel), poll the mouse position to detect exit.
if self._hover_divider and not self._dragging:
from ..input.state import Input
mx, my = Input.get_mouse_position()
if not self._point_on_divider((mx, my)):
self._hover_divider = False
self._update_cursor(False)
def _update_cursor(self, resize_cursor: bool):
"""Set mouse cursor to resize arrows or back to default."""
app = self.app if self._tree else None
engine = getattr(app, "_engine", None) if app else None
if engine and hasattr(engine, "set_cursor_shape"):
if resize_cursor:
engine.set_cursor_shape(4 if self.vertical else 5) # hresize / vresize
else:
engine.set_cursor_shape(0) # arrow
[docs]
def draw(self, renderer):
# Only draw the divider — children are drawn by _draw_recursive tree traversal
dx, dy, dw, dh = self._divider_rect()
colour = self.divider_hover_colour if (self._hover_divider or self._dragging) else self.divider_colour
renderer.draw_filled_rect(dx, dy, dw, dh, colour)