Source code for simvx.core.ui.split

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