// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Interactive response data returned from VirtualList execution.
pub struct VirtualListResponse {
rect : @math.Rect
scroll_offset : Double
first_visible : Int
last_visible : Int
hovered : Bool
} derive(Eq, Debug)
///|
/// Standalone virtualized 1D list container rendering only items currently visible
/// within the viewable scissor bounds.
pub struct VirtualList {
id_salt : String
total_items : Int
height : Double
mut width_val : Double?
mut item_height_val : Double
mut overscan_val : Int
} derive(Eq, Debug)
///|
pub fn VirtualList::new(
id_salt : String,
total_items : Int,
height : Double,
) -> VirtualList {
{
id_salt,
total_items,
height,
width_val: None,
item_height_val: 28.0,
overscan_val: 2,
}
}
///|
pub fn VirtualList::item_height(self : VirtualList, h : Double) -> VirtualList {
let valid_h = if h < 4.0 { 4.0 } else { h }
self.item_height_val = valid_h
self
}
///|
pub fn VirtualList::width(self : VirtualList, w : Double) -> VirtualList {
self.width_val = Some(w)
self
}
///|
pub fn VirtualList::overscan(self : VirtualList, count : Int) -> VirtualList {
let valid_c = if count < 0 { 0 } else { count }
self.overscan_val = valid_c
self
}
///|
/// Computes visible slice bounds and executes the row rendering closure exclusively
/// for visible items within the scroll viewport.
pub fn VirtualList::render(
self : VirtualList,
ctx : UIContext,
item_renderer : (Int, @math.Rect) -> Unit,
) -> VirtualListResponse {
let scale = ctx.style.scale
let item_h = self.item_height_val * scale
let list_h = self.height * scale
let total_w = match self.width_val {
Some(w) => w * scale
None =>
if ctx.available_width() > 0.0 {
ctx.available_width()
} else {
300.0 * scale
}
}
let list_id = ctx.id_stack.derive_id(self.id_salt)
let (_id, container_rect, resp) = ctx.allocate_space(
@math.Vec2::new(total_w, list_h),
)
ctx.register_focusable(list_id)
let is_hovered = resp.hovered()
if is_hovered {
ctx.set_wants_capture_mouse(true)
}
if resp.clicked() || (is_hovered && ctx.input.mouse_pressed) {
ctx.request_focus(list_id)
}
let is_focused = resp.has_focus() || ctx.has_focus(list_id)
let total_content_h = self.total_items.to_double() * item_h
let max_scroll = if total_content_h > list_h {
total_content_h - list_h
} else {
0.0
}
let raw_scroll = ctx.get_scroll_offset(list_id)
let mut scroll_y = if raw_scroll.is_nan() { 0.0 } else { raw_scroll }
// Process mouse wheel scrolling
if is_hovered && max_scroll > 0.0 {
let dy = ctx.input.scroll_delta.y
if dy != 0.0 {
scroll_y = scroll_y + dy
}
}
// Keyboard navigation when focused
if is_focused && max_scroll > 0.0 {
if ctx.input.key_pressed(Key::ArrowUp) {
scroll_y = scroll_y - item_h
ctx.input.consume_key(Key::ArrowUp)
}
if ctx.input.key_pressed(Key::ArrowDown) {
scroll_y = scroll_y + item_h
ctx.input.consume_key(Key::ArrowDown)
}
if ctx.input.key_pressed(Key::PageUp) {
scroll_y = scroll_y - list_h
ctx.input.consume_key(Key::PageUp)
}
if ctx.input.key_pressed(Key::PageDown) {
scroll_y = scroll_y + list_h
ctx.input.consume_key(Key::PageDown)
}
if ctx.input.key_pressed(Key::Home) {
scroll_y = 0.0
ctx.input.consume_key(Key::Home)
}
if ctx.input.key_pressed(Key::End) {
scroll_y = max_scroll
ctx.input.consume_key(Key::End)
}
}
// Process scrollbar thumb dragging
let scrollbar_w = 5.0 * scale
let thumb_id = ctx.id_stack.derive_id(self.id_salt + "::thumb")
if max_scroll > 0.0 {
let track_x = container_rect.x +
container_rect.w -
scrollbar_w -
2.0 * scale
let min_thumb_h = 16.0 * scale
let thumb_h = if total_content_h > 0.0 {
let h_prop = list_h / total_content_h * list_h
if h_prop < min_thumb_h {
min_thumb_h
} else {
h_prop
}
} else {
min_thumb_h
}
let track_travel = list_h - thumb_h
let track_rect = @math.Rect::new(
track_x - 2.0 * scale,
container_rect.y,
scrollbar_w + 4.0 * scale,
list_h,
)
let current_pct = if max_scroll > 0.0001 {
scroll_y / max_scroll
} else {
0.0
}
let thumb_y = container_rect.y + current_pct * track_travel
let thumb_rect = @math.Rect::new(track_x, thumb_y, scrollbar_w, thumb_h)
let is_thumb_hovered = ctx.is_hovered(thumb_rect)
let is_track_hovered = ctx.is_hovered(track_rect)
if is_thumb_hovered || is_track_hovered || ctx.active_id() == thumb_id {
ctx.set_cursor_icon("pointer")
}
if (is_thumb_hovered || is_track_hovered) && ctx.input.mouse_pressed {
ctx.set_active_id(thumb_id)
}
if ctx.active_id() == thumb_id {
ctx.set_wants_capture_mouse(true)
if ctx.input.mouse_down && track_travel > 0.0 {
let rel_mouse_y = ctx.input.mouse_pos.y -
container_rect.y -
thumb_h * 0.5
let mut new_pct = rel_mouse_y / track_travel
if new_pct < 0.0 {
new_pct = 0.0
}
if new_pct > 1.0 {
new_pct = 1.0
}
scroll_y = new_pct * max_scroll
}
if ctx.input.mouse_released {
ctx.set_active_id(Id::zero())
}
}
}
// Clamp scroll offset within valid boundaries
if scroll_y.is_nan() || scroll_y < 0.0 {
scroll_y = 0.0
} else if scroll_y > max_scroll {
scroll_y = max_scroll
}
ctx.set_scroll_offset(list_id, scroll_y)
// Compute visible item window index range
let (first_idx, last_idx) = if self.total_items <= 0 {
(0, -1)
} else {
let raw_first = if item_h > 0.0 {
(scroll_y / item_h).to_int() - self.overscan_val
} else {
0
}
let first = if raw_first < 0 { 0 } else { raw_first }
let raw_last = if item_h > 0.0 {
((scroll_y + list_h) / item_h).to_int() + self.overscan_val
} else {
self.total_items - 1
}
let last = if raw_last >= self.total_items {
self.total_items - 1
} else {
raw_last
}
(first, last)
}
let content_w = if max_scroll > 0.0 {
container_rect.w - scrollbar_w - 2.0 * scale
} else {
container_rect.w
}
// Scoped scissor clipping around container
ctx.push_clip(container_rect)
if first_idx <= last_idx {
for i = first_idx; i <= last_idx; i = i + 1 {
let item_y = container_rect.y + i.to_double() * item_h - scroll_y
let item_rect = @math.Rect::new(
container_rect.x,
item_y,
content_w,
item_h,
)
item_renderer(i, item_rect)
}
}
ctx.pop_clip()
// Draw vertical scrollbar indicator if overflowing
if max_scroll > 0.0 {
let track_x = container_rect.x +
container_rect.w -
scrollbar_w -
2.0 * scale
let min_thumb_h = 16.0 * scale
let thumb_h = if total_content_h > 0.0 {
let h_prop = list_h / total_content_h * list_h
if h_prop < min_thumb_h {
min_thumb_h
} else {
h_prop
}
} else {
min_thumb_h
}
let track_travel = list_h - thumb_h
let current_pct = if max_scroll > 0.0001 {
scroll_y / max_scroll
} else {
0.0
}
let thumb_y = container_rect.y + current_pct * track_travel
let thumb_rect = @math.Rect::new(track_x, thumb_y, scrollbar_w, thumb_h)
let theme = ctx.theme
let is_thumb_hovered = ctx.is_hovered(thumb_rect)
let thumb_color = if ctx.active_id() == thumb_id || is_thumb_hovered {
theme.accent_primary
} else {
theme.border_default
}
let p = ctx.painter()
let radius = scrollbar_w * 0.5
p.add_rect(thumb_rect, thumb_color, radius)
}
// Draw container focus ring if focused
if is_focused {
let theme = ctx.theme
let focus_radius = ctx.style.radius_sm * scale
ctx
.painter()
.add_rect_stroke(
container_rect.expand(2.0 * scale),
theme.border_focus,
1.5 * scale,
focus_radius + 2.0 * scale,
)
}
{
rect: container_rect,
scroll_offset: scroll_y,
first_visible: first_idx,
last_visible: last_idx,
hovered: is_hovered,
}
}
///|
/// Convenience one-liner to render a virtual list with default metrics.
pub fn virtual_list(
ctx : UIContext,
id_salt : String,
total_items : Int,
height : Double,
item_height? : Double = 28.0,
item_renderer : (Int, @math.Rect) -> Unit,
) -> VirtualListResponse {
let list = VirtualList::new(id_salt, total_items, height).item_height(
item_height,
)
list.render(ctx, item_renderer)
}