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.enums import MouseButton
from ..math.types import Vec2
from ..node import Node
from ..signals import Signal
from .containers import Container
from .core import Control, ThemeColour

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) """ _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 def _on_gui_input(self, event): if event.key == "scroll_up": if self._scroll_vertically(-self._item_height * 3): event.handled = True elif event.key == "scroll_down": if self._scroll_vertically(self._item_height * 3): event.handled = True # Scrollbar drag if self._needs_scrollbar and event.button == MouseButton.LEFT: if event.pressed: tx, ty, tw, th = self._scrollbar_thumb_rect() if tw > 0: px = event.position.x py = event.position.y 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.grab_mouse() event.handled = True elif not event.pressed and 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_global_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) # ---------------------------------------------------------------- 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 screen space.""" if not self._needs_scrollbar: return (0, 0, 0, 0) x, y, w, h = self.get_global_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 return (x, y, w - gutter, h)
[docs] def on_draw(self, renderer): x, y, w, h = self.get_global_rect() # Scrollbar if self._needs_scrollbar: # Track track_x = x + w - _SCROLLBAR_WIDTH - 2 renderer.draw_rect((track_x, y), (_SCROLLBAR_WIDTH, h), colour=self.scrollbar_track_colour, filled=True) # Thumb tx, ty, tw, th = self._scrollbar_thumb_rect() renderer.draw_rect((tx, ty), (tw, th), colour=self.scrollbar_colour, filled=True)