Source code for simvx.core.ui.scroll

"""ScrollContainer — scrollable container that clips content and shows scrollbar."""


from __future__ import annotations

import logging

from ..descriptors import Signal
from ..math.types import Vec2
from .containers import Container
from .core import Control, ThemeColour, ThemeSize

log = logging.getLogger(__name__)

__all__ = ["ScrollContainer"]

# Scrollbar styling
_SCROLLBAR_WIDTH = 8.0
_SCROLL_STEP = 20.0


[docs] class ScrollContainer(Container): """Scrollable container that clips content and shows a vertical/horizontal scrollbar. Children are positioned offset by the current scroll values. Content that overflows the container bounds is clipped. Example: scroll = ScrollContainer() scroll.size = Vec2(300, 200) for i in range(20): scroll.add_child(Label(f"Item {i}")) """ _draw_caching = True _draws_children = True bg_colour = ThemeColour("bg_darker") scrollbar_colour = ThemeColour("scrollbar_fg") scrollbar_hover_colour = ThemeColour("scrollbar_hover") scrollbar_track_colour = ThemeColour("scrollbar_track") scrollbar_width = ThemeSize("scrollbar_width", default=8.0) def __init__(self, **kwargs): super().__init__(**kwargs) self.scroll_x = 0.0 self.scroll_y = 0.0 self._dragging_scrollbar = False self._drag_start_y = 0.0 self._drag_start_scroll = 0.0 self.scroll_changed = Signal() self.size = Vec2(200, 200) @property def content_size(self) -> Vec2: """Compute total content size from children bounds.""" max_x = 0.0 max_y = 0.0 for child in self.children: if not isinstance(child, Control): continue cx = child.position.x + child.size.x cy = child.position.y + child.size.y max_x = max(max_x, cx) max_y = max(max_y, cy) return Vec2(max_x, max_y)
[docs] def get_minimum_size(self) -> Vec2: # Scrollable content doesn't constrain; just return own min_size return Vec2(max(0, self.min_size.x), max(0, self.min_size.y))
def _clamp_scroll(self): """Keep scroll values within valid range.""" _, _, w, h = self.get_rect() cs = self.content_size max_scroll_x = max(0.0, cs.x - w + self.scrollbar_width) max_scroll_y = max(0.0, cs.y - h) self.scroll_x = max(0.0, min(self.scroll_x, max_scroll_x)) self.scroll_y = max(0.0, min(self.scroll_y, max_scroll_y)) def _update_layout(self): """Offset children by current scroll position.""" if not self.children: return # Stack children vertically, then offset by scroll y_offset = 0.0 for child in self.children: if not isinstance(child, Control): continue child.position = Vec2(-self.scroll_x, y_offset - self.scroll_y) y_offset += child.size.y + self.separation def _on_gui_input(self, event): # Mouse wheel scrolling if event.key == "scroll_up": self.scroll_y = max(0.0, self.scroll_y - _SCROLL_STEP) self._clamp_scroll() self._update_layout() self.queue_redraw() self.scroll_changed(self.scroll_y) elif event.key == "scroll_down": self.scroll_y += _SCROLL_STEP self._clamp_scroll() self._update_layout() self.queue_redraw() self.scroll_changed(self.scroll_y) # Arrow key scrolling when focused if self.focused and not event.pressed: if event.key == "up": self.scroll_y = max(0.0, self.scroll_y - _SCROLL_STEP) self._clamp_scroll() self._update_layout() self.queue_redraw() self.scroll_changed(self.scroll_y) elif event.key == "down": self.scroll_y += _SCROLL_STEP self._clamp_scroll() self._update_layout() self.queue_redraw() self.scroll_changed(self.scroll_y) elif event.key == "left": self.scroll_x = max(0.0, self.scroll_x - _SCROLL_STEP) self._clamp_scroll() self._update_layout() self.queue_redraw() self.scroll_changed(self.scroll_x) elif event.key == "right": self.scroll_x += _SCROLL_STEP self._clamp_scroll() self._update_layout() self.queue_redraw() self.scroll_changed(self.scroll_x) # Scrollbar drag if event.button == 1: if event.pressed: sx, sy, sw, sh = self._scrollbar_rect() if sx > 0: px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] if sx <= px <= sx + sw and sy <= py <= sy + sh: self._dragging_scrollbar = True self._drag_start_y = py self._drag_start_scroll = self.scroll_y else: self._dragging_scrollbar = False if self._dragging_scrollbar and event.position: py = event.position.y if hasattr(event.position, "y") else event.position[1] _, _, _, h = self.get_rect() cs = self.content_size max(1.0, cs.y - h) track_h = h - self.scrollbar_width # usable track height delta_px = py - self._drag_start_y delta_scroll = (delta_px / track_h) * cs.y if track_h > 0 else 0 self.scroll_y = self._drag_start_scroll + delta_scroll self._clamp_scroll() self._update_layout() self.queue_redraw() self.scroll_changed(self.scroll_y) def _scrollbar_rect(self) -> tuple[float, float, float, float]: """Return (x, y, w, h) of the scrollbar thumb in screen space, or zeros if not needed.""" x, y, w, h = self.get_global_rect() cs = self.content_size if cs.y <= h: return (0, 0, 0, 0) ratio = h / cs.y thumb_h = max(20.0, h * ratio) scroll_ratio = self.scroll_y / max(1.0, cs.y - h) thumb_y = y + scroll_ratio * (h - thumb_h) sbw = self.scrollbar_width return (x + w - sbw, thumb_y, sbw, thumb_h)
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() # Background renderer.draw_filled_rect(x, y, w, h, self.bg_colour) # Clip children to container bounds sbw = self.scrollbar_width renderer.push_clip(x, y, w - sbw, h) for child in self.children: if isinstance(child, Control) and hasattr(child, "draw"): child.draw(renderer) renderer.pop_clip() # Scrollbar track cs = self.content_size if cs.y > h: renderer.draw_filled_rect( x + w - sbw, y, sbw, h, self.scrollbar_track_colour, ) # Scrollbar thumb sx, sy, sw, sh = self._scrollbar_rect() colour = self.scrollbar_hover_colour if self._dragging_scrollbar else self.scrollbar_colour renderer.draw_filled_rect(sx, sy, sw, sh, colour)