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)