Source code for simvx.core.ui.scroll

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

import logging

from ..signals import Signal
from ..math.types import Vec2
from .containers import Container
from .core import Control, ThemeColour, ThemeSize
from ..input.enums import MouseButton

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 _clips_input = 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)
[docs] @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 _scrollbar_visible(self) -> bool: """True when content overflows the rect on either axis (scrollbar gutter reserved).""" _, _, w, h = self.get_rect() cs = self.content_size return cs.y > h or cs.x > w def _clamp_scroll(self): """Keep scroll values within valid range.""" _, _, w, h = self.get_rect() cs = self.content_size sbw = self.scrollbar_width if self._scrollbar_visible() else 0.0 max_scroll_x = max(0.0, cs.x - w + sbw) 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 == MouseButton.LEFT: 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 on_draw(self, renderer): x, y, w, h = self.get_global_rect() # Background renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True) # Clip children to container bounds. When content fits, reclaim the gutter # so ScrollContainer can serve as a pure clipped region (no invisible reservation). sbw = self.scrollbar_width if self._scrollbar_visible() else 0.0 renderer.push_clip(x, y, w - sbw, h) for child in self.children: if isinstance(child, Control) and hasattr(child, "draw"): child.on_draw(renderer) renderer.pop_clip() # Scrollbar track + thumb (only when content overflows vertically) cs = self.content_size if cs.y > h: renderer.draw_rect((x + w - sbw, y), (sbw, h), colour=self.scrollbar_track_colour, filled=True) sx, sy, sw, sh = self._scrollbar_rect() colour = self.scrollbar_hover_colour if self._dragging_scrollbar else self.scrollbar_colour renderer.draw_rect((sx, sy), (sw, sh), colour=colour, filled=True)