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)