Animation System: - Easing functions: linear, quad, cubic, quartic, sine, expo, elastic, bounce, back (in/out/inout variants) - Animation struct with start/stop/getValue/isComplete - AnimationManager for concurrent animations - lerp/lerpInt interpolation helpers Visual Effects: - Shadow: soft/hard presets, offset, blur, spread - Gradient: horizontal, vertical, diagonal, radial - Blur: box blur with configurable radius - Color utilities: interpolateColor, applyOpacity, highlight, lowlight Virtual Scrolling: - VirtualScrollState for large list management - Variable item height support - Scrollbar with drag support - Overscan for smooth scrolling - ensureVisible/scrollToItem helpers Anti-Aliased Rendering: - drawLineAA: Xiaolin Wu's algorithm - drawCircleAA: filled and stroke - drawRoundedRectAA: rounded corners - drawEllipseAA: arbitrary ellipses - drawPolygonAA: polygon outlines - Quality levels: none, low, medium, high, ultra Widget count: 35 widgets Test count: 256 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
496 lines
16 KiB
Zig
496 lines
16 KiB
Zig
//! Virtual Scrolling
|
|
//!
|
|
//! Efficiently renders large lists by only rendering visible items.
|
|
//! Supports variable item heights and smooth scrolling.
|
|
|
|
const std = @import("std");
|
|
const Context = @import("../core/context.zig").Context;
|
|
const Layout = @import("../core/layout.zig");
|
|
const Style = @import("../core/style.zig");
|
|
const Input = @import("../core/input.zig");
|
|
|
|
/// Maximum cached item heights
|
|
const MAX_CACHED_HEIGHTS = 10000;
|
|
|
|
/// Virtual scroll state
|
|
pub const VirtualScrollState = struct {
|
|
/// Scroll offset in pixels
|
|
scroll_offset: i32 = 0,
|
|
/// Total content height
|
|
total_height: u32 = 0,
|
|
/// First visible item index
|
|
first_visible: usize = 0,
|
|
/// Last visible item index
|
|
last_visible: usize = 0,
|
|
/// Number of items
|
|
item_count: usize = 0,
|
|
/// Cached item heights (for variable height)
|
|
item_heights: [MAX_CACHED_HEIGHTS]u16 = [_]u16{0} ** MAX_CACHED_HEIGHTS,
|
|
/// Default item height
|
|
default_height: u16 = 24,
|
|
/// Is dragging scrollbar
|
|
dragging_scrollbar: bool = false,
|
|
/// Drag start offset
|
|
drag_start_y: i32 = 0,
|
|
/// Drag start scroll
|
|
drag_start_scroll: i32 = 0,
|
|
|
|
const Self = @This();
|
|
|
|
/// Initialize state
|
|
pub fn init(item_count: usize, default_height: u16) Self {
|
|
return .{
|
|
.item_count = item_count,
|
|
.default_height = default_height,
|
|
};
|
|
}
|
|
|
|
/// Set item count
|
|
pub fn setItemCount(self: *Self, count: usize) void {
|
|
self.item_count = count;
|
|
self.recalculateTotalHeight();
|
|
}
|
|
|
|
/// Set height for a specific item
|
|
pub fn setItemHeight(self: *Self, index: usize, height: u16) void {
|
|
if (index < MAX_CACHED_HEIGHTS) {
|
|
self.item_heights[index] = height;
|
|
self.recalculateTotalHeight();
|
|
}
|
|
}
|
|
|
|
/// Get height for a specific item
|
|
pub fn getItemHeight(self: Self, index: usize) u16 {
|
|
if (index < MAX_CACHED_HEIGHTS and self.item_heights[index] > 0) {
|
|
return self.item_heights[index];
|
|
}
|
|
return self.default_height;
|
|
}
|
|
|
|
/// Recalculate total content height
|
|
pub fn recalculateTotalHeight(self: *Self) void {
|
|
var total: u32 = 0;
|
|
var i: usize = 0;
|
|
while (i < self.item_count) : (i += 1) {
|
|
total += self.getItemHeight(i);
|
|
}
|
|
self.total_height = total;
|
|
}
|
|
|
|
/// Scroll to a specific item
|
|
pub fn scrollToItem(self: *Self, index: usize, viewport_height: u32) void {
|
|
if (index >= self.item_count) return;
|
|
|
|
// Calculate item offset
|
|
var offset: i32 = 0;
|
|
var i: usize = 0;
|
|
while (i < index) : (i += 1) {
|
|
offset += self.getItemHeight(i);
|
|
}
|
|
|
|
// Center item in viewport
|
|
const item_height = self.getItemHeight(index);
|
|
const center_offset = offset - @as(i32, @intCast(viewport_height / 2)) + @divTrunc(@as(i32, item_height), 2);
|
|
self.scroll_offset = @max(0, center_offset);
|
|
}
|
|
|
|
/// Ensure item is visible
|
|
pub fn ensureVisible(self: *Self, index: usize, viewport_height: u32) void {
|
|
if (index >= self.item_count) return;
|
|
|
|
// Calculate item bounds
|
|
var item_top: i32 = 0;
|
|
var i: usize = 0;
|
|
while (i < index) : (i += 1) {
|
|
item_top += self.getItemHeight(i);
|
|
}
|
|
const item_bottom = item_top + @as(i32, self.getItemHeight(index));
|
|
|
|
// Check if scrolling needed
|
|
if (item_top < self.scroll_offset) {
|
|
self.scroll_offset = item_top;
|
|
} else if (item_bottom > self.scroll_offset + @as(i32, @intCast(viewport_height))) {
|
|
self.scroll_offset = item_bottom - @as(i32, @intCast(viewport_height));
|
|
}
|
|
}
|
|
|
|
/// Get offset for a specific item
|
|
pub fn getItemOffset(self: Self, index: usize) i32 {
|
|
var offset: i32 = 0;
|
|
var i: usize = 0;
|
|
while (i < index and i < self.item_count) : (i += 1) {
|
|
offset += self.getItemHeight(i);
|
|
}
|
|
return offset;
|
|
}
|
|
};
|
|
|
|
/// Virtual scroll configuration
|
|
pub const VirtualScrollConfig = struct {
|
|
/// Show scrollbar
|
|
show_scrollbar: bool = true,
|
|
/// Scrollbar width
|
|
scrollbar_width: u16 = 12,
|
|
/// Overscan (render extra items above/below viewport)
|
|
overscan: u16 = 2,
|
|
/// Enable smooth scrolling
|
|
smooth_scroll: bool = true,
|
|
/// Scroll speed (pixels per wheel tick)
|
|
scroll_speed: u16 = 48,
|
|
/// Minimum thumb size
|
|
min_thumb_size: u16 = 20,
|
|
};
|
|
|
|
/// Virtual scroll colors
|
|
pub const VirtualScrollColors = struct {
|
|
background: Style.Color = Style.Color.rgba(30, 30, 30, 255),
|
|
scrollbar_track: Style.Color = Style.Color.rgba(50, 50, 50, 255),
|
|
scrollbar_thumb: Style.Color = Style.Color.rgba(100, 100, 100, 255),
|
|
scrollbar_thumb_hover: Style.Color = Style.Color.rgba(130, 130, 130, 255),
|
|
scrollbar_thumb_active: Style.Color = Style.Color.rgba(160, 160, 160, 255),
|
|
};
|
|
|
|
/// Result from virtual scroll widget
|
|
pub const VirtualScrollResult = struct {
|
|
/// First visible item index
|
|
first_visible: usize,
|
|
/// Last visible item index (exclusive)
|
|
last_visible: usize,
|
|
/// Content area rect (excluding scrollbar)
|
|
content_rect: Layout.Rect,
|
|
/// Did scroll position change
|
|
scrolled: bool,
|
|
/// Is scrollbar being dragged
|
|
dragging: bool,
|
|
};
|
|
|
|
/// Item callback for rendering
|
|
pub const ItemRenderer = *const fn (
|
|
ctx: *Context,
|
|
index: usize,
|
|
rect: Layout.Rect,
|
|
user_data: ?*anyopaque,
|
|
) void;
|
|
|
|
/// Render a virtual scroll area
|
|
pub fn virtualScroll(
|
|
ctx: *Context,
|
|
state: *VirtualScrollState,
|
|
rect: Layout.Rect,
|
|
) VirtualScrollResult {
|
|
return virtualScrollEx(ctx, state, rect, .{}, .{});
|
|
}
|
|
|
|
/// Render a virtual scroll area with configuration
|
|
pub fn virtualScrollEx(
|
|
ctx: *Context,
|
|
state: *VirtualScrollState,
|
|
rect: Layout.Rect,
|
|
config: VirtualScrollConfig,
|
|
colors: VirtualScrollColors,
|
|
) VirtualScrollResult {
|
|
if (rect.isEmpty()) {
|
|
return .{
|
|
.first_visible = 0,
|
|
.last_visible = 0,
|
|
.content_rect = rect,
|
|
.scrolled = false,
|
|
.dragging = false,
|
|
};
|
|
}
|
|
|
|
const scrollbar_width: u32 = if (config.show_scrollbar and state.total_height > rect.h)
|
|
@as(u32, config.scrollbar_width)
|
|
else
|
|
0;
|
|
|
|
const content_rect = Layout.Rect{
|
|
.x = rect.x,
|
|
.y = rect.y,
|
|
.w = rect.w -| scrollbar_width,
|
|
.h = rect.h,
|
|
};
|
|
|
|
// Draw background
|
|
ctx.pushCommand(.{ .fill_rect = .{
|
|
.rect = rect,
|
|
.color = colors.background,
|
|
} });
|
|
|
|
// Handle input
|
|
var scrolled = false;
|
|
const input = ctx.getInput();
|
|
|
|
// Mouse wheel scrolling
|
|
if (rect.contains(input.mouse_x, input.mouse_y)) {
|
|
if (input.scroll_y != 0) {
|
|
const scroll_delta = -input.scroll_y * @as(i32, config.scroll_speed);
|
|
const old_offset = state.scroll_offset;
|
|
state.scroll_offset = @max(0, @min(
|
|
@as(i32, @intCast(state.total_height)) - @as(i32, @intCast(rect.h)),
|
|
state.scroll_offset + scroll_delta,
|
|
));
|
|
scrolled = old_offset != state.scroll_offset;
|
|
}
|
|
}
|
|
|
|
// Scrollbar handling
|
|
var dragging = state.dragging_scrollbar;
|
|
if (scrollbar_width > 0) {
|
|
const scrollbar_rect = Layout.Rect{
|
|
.x = rect.x + @as(i32, @intCast(rect.w - scrollbar_width)),
|
|
.y = rect.y,
|
|
.w = scrollbar_width,
|
|
.h = rect.h,
|
|
};
|
|
|
|
// Draw scrollbar track
|
|
ctx.pushCommand(.{ .fill_rect = .{
|
|
.rect = scrollbar_rect,
|
|
.color = colors.scrollbar_track,
|
|
} });
|
|
|
|
// Calculate thumb
|
|
if (state.total_height > rect.h) {
|
|
const visible_ratio = @as(f32, @floatFromInt(rect.h)) / @as(f32, @floatFromInt(state.total_height));
|
|
const thumb_height = @max(
|
|
config.min_thumb_size,
|
|
@as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(rect.h)))),
|
|
);
|
|
|
|
const scroll_range = state.total_height - rect.h;
|
|
const thumb_range = rect.h - thumb_height;
|
|
const thumb_pos = if (scroll_range > 0)
|
|
@as(u32, @intFromFloat(
|
|
@as(f32, @floatFromInt(state.scroll_offset)) /
|
|
@as(f32, @floatFromInt(scroll_range)) *
|
|
@as(f32, @floatFromInt(thumb_range)),
|
|
))
|
|
else
|
|
0;
|
|
|
|
const thumb_rect = Layout.Rect{
|
|
.x = scrollbar_rect.x + 2,
|
|
.y = scrollbar_rect.y + @as(i32, @intCast(thumb_pos)),
|
|
.w = scrollbar_width - 4,
|
|
.h = thumb_height,
|
|
};
|
|
|
|
// Handle scrollbar dragging
|
|
const thumb_hovered = thumb_rect.contains(input.mouse_x, input.mouse_y);
|
|
|
|
if (state.dragging_scrollbar) {
|
|
if (input.mouse_down) {
|
|
const delta = input.mouse_y - state.drag_start_y;
|
|
const scroll_delta = if (thumb_range > 0)
|
|
@as(i32, @intFromFloat(
|
|
@as(f32, @floatFromInt(delta)) /
|
|
@as(f32, @floatFromInt(thumb_range)) *
|
|
@as(f32, @floatFromInt(scroll_range)),
|
|
))
|
|
else
|
|
0;
|
|
const old_offset = state.scroll_offset;
|
|
state.scroll_offset = @max(0, @min(
|
|
@as(i32, @intCast(scroll_range)),
|
|
state.drag_start_scroll + scroll_delta,
|
|
));
|
|
scrolled = old_offset != state.scroll_offset;
|
|
} else {
|
|
state.dragging_scrollbar = false;
|
|
dragging = false;
|
|
}
|
|
} else if (thumb_hovered and input.mouse_pressed) {
|
|
state.dragging_scrollbar = true;
|
|
state.drag_start_y = input.mouse_y;
|
|
state.drag_start_scroll = state.scroll_offset;
|
|
dragging = true;
|
|
}
|
|
|
|
// Draw thumb
|
|
const thumb_color = if (state.dragging_scrollbar)
|
|
colors.scrollbar_thumb_active
|
|
else if (thumb_hovered)
|
|
colors.scrollbar_thumb_hover
|
|
else
|
|
colors.scrollbar_thumb;
|
|
|
|
ctx.pushCommand(.{ .fill_rect = .{
|
|
.rect = thumb_rect,
|
|
.color = thumb_color,
|
|
} });
|
|
}
|
|
}
|
|
|
|
// Calculate visible range
|
|
const viewport_height = rect.h;
|
|
var first_visible: usize = 0;
|
|
var last_visible: usize = 0;
|
|
var accumulated_height: i32 = 0;
|
|
|
|
// Find first visible
|
|
var i: usize = 0;
|
|
while (i < state.item_count) : (i += 1) {
|
|
const item_height = state.getItemHeight(i);
|
|
if (accumulated_height + @as(i32, item_height) > state.scroll_offset) {
|
|
first_visible = i;
|
|
break;
|
|
}
|
|
accumulated_height += item_height;
|
|
}
|
|
|
|
// Apply overscan
|
|
if (first_visible > config.overscan) {
|
|
first_visible -= config.overscan;
|
|
} else {
|
|
first_visible = 0;
|
|
}
|
|
|
|
// Find last visible
|
|
accumulated_height = 0;
|
|
i = 0;
|
|
while (i < first_visible) : (i += 1) {
|
|
accumulated_height += state.getItemHeight(i);
|
|
}
|
|
|
|
i = first_visible;
|
|
while (i < state.item_count) : (i += 1) {
|
|
if (accumulated_height > state.scroll_offset + @as(i32, @intCast(viewport_height))) {
|
|
break;
|
|
}
|
|
accumulated_height += state.getItemHeight(i);
|
|
last_visible = i + 1;
|
|
}
|
|
|
|
// Apply overscan
|
|
last_visible = @min(state.item_count, last_visible + config.overscan);
|
|
|
|
// Update state
|
|
state.first_visible = first_visible;
|
|
state.last_visible = last_visible;
|
|
|
|
return .{
|
|
.first_visible = first_visible,
|
|
.last_visible = last_visible,
|
|
.content_rect = content_rect,
|
|
.scrolled = scrolled,
|
|
.dragging = dragging,
|
|
};
|
|
}
|
|
|
|
/// Get the rect for a specific item
|
|
pub fn getItemRect(
|
|
state: *const VirtualScrollState,
|
|
content_rect: Layout.Rect,
|
|
index: usize,
|
|
) Layout.Rect {
|
|
if (index >= state.item_count) {
|
|
return Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 };
|
|
}
|
|
|
|
const item_offset = state.getItemOffset(index);
|
|
const item_height = state.getItemHeight(index);
|
|
|
|
return Layout.Rect{
|
|
.x = content_rect.x,
|
|
.y = content_rect.y + item_offset - state.scroll_offset,
|
|
.w = content_rect.w,
|
|
.h = item_height,
|
|
};
|
|
}
|
|
|
|
/// Render visible items using a callback
|
|
pub fn renderItems(
|
|
ctx: *Context,
|
|
state: *const VirtualScrollState,
|
|
result: VirtualScrollResult,
|
|
renderer: ItemRenderer,
|
|
user_data: ?*anyopaque,
|
|
) void {
|
|
var i: usize = result.first_visible;
|
|
while (i < result.last_visible) : (i += 1) {
|
|
const item_rect = getItemRect(state, result.content_rect, i);
|
|
renderer(ctx, i, item_rect, user_data);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "VirtualScrollState init" {
|
|
const state = VirtualScrollState.init(100, 24);
|
|
try std.testing.expectEqual(@as(usize, 100), state.item_count);
|
|
try std.testing.expectEqual(@as(u16, 24), state.default_height);
|
|
}
|
|
|
|
test "VirtualScrollState item heights" {
|
|
var state = VirtualScrollState.init(10, 24);
|
|
|
|
// Default height
|
|
try std.testing.expectEqual(@as(u16, 24), state.getItemHeight(0));
|
|
|
|
// Custom height
|
|
state.setItemHeight(5, 48);
|
|
try std.testing.expectEqual(@as(u16, 48), state.getItemHeight(5));
|
|
}
|
|
|
|
test "VirtualScrollState total height" {
|
|
var state = VirtualScrollState.init(10, 20);
|
|
state.recalculateTotalHeight();
|
|
|
|
// 10 items * 20 pixels = 200
|
|
try std.testing.expectEqual(@as(u32, 200), state.total_height);
|
|
|
|
// Set custom height
|
|
state.setItemHeight(0, 40); // 40 instead of 20
|
|
try std.testing.expectEqual(@as(u32, 220), state.total_height);
|
|
}
|
|
|
|
test "VirtualScrollState item offset" {
|
|
var state = VirtualScrollState.init(5, 30);
|
|
state.recalculateTotalHeight();
|
|
|
|
try std.testing.expectEqual(@as(i32, 0), state.getItemOffset(0));
|
|
try std.testing.expectEqual(@as(i32, 30), state.getItemOffset(1));
|
|
try std.testing.expectEqual(@as(i32, 60), state.getItemOffset(2));
|
|
try std.testing.expectEqual(@as(i32, 120), state.getItemOffset(4));
|
|
}
|
|
|
|
test "VirtualScrollState ensure visible" {
|
|
var state = VirtualScrollState.init(100, 20);
|
|
state.recalculateTotalHeight();
|
|
|
|
// Item at position 50*20 = 1000
|
|
state.ensureVisible(50, 200);
|
|
// Should scroll so item 50 is visible
|
|
try std.testing.expect(state.scroll_offset >= 800);
|
|
try std.testing.expect(state.scroll_offset <= 1000);
|
|
}
|
|
|
|
test "getItemRect" {
|
|
var state = VirtualScrollState.init(10, 30);
|
|
state.scroll_offset = 0;
|
|
|
|
const content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 200, .h = 100 };
|
|
|
|
const rect0 = getItemRect(&state, content_rect, 0);
|
|
try std.testing.expectEqual(@as(i32, 0), rect0.y);
|
|
try std.testing.expectEqual(@as(u32, 30), rect0.h);
|
|
|
|
const rect2 = getItemRect(&state, content_rect, 2);
|
|
try std.testing.expectEqual(@as(i32, 60), rect2.y);
|
|
}
|
|
|
|
test "getItemRect with scroll offset" {
|
|
var state = VirtualScrollState.init(10, 30);
|
|
state.scroll_offset = 45; // Scroll past 1.5 items
|
|
|
|
const content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 200, .h = 100 };
|
|
|
|
const rect0 = getItemRect(&state, content_rect, 0);
|
|
try std.testing.expectEqual(@as(i32, -45), rect0.y);
|
|
|
|
const rect2 = getItemRect(&state, content_rect, 2);
|
|
try std.testing.expectEqual(@as(i32, 15), rect2.y); // 60 - 45
|
|
}
|