Source code for simvx.core.ui.split

"""SplitContainer: two-panel split with draggable divider."""

import logging

from ..descriptors import Property
from ..input.controls import Pointer
from ..input.enums import CursorShape, PointerPhase
from ..input.events import PointerEvent
from ..math.types import Vec2
from ..signals import Signal
from .containers import Container  # also provides Container._place
from .core import Control, ThemeColour
from .enums import PointerFilter

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}")) """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") split_ratio = Property(0.5, range=(0.0, 1.0), hint="Split position (0-1)") size_x = Property(400.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(300.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") 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() 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 local space.""" x, y, w, h = self._local_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() try: px, py = self.to_local(pos) except ValueError: return False # scaled to zero: nothing to hit 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._combined_minimum_size() if first else Vec2(0, 0) m2 = second._combined_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 floor_x, floor_y = self.size_floor() return Vec2(max(floor_x, w), max(floor_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))
[docs] def on_gui_input(self, event): # The divider owns the press that grabs it and the release that lets go. # Carried rather than returned: the drag and hover updates below run on # the same event, and the press that starts a drag is also its first # motion. if not isinstance(event, PointerEvent): return None claimed = False if event.button in Pointer.PRIMARY: if event.phase is PointerPhase.DOWN and self._point_on_divider(event.position): self._dragging = True self.capture_pointer() claimed = True elif event.phase in (PointerPhase.UP, PointerPhase.CANCEL) and self._dragging: # A cancel ends the drag exactly as a release does: the divider # keeps where it has been dragged to, and it stops following a # pointer the window no longer has. self._dragging = False self.release_pointer() if not self._hover_divider: self._update_cursor(False) claimed = True if self._dragging: _x, _y, w, h = self._local_rect() px, py = self.to_local(event.position) if self.vertical: ratio = px / w if w > 0 else 0.5 else: ratio = py / 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.emit(self.split_ratio) # Update hover state for visual feedback and cursor shape if 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) return claimed
[docs] def on_update(self, dt: float): super().on_update(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.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): """Show resize arrows while the pointer is on the divider, and the parent's shape otherwise.""" if resize_cursor: self.cursor_shape = CursorShape.RESIZE_EW if self.vertical else CursorShape.RESIZE_NS else: self.cursor_shape = None
[docs] def on_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.rect((dx, dy), (dw, dh), fill=colour)