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.
"""


from __future__ import annotations

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

from ..node import Node
from ..descriptors import Signal
from ..math.types import Vec2
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 _draws_children = True 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() self.size = Vec2(200, 200)
[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()
@property def total_height(self) -> float: """Total content height based on item count and row height.""" return self._item_count * self._item_height @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 _on_gui_input(self, event): if event.key == "scroll_up": self.scroll_to(self._scroll_offset - self._item_height * 3) elif event.key == "scroll_down": self.scroll_to(self._scroll_offset + self._item_height * 3) # Scrollbar drag if self._needs_scrollbar and event.button == 1: if event.pressed: tx, ty, tw, th = self._scrollbar_thumb_rect() if tw > 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 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() elif not event.pressed and self._dragging_scrollbar: self._dragging_scrollbar = False self.release_mouse() if self._dragging_scrollbar and event.position: py = event.position.y if hasattr(event.position, "y") else event.position[1] _, _, _, 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
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() # Clip children to container bounds (leave room for scrollbar) clip_w = w - (_SCROLLBAR_WIDTH + 2 if self._needs_scrollbar else 0) renderer.push_clip(x, y, clip_w, h) for child in self.children: if isinstance(child, Control): child._draw_recursive(renderer) renderer.pop_clip() # Scrollbar if self._needs_scrollbar: # Track track_x = x + w - _SCROLLBAR_WIDTH - 2 renderer.draw_filled_rect(track_x, y, _SCROLLBAR_WIDTH, h, self.scrollbar_track_colour) # Thumb tx, ty, tw, th = self._scrollbar_thumb_rect() renderer.draw_filled_rect(tx, ty, tw, th, self.scrollbar_colour)