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)