Source code for simvx.core.ui.virtual_scroll

"""VirtualScrollContainer -- only renders visible items for large lists.

Items are provided via a data source callback. Only items within the
visible viewport range are instantiated and laid out, enabling efficient
handling of thousands of items.
"""

import logging
from collections.abc import Callable
from typing import Any

from ..descriptors import Property
from ..input.controls import Pointer
from ..input.enums import PointerPhase
from ..input.events import PointerEvent, ScrollEvent
from ..math.types import Vec2
from ..node import Node
from ..signals import Signal
from .containers import Container
from .core import Control, ThemeColour
from .enums import PointerFilter

log = logging.getLogger(__name__)

__all__ = ["VirtualScrollContainer"]

_SCROLLBAR_WIDTH = 6.0


[docs] class VirtualScrollContainer(Container): """ScrollContainer that only renders visible items. Items are provided via a data source callback. Only items within the visible viewport range are instantiated and laid out. Off-screen items are recycled to minimize allocation. Set ``show_scrollbar = True`` to display a vertical scrollbar thumb when content overflows (default False). Example: vs = VirtualScrollContainer(item_height=24.0, show_scrollbar=True) vs.size = Vec2(300, 400) def make_label(index, recycled): from simvx.core.ui.widgets import Label lbl = recycled or Label() lbl.text = f"Item {index}" return lbl vs.set_data_source(10000, make_label) """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") _draw_caching = False # A viewport onto a list larger than itself: clipping is what makes it one. 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") scrollbar_colour = ThemeColour("scrollbar_fg") scrollbar_track_colour = ThemeColour("scrollbar_track") def __init__(self, item_height: float = 24.0, show_scrollbar: bool = False, **kwargs): super().__init__(**kwargs) self._item_height = item_height self._item_count = 0 self._scroll_offset = 0.0 self._item_factory: Callable[[int, Control | None], Control] | None = None self._data_source: Callable[[int], Any] | None = None self._visible_items: dict[int, Control] = {} self._pool: list[Control] = [] self._rebuilding = False self.show_scrollbar = show_scrollbar self._dragging_scrollbar = False self._drag_start_y = 0.0 self._drag_start_scroll = 0.0 self.scroll_changed = Signal()
[docs] def set_data_source( self, count: int, factory: Callable[[int, Control | None], Control], data_fn: Callable[[int], Any] | None = None, ): """Configure the virtual list. Args: count: Total number of items. factory: Creates/updates a Control for item at index. Signature: fn(index, existing_or_None) -> Control data_fn: Optional callback returning data for item at index. """ self._item_count = count self._item_factory = factory self._data_source = data_fn self._scroll_offset = 0.0 self._rebuild_visible()
[docs] def update_item_count(self, count: int): """Update total item count and refresh visible items.""" self._item_count = count max_scroll = max(0.0, self.total_height - self.size.y) self._scroll_offset = min(self._scroll_offset, max_scroll) self._rebuild_visible()
[docs] @property def total_height(self) -> float: """Total content height based on item count and row height.""" return self._item_count * self._item_height
[docs] @property def visible_range(self) -> tuple[int, int]: """Return (first_visible_index, last_visible_index_exclusive).""" if self._item_count == 0: return (0, 0) first = max(0, int(self._scroll_offset / self._item_height)) visible_count = int(self.size.y / self._item_height) + 2 last = min(self._item_count, first + visible_count) return (first, last)
@property def _max_scroll(self) -> float: return max(0.0, self.total_height - self.size.y) @property def _needs_scrollbar(self) -> bool: return self.show_scrollbar and self.total_height > self.size.y
[docs] def scroll_to(self, offset: float): """Scroll to absolute pixel offset.""" old = self._scroll_offset self._scroll_offset = max(0.0, min(offset, self._max_scroll)) if self._scroll_offset != old: self._rebuild_visible() self.scroll_changed.emit(self._scroll_offset)
[docs] def scroll_to_index(self, index: int): """Scroll so the item at *index* is at the top of the viewport.""" self.scroll_to(index * self._item_height)
[docs] def on_scroll(self, delta: float): """Handle scroll wheel input (positive delta = scroll down).""" self.scroll_to(self._scroll_offset - delta * self._item_height * 3)
def _scroll_vertically(self, delta: float) -> bool: """Move the view by *delta* pixels; report whether it actually moved. False means this list is already as far as it goes that way, or its items fit and it never had anywhere to go. The wheel is claimed on the answer, so a virtual list that has run out of room hands the wheel to the container around it instead of eating it. """ before = self._scroll_offset self.scroll_to(before + delta) return self._scroll_offset != before
[docs] def on_gui_input(self, event): # Carried rather than returned: the press that grabs the thumb is also # the drag's first sample, which the tracking at the end reads. if isinstance(event, ScrollEvent): step = -self._item_height * 3 if event.scroll[1] > 0 else self._item_height * 3 return self._scroll_vertically(step) if not isinstance(event, PointerEvent): return None claimed = False # Scrollbar drag if self._needs_scrollbar and event.button in Pointer.PRIMARY: if event.phase is PointerPhase.DOWN: tx, ty, tw, th = self._scrollbar_thumb_rect() if tw > 0: px, py = self.to_local(event.position) if tx <= px <= tx + tw and ty <= py <= ty + th: self._dragging_scrollbar = True self._drag_start_y = py self._drag_start_scroll = self._scroll_offset self.capture_pointer() claimed = True elif event.phase in (PointerPhase.UP, PointerPhase.CANCEL) and self._dragging_scrollbar: # A cancel ends the thumb drag where a release would: the list # keeps where it was scrolled to and stops following. self._dragging_scrollbar = False self.release_pointer() claimed = True if self._dragging_scrollbar: py = self.to_local(event.position).y _, _, _, h = self._local_rect() thumb_h = self._thumb_height(h) track_h = h - thumb_h delta_px = py - self._drag_start_y if track_h > 0: self.scroll_to(self._drag_start_scroll + (delta_px / track_h) * self._max_scroll) return claimed
# ---------------------------------------------------------------- scrollbar geometry def _thumb_height(self, viewport_h: float) -> float: total = self.total_height if total <= 0: return viewport_h return max(20.0, viewport_h * (viewport_h / total)) def _scrollbar_thumb_rect(self) -> tuple[float, float, float, float]: """Return (x, y, w, h) of the scrollbar thumb in local space.""" if not self._needs_scrollbar: return (0, 0, 0, 0) x, y, w, h = self._local_rect() thumb_h = self._thumb_height(h) track_h = h - thumb_h ms = self._max_scroll scroll_ratio = self._scroll_offset / ms if ms > 0 else 0 thumb_y = y + scroll_ratio * track_h return (x + w - _SCROLLBAR_WIDTH - 2, thumb_y, _SCROLLBAR_WIDTH, thumb_h) # ---------------------------------------------------------------- rebuild / layout def _rebuild_visible(self): """Rebuild only the visible items, recycling off-screen controls.""" if self._rebuilding or not self._item_factory: return self._rebuilding = True try: first, last = self.visible_range content_w = self.size.x if self._needs_scrollbar: content_w -= _SCROLLBAR_WIDTH + 2 # Recycle items no longer visible: use Node.remove_child to avoid # Container._update_layout triggering re-entrant rebuild. for idx in list(self._visible_items): if idx < first or idx >= last: ctrl = self._visible_items.pop(idx) self._pool.append(ctrl) if ctrl.parent is self: Node.remove_child(self, ctrl) # Create/reuse items for newly visible indices for idx in range(first, last): if idx not in self._visible_items: recycled = self._pool.pop() if self._pool else None ctrl = self._item_factory(idx, recycled) self._visible_items[idx] = ctrl if ctrl.parent is not self: Node.add_child(self, ctrl) # Position all visible items for idx, ctrl in self._visible_items.items(): ctrl.position = Vec2(0, idx * self._item_height - self._scroll_offset) ctrl.size = Vec2(content_w, self._item_height) finally: self._rebuilding = False def _update_layout(self): """Reposition visible items on size change.""" self._rebuild_visible() # ---------------------------------------------------------------- draw def _child_clip_rect(self) -> tuple[float, float, float, float] | None: """The viewport the visible rows are drawn through, minus the scrollbar gutter. Derived rather than walked: the rows are drawn by whichever pipeline is walking the tree, so each is submitted exactly once and keeps its own retained items. Walking them here as well handed the item pipeline, which walks children itself, a second copy of every visible row. ``None`` when ``clip_contents`` is turned off, as on every Control. """ if not self.clip_contents: return None x, y, w, h = self.get_global_rect() gutter = _SCROLLBAR_WIDTH + 2 if self._needs_scrollbar else 0.0 # The rect is on the screen and the gutter is in this control's units. return (x, y, w - gutter * abs(float(self.world_scale[0])), h)
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() # Scrollbar if self._needs_scrollbar: # Track track_x = x + w - _SCROLLBAR_WIDTH - 2 renderer.rect((track_x, y), (_SCROLLBAR_WIDTH, h), fill=self.scrollbar_track_colour) # Thumb tx, ty, tw, th = self._scrollbar_thumb_rect() renderer.rect((tx, ty), (tw, th), fill=self.scrollbar_colour)