zcatgui/src/widgets/virtual_scroll.zig
reugenio 70fca5177b feat: zcatgui v0.13.0 - Phase 7 Visual Polish
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>
2025-12-09 13:49:50 +01:00

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
}