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)