//! 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 (Z-Design V2: 14px para mejor visibilidad) scrollbar_width: u16 = 14, /// 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 }