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)