Source code for simvx.core.ui.scroll
"""ScrollContainer: scrollable container that clips content and shows scrollbar."""
import logging
from ..descriptors import Property
from ..input.enums import MouseButton
from ..math.types import Vec2
from ..signals import Signal
from .containers import Container
from .core import Control, ThemeColour, ThemeSize, _current_metrics_epoch
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
_clips_input = True
# A viewport onto content larger than itself: clipping is what makes it one,
# so the switch every Control carries is on by default here.
clip_contents = Property(True, group="Layout", hint="Clip children's drawing to this control's rect")
size_x = Property(200.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(200.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
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)
#: Remembered ``content_size`` and the text-metrics epoch it was measured
#: under, dropped by :meth:`_forget_measurements` and retired by a metrics
#: move exactly as the remembered minimum sizes it is made of are.
_content_size_cache: tuple[float, float] | None = None
_content_size_epoch: int = -1
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()
@staticmethod
def _child_extent(child: Control) -> Vec2:
"""Room a child occupies: its size, never below the minimum it reports.
A child that grows with its own content -- a ``VBoxContainer`` given a nominal
height and then filled -- keeps that stale ``size`` and reports the room it
really needs through its minimum. Measuring only the declared size would
under-report the content, hide the scrollbar and leave the overflow clipped
away with no way to scroll to it.
The minimum is the remembered one, so measuring a list does not re-measure
every widget in it on every read.
"""
minimum = child._combined_minimum_size()
return Vec2(max(child.size.x, minimum.x), max(child.size.y, minimum.y))
[docs]
@property
def content_size(self) -> Vec2:
"""Total extent of the laid-out content, independent of the current scroll offset.
Children are measured from the unscrolled stack that ``_update_layout`` builds, not
from their current positions: those already carry the scroll offset, so reading them
back would shrink the content by however far the view has been scrolled.
Remembered until something can have changed it: one wheel step reads this
four times (clamp, scrollbar visibility, thumb, redraw) and a settings list
can hold hundreds of rows.
"""
epoch = _current_metrics_epoch()
cached = self._content_size_cache
if cached is not None and self._content_size_epoch == epoch:
return Vec2(cached[0], cached[1])
max_x = 0.0
max_y = 0.0
y_offset = 0.0
for child in self.children:
if not isinstance(child, Control):
continue
extent = self._child_extent(child)
max_x = max(max_x, extent.x)
max_y = max(max_y, y_offset + extent.y)
y_offset += extent.y + self.separation
self._content_size_cache = (max_x, max_y)
self._content_size_epoch = epoch
return Vec2(max_x, max_y)
def _forget_measurements(self) -> None:
"""Drop the measured content along with the remembered minimum size.
``content_size`` is measured from the child minimums, so the events that
retire those retire it: a child resized, redrew, raised its minimum,
arrived or left. A metrics move is not one of them -- it retires both
caches through the epoch each is stamped with, without a walk.
"""
self._content_size_cache = None
super()._forget_measurements()
[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 _scroll_vertically(self, delta: float) -> bool:
"""Move the view by *delta* pixels; report whether it actually moved.
False means this container is already as far as it goes that way, or its
content fits and it never had anywhere to go. The wheel is claimed on the
answer, so an inner list that has run out of room hands the wheel to the
list around it instead of eating it -- the rule Godot's ScrollContainer
follows, and the reason a nested list does not trap the pointer.
"""
before = self.scroll_y
self.scroll_y = max(0.0, self.scroll_y + delta)
self._clamp_scroll()
if self.scroll_y == before:
return False
self._update_layout()
self.queue_redraw()
self.scroll_changed(self.scroll_y)
return True
def _scroll_horizontally(self, delta: float) -> bool:
"""Move the view sideways by *delta* pixels; report whether it moved."""
before = self.scroll_x
self.scroll_x = max(0.0, self.scroll_x + delta)
self._clamp_scroll()
if self.scroll_x == before:
return False
self._update_layout()
self.queue_redraw()
self.scroll_changed(self.scroll_x)
return True
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)
self.scroll_x = max(0.0, min(self.scroll_x, max_scroll_x))
self.scroll_y = max(0.0, min(self.scroll_y, self._max_scroll_y()))
def _max_scroll_y(self) -> float:
"""How far the content can travel vertically: its height less the viewport's."""
return max(0.0, self.content_size.y - self.get_rect()[3])
def _update_layout(self):
"""Offset children by current scroll position."""
if not self.children:
return
# Stack children vertically, then offset by scroll. The stack advances by the
# same extent ``content_size`` measures, so the last child ends exactly where
# the measured content does and stays reachable.
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)
# The scroll offset is the engine's arithmetic, not a position the
# author typed, so a save must not write it into their line.
child._record_derived("position", Vec2(child.position))
y_offset += self._child_extent(child).y + self.separation
def _scroll_by_key(self, key: str) -> bool:
"""Apply the scroll a navigation key asks for; report whether the view moved.
A page is one viewport height, and home/end run to the two ends of the
content. Anything else is not a scrolling key and moves nothing.
"""
if key == "up":
return self._scroll_vertically(-_SCROLL_STEP)
if key == "down":
return self._scroll_vertically(_SCROLL_STEP)
if key == "left":
return self._scroll_horizontally(-_SCROLL_STEP)
if key == "right":
return self._scroll_horizontally(_SCROLL_STEP)
if key in ("pageup", "page_up"):
return self._scroll_vertically(-self.get_rect()[3])
if key in ("pagedown", "page_down"):
return self._scroll_vertically(self.get_rect()[3])
if key == "home":
return self._scroll_vertically(-self.scroll_y)
if key == "end":
return self._scroll_vertically(self.content_size.y)
return False
def _on_gui_input(self, event):
# Mouse wheel scrolling. Claimed only when the view actually moves, so a
# container that is at its limit -- or whose content fits -- hands the
# wheel to the scrolling ancestor around it instead of eating it.
if event.key == "scroll_up":
if self._scroll_vertically(-_SCROLL_STEP):
event.handled = True
elif event.key == "scroll_down":
if self._scroll_vertically(_SCROLL_STEP):
event.handled = True
# Keyboard scrolling when focused, on the press: key auto-repeat arrives as
# repeated presses, so acting on the release would scroll once per keystroke
# and never repeat. Claimed only when the view moved, on the same terms as
# the wheel, so a container at its limit lets the key reach the one around it.
if self.focused and event.pressed and event.key:
if self._scroll_by_key(event.key):
event.handled = True
# Scrollbar drag. The press is claimed and takes the mouse grab: the thumb
# belongs to this container, and the drag has to survive the pointer leaving
# the container's rect, which it does for any drag longer than the gutter.
if event.button == MouseButton.LEFT:
if event.pressed:
sx, sy, sw, sh = self._scrollbar_rect()
if sx > 0:
px = event.position.x
py = event.position.y
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
self.grab_mouse()
event.handled = True
elif self._dragging_scrollbar:
self._dragging_scrollbar = False
self.release_mouse()
event.handled = True
if self._dragging_scrollbar and event.position:
py = event.position.y
_, _, _, h = self.get_rect()
# The thumb travels the track less its own height, and that travel spans
# the whole scrollable range. It is the inverse of the mapping
# ``_scrollbar_rect`` places the thumb with, so the thumb stays exactly
# under the pointer, minimum-thumb-size floor included.
thumb_h = self._scrollbar_rect()[3]
track_h = h - thumb_h
delta_px = py - self._drag_start_y
delta_scroll = (delta_px / track_h) * self._max_scroll_y() if track_h > 0 else 0.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)
max_scroll = self._max_scroll_y()
scroll_ratio = self.scroll_y / max_scroll if max_scroll > 0.0 else 0.0
thumb_y = y + scroll_ratio * (h - thumb_h)
sbw = self.scrollbar_width
return (x + w - sbw, thumb_y, sbw, thumb_h)
def _child_clip_rect(self) -> tuple[float, float, float, float] | None:
"""The viewport children are drawn through: the rect minus the scrollbar gutter.
When the content fits, the gutter is reclaimed so a ScrollContainer can
serve as a pure clipped region with no invisible reservation. ``None``
when ``clip_contents`` is turned off, which is what that switch means on
every Control.
"""
if not self.clip_contents:
return None
x, y, w, h = self.get_global_rect()
sbw = self.scrollbar_width if self._scrollbar_visible() else 0.0
return (x, y, w - sbw, 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)
# Scrollbar track + thumb (only when content overflows vertically). Children
# are drawn by the ordinary walk, clipped to ``_child_clip_rect``.
cs = self.content_size
if cs.y > h:
sbw = self.scrollbar_width
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)