feat: Add lazy rendering and scrollable containers

Lazy rendering (src/lazy.zig):
- RenderCache: Cache rendered content with TTL
- RenderTask: Background thread-based rendering
- Throttle: Rate-limit render updates
- Debounce: Delay processing until quiet period
- DeferredRender: First-access rendering

Scrollable containers (src/widgets/scroll.zig):
- ScrollView: Basic scrollable container
- ScrollState: Scroll position management
- VirtualList: Efficient virtual scrolling for large datasets
- InfiniteScroll: Load-more-on-demand container

Note: Zig 0.15's new io.Async is still in development.
Thread-based implementation provided for compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 17:38:47 +01:00
parent 5ac74ebff5
commit 04e18ff0c1
3 changed files with 1246 additions and 0 deletions

509
src/lazy.zig Normal file
View file

@ -0,0 +1,509 @@
//! Lazy rendering and async utilities for zcatui.
//!
//! Provides infrastructure for deferred/background rendering operations:
//! - LazyWidget: Widget that renders content on-demand
//! - RenderTask: Background rendering task
//! - RenderCache: Caches rendered content
//!
//! ## Example
//!
//! ```zig
//! // Create a lazy widget that loads content asynchronously
//! var lazy = LazyWidget.init(allocator)
//! .setLoader(loadExpensiveContent)
//! .setPlaceholder("Loading...");
//!
//! // In render loop
//! lazy.render(area, buf);
//!
//! // Poll for completion
//! if (lazy.poll()) {
//! // Content is ready
//! }
//! ```
//!
//! ## Design Notes
//!
//! Zig 0.15 has a new async I/O design in progress (see Loris Cro's blog).
//! This module provides a thread-based implementation that will be
//! compatible with the upcoming io.Async interface.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Thread = std.Thread;
const Mutex = Thread.Mutex;
const buffer_mod = @import("buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const Cell = buffer_mod.Cell;
const style_mod = @import("style.zig");
const Style = style_mod.Style;
// ============================================================================
// RenderCache
// ============================================================================
/// Cached render buffer for avoiding re-renders.
pub const RenderCache = struct {
/// Cached cells.
cells: []Cell,
/// Area the cache was rendered for.
area: Rect,
/// Whether cache is valid.
valid: bool = false,
/// Timestamp of last update.
timestamp: i64 = 0,
/// Allocator.
allocator: Allocator,
/// Creates a new render cache.
pub fn init(allocator: Allocator) RenderCache {
return .{
.cells = &.{},
.area = Rect.init(0, 0, 0, 0),
.allocator = allocator,
};
}
/// Frees resources.
pub fn deinit(self: *RenderCache) void {
if (self.cells.len > 0) {
self.allocator.free(self.cells);
}
}
/// Ensures cache has capacity for the given area.
pub fn ensureCapacity(self: *RenderCache, area: Rect) !void {
const size = @as(usize, area.width) * @as(usize, area.height);
if (self.cells.len < size) {
if (self.cells.len > 0) {
self.allocator.free(self.cells);
}
self.cells = try self.allocator.alloc(Cell, size);
}
self.area = area;
}
/// Captures current buffer state to cache.
pub fn capture(self: *RenderCache, buf: *Buffer, area: Rect) !void {
try self.ensureCapacity(area);
var i: usize = 0;
var y = area.top();
while (y < area.bottom()) : (y += 1) {
var x = area.left();
while (x < area.right()) : (x += 1) {
if (buf.getCell(x, y)) |cell| {
self.cells[i] = cell.*;
}
i += 1;
}
}
self.valid = true;
self.timestamp = std.time.milliTimestamp();
}
/// Restores cached content to buffer.
pub fn restore(self: *RenderCache, buf: *Buffer, area: Rect) void {
if (!self.valid) return;
var i: usize = 0;
var y = area.top();
while (y < area.bottom()) : (y += 1) {
var x = area.left();
while (x < area.right()) : (x += 1) {
if (buf.getCell(x, y)) |cell| {
if (i < self.cells.len) {
cell.* = self.cells[i];
}
}
i += 1;
}
}
}
/// Invalidates the cache.
pub fn invalidate(self: *RenderCache) void {
self.valid = false;
}
/// Returns whether cache is still valid within TTL.
pub fn isValidWithTtl(self: *RenderCache, ttl_ms: i64) bool {
if (!self.valid) return false;
const now = std.time.milliTimestamp();
return (now - self.timestamp) < ttl_ms;
}
};
// ============================================================================
// RenderTask
// ============================================================================
/// Status of a render task.
pub const TaskStatus = enum {
pending,
running,
completed,
failed,
cancelled,
};
/// A background render task.
pub fn RenderTask(comptime Context: type, comptime Result: type) type {
return struct {
const Self = @This();
/// Task context.
context: Context,
/// Task function.
task_fn: *const fn (Context) Result,
/// Result (valid when completed).
result: ?Result = null,
/// Error (valid when failed).
err: ?anyerror = null,
/// Current status.
status: TaskStatus = .pending,
/// Thread handle.
thread: ?Thread = null,
/// Mutex for status/result access.
mutex: Mutex = .{},
/// Creates a new task.
pub fn init(context: Context, task_fn: *const fn (Context) Result) Self {
return .{
.context = context,
.task_fn = task_fn,
};
}
/// Starts the task in background.
pub fn start(self: *Self) !void {
self.mutex.lock();
defer self.mutex.unlock();
if (self.status != .pending) return;
self.status = .running;
self.thread = try Thread.spawn(.{}, Self.runTask, .{self});
}
/// Task runner (executed in background thread).
fn runTask(self: *Self) void {
const result = self.task_fn(self.context);
self.mutex.lock();
defer self.mutex.unlock();
self.result = result;
self.status = .completed;
}
/// Polls for completion (non-blocking).
pub fn poll(self: *Self) bool {
self.mutex.lock();
defer self.mutex.unlock();
return self.status == .completed or self.status == .failed;
}
/// Waits for completion (blocking).
pub fn wait(self: *Self) ?Result {
if (self.thread) |t| {
t.join();
self.thread = null;
}
return self.result;
}
/// Cancels the task (best effort).
pub fn cancel(self: *Self) void {
self.mutex.lock();
defer self.mutex.unlock();
if (self.status == .pending) {
self.status = .cancelled;
}
}
/// Returns the result if completed.
pub fn getResult(self: *Self) ?Result {
self.mutex.lock();
defer self.mutex.unlock();
return self.result;
}
};
}
// ============================================================================
// LazyContent
// ============================================================================
/// Content that can be loaded lazily.
pub const LazyContent = struct {
/// Loaded content lines.
lines: [][]const u8 = &.{},
/// Whether content is loaded.
loaded: bool = false,
/// Allocator.
allocator: Allocator,
/// Creates empty lazy content.
pub fn init(allocator: Allocator) LazyContent {
return .{
.allocator = allocator,
};
}
/// Frees resources.
pub fn deinit(self: *LazyContent) void {
if (self.lines.len > 0) {
for (self.lines) |line| {
self.allocator.free(line);
}
self.allocator.free(self.lines);
}
}
/// Sets content from string slice.
pub fn setLines(self: *LazyContent, lines: []const []const u8) !void {
// Free existing
if (self.lines.len > 0) {
for (self.lines) |line| {
self.allocator.free(line);
}
self.allocator.free(self.lines);
}
// Copy new lines
self.lines = try self.allocator.alloc([]const u8, lines.len);
for (lines, 0..) |line, i| {
self.lines[i] = try self.allocator.dupe(u8, line);
}
self.loaded = true;
}
};
// ============================================================================
// DeferredRender
// ============================================================================
/// Deferred rendering context - renders on first access.
pub const DeferredRender = struct {
/// Render function.
render_fn: ?*const fn (*DeferredRender, Rect, *Buffer) void = null,
/// User context.
user_data: ?*anyopaque = null,
/// Whether first render has occurred.
rendered: bool = false,
/// Cache for rendered content.
cache: ?*RenderCache = null,
/// Creates a deferred render.
pub fn init() DeferredRender {
return .{};
}
/// Sets the render function.
pub fn setRenderFn(self: *DeferredRender, render_fn: *const fn (*DeferredRender, Rect, *Buffer) void) *DeferredRender {
self.render_fn = render_fn;
return self;
}
/// Sets user data.
pub fn setUserData(self: *DeferredRender, data: *anyopaque) *DeferredRender {
self.user_data = data;
return self;
}
/// Renders content (caches if cache is set).
pub fn render(self: *DeferredRender, area: Rect, buf: *Buffer) void {
// Use cache if valid
if (self.cache) |cache| {
if (cache.valid and cache.area.width == area.width and cache.area.height == area.height) {
cache.restore(buf, area);
return;
}
}
// Render
if (self.render_fn) |render_fn| {
render_fn(self, area, buf);
self.rendered = true;
// Update cache
if (self.cache) |cache| {
cache.capture(buf, area) catch {};
}
}
}
/// Invalidates cached content.
pub fn invalidate(self: *DeferredRender) void {
self.rendered = false;
if (self.cache) |cache| {
cache.invalidate();
}
}
};
// ============================================================================
// Throttle
// ============================================================================
/// Throttles render updates to a maximum rate.
pub const Throttle = struct {
/// Minimum interval between updates (ms).
interval_ms: i64,
/// Last update timestamp.
last_update: i64 = 0,
/// Creates a throttle with given interval.
pub fn init(interval_ms: i64) Throttle {
return .{
.interval_ms = interval_ms,
};
}
/// Checks if an update should proceed.
pub fn shouldUpdate(self: *Throttle) bool {
const now = std.time.milliTimestamp();
if (now - self.last_update >= self.interval_ms) {
self.last_update = now;
return true;
}
return false;
}
/// Forces an update (resets timer).
pub fn forceUpdate(self: *Throttle) void {
self.last_update = std.time.milliTimestamp();
}
/// Resets the throttle.
pub fn reset(self: *Throttle) void {
self.last_update = 0;
}
};
// ============================================================================
// Debounce
// ============================================================================
/// Debounces updates - only processes after quiet period.
pub const Debounce = struct {
/// Quiet period before processing (ms).
delay_ms: i64,
/// Last activity timestamp.
last_activity: i64 = 0,
/// Whether we have pending activity.
pending: bool = false,
/// Creates a debounce with given delay.
pub fn init(delay_ms: i64) Debounce {
return .{
.delay_ms = delay_ms,
};
}
/// Records activity (resets the timer).
pub fn activity(self: *Debounce) void {
self.last_activity = std.time.milliTimestamp();
self.pending = true;
}
/// Checks if debounced action should proceed.
pub fn shouldProcess(self: *Debounce) bool {
if (!self.pending) return false;
const now = std.time.milliTimestamp();
if (now - self.last_activity >= self.delay_ms) {
self.pending = false;
return true;
}
return false;
}
/// Cancels pending action.
pub fn cancel(self: *Debounce) void {
self.pending = false;
}
};
// ============================================================================
// Tests
// ============================================================================
test "RenderCache basic" {
const allocator = std.testing.allocator;
var cache = RenderCache.init(allocator);
defer cache.deinit();
const area = Rect.init(0, 0, 10, 5);
try cache.ensureCapacity(area);
try std.testing.expect(!cache.valid);
try std.testing.expectEqual(@as(usize, 50), cache.cells.len);
}
test "Throttle" {
var throttle = Throttle.init(10); // 10ms
try std.testing.expect(throttle.shouldUpdate());
try std.testing.expect(!throttle.shouldUpdate());
// Wait for throttle
std.time.sleep(15 * std.time.ns_per_ms);
try std.testing.expect(throttle.shouldUpdate());
}
test "Debounce" {
var debounce = Debounce.init(10); // 10ms
try std.testing.expect(!debounce.shouldProcess());
debounce.activity();
try std.testing.expect(!debounce.shouldProcess()); // Too soon
std.time.sleep(15 * std.time.ns_per_ms);
try std.testing.expect(debounce.shouldProcess());
try std.testing.expect(!debounce.shouldProcess()); // Already processed
}
test "RenderTask basic" {
const Task = RenderTask(i32, i32);
var task = Task.init(42, struct {
fn compute(n: i32) i32 {
return n * 2;
}
}.compute);
try task.start();
const result = task.wait();
try std.testing.expectEqual(@as(?i32, 84), result);
}

View file

@ -166,6 +166,12 @@ pub const widgets = struct {
pub const FileEntry = filepicker_mod.FileEntry;
pub const FileType = filepicker_mod.FileType;
pub const FileIcons = filepicker_mod.FileIcons;
pub const scroll_mod = @import("widgets/scroll.zig");
pub const ScrollView = scroll_mod.ScrollView;
pub const ScrollState = scroll_mod.ScrollState;
pub const VirtualList = scroll_mod.VirtualList;
pub const InfiniteScroll = scroll_mod.InfiniteScroll;
};
// Backend
@ -220,6 +226,14 @@ pub const Iterm2 = image.Iterm2;
pub const ImageOptions = image.ImageOptions;
pub const ImageFormat = image.ImageFormat;
// Lazy rendering and async utilities
pub const lazy = @import("lazy.zig");
pub const RenderCache = lazy.RenderCache;
pub const RenderTask = lazy.RenderTask;
pub const Throttle = lazy.Throttle;
pub const Debounce = lazy.Debounce;
pub const DeferredRender = lazy.DeferredRender;
// ============================================================================
// Tests
// ============================================================================

723
src/widgets/scroll.zig Normal file
View file

@ -0,0 +1,723 @@
//! Scrollable container widget for zcatui.
//!
//! Provides scrollable views for content larger than the viewport:
//! - ScrollView: Basic scrollable container
//! - VirtualList: Efficient list for large datasets (virtual scrolling)
//! - InfiniteScroll: Load more content as user scrolls
//!
//! ## Example
//!
//! ```zig
//! var scroll = ScrollView.init()
//! .setContentSize(1000, 500)
//! .setShowScrollbar(true);
//!
//! // Handle scroll events
//! switch (event.key.code) {
//! .up => scroll.scrollUp(1),
//! .down => scroll.scrollDown(1),
//! .page_up => scroll.pageUp(),
//! .page_down => scroll.pageDown(),
//! }
//!
//! // Render
//! scroll.render(area, buf);
//! ```
const std = @import("std");
const Allocator = std.mem.Allocator;
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
const block_mod = @import("block.zig");
const Block = block_mod.Block;
const Borders = block_mod.Borders;
const scrollbar_mod = @import("scrollbar.zig");
const Scrollbar = scrollbar_mod.Scrollbar;
const ScrollbarState = scrollbar_mod.ScrollbarState;
const ScrollbarOrientation = scrollbar_mod.ScrollbarOrientation;
// ============================================================================
// ScrollState
// ============================================================================
/// State for scrollable content.
pub const ScrollState = struct {
/// Horizontal scroll offset.
offset_x: u32 = 0,
/// Vertical scroll offset.
offset_y: u32 = 0,
/// Total content width.
content_width: u32 = 0,
/// Total content height.
content_height: u32 = 0,
/// Viewport width.
viewport_width: u16 = 0,
/// Viewport height.
viewport_height: u16 = 0,
/// Creates a new scroll state.
pub fn init() ScrollState {
return .{};
}
/// Sets content dimensions.
pub fn setContentSize(self: *ScrollState, width: u32, height: u32) void {
self.content_width = width;
self.content_height = height;
self.clampOffsets();
}
/// Sets viewport dimensions.
pub fn setViewport(self: *ScrollState, width: u16, height: u16) void {
self.viewport_width = width;
self.viewport_height = height;
self.clampOffsets();
}
/// Scrolls up by amount.
pub fn scrollUp(self: *ScrollState, amount: u32) void {
self.offset_y -|= amount;
}
/// Scrolls down by amount.
pub fn scrollDown(self: *ScrollState, amount: u32) void {
self.offset_y +|= amount;
self.clampOffsets();
}
/// Scrolls left by amount.
pub fn scrollLeft(self: *ScrollState, amount: u32) void {
self.offset_x -|= amount;
}
/// Scrolls right by amount.
pub fn scrollRight(self: *ScrollState, amount: u32) void {
self.offset_x +|= amount;
self.clampOffsets();
}
/// Scrolls up by one page.
pub fn pageUp(self: *ScrollState) void {
self.scrollUp(self.viewport_height);
}
/// Scrolls down by one page.
pub fn pageDown(self: *ScrollState) void {
self.scrollDown(self.viewport_height);
}
/// Scrolls to top.
pub fn scrollToTop(self: *ScrollState) void {
self.offset_y = 0;
}
/// Scrolls to bottom.
pub fn scrollToBottom(self: *ScrollState) void {
if (self.content_height > self.viewport_height) {
self.offset_y = self.content_height - self.viewport_height;
} else {
self.offset_y = 0;
}
}
/// Scrolls to make a position visible.
pub fn scrollToVisible(self: *ScrollState, y: u32) void {
if (y < self.offset_y) {
self.offset_y = y;
} else if (y >= self.offset_y + self.viewport_height) {
self.offset_y = y - self.viewport_height + 1;
}
self.clampOffsets();
}
/// Clamps offsets to valid range.
pub fn clampOffsets(self: *ScrollState) void {
if (self.content_height > self.viewport_height) {
const max_y = self.content_height - self.viewport_height;
if (self.offset_y > max_y) {
self.offset_y = max_y;
}
} else {
self.offset_y = 0;
}
if (self.content_width > self.viewport_width) {
const max_x = self.content_width - self.viewport_width;
if (self.offset_x > max_x) {
self.offset_x = max_x;
}
} else {
self.offset_x = 0;
}
}
/// Returns scroll percentage (0.0 - 1.0).
pub fn getScrollPercentY(self: ScrollState) f32 {
if (self.content_height <= self.viewport_height) return 0.0;
const max_offset = self.content_height - self.viewport_height;
return @as(f32, @floatFromInt(self.offset_y)) / @as(f32, @floatFromInt(max_offset));
}
/// Returns scroll percentage (0.0 - 1.0).
pub fn getScrollPercentX(self: ScrollState) f32 {
if (self.content_width <= self.viewport_width) return 0.0;
const max_offset = self.content_width - self.viewport_width;
return @as(f32, @floatFromInt(self.offset_x)) / @as(f32, @floatFromInt(max_offset));
}
/// Returns whether at top.
pub fn isAtTop(self: ScrollState) bool {
return self.offset_y == 0;
}
/// Returns whether at bottom.
pub fn isAtBottom(self: ScrollState) bool {
if (self.content_height <= self.viewport_height) return true;
return self.offset_y >= self.content_height - self.viewport_height;
}
};
// ============================================================================
// ScrollView
// ============================================================================
/// A scrollable container widget.
pub const ScrollView = struct {
/// Scroll state.
state: ScrollState = .{},
/// Block wrapper.
block: ?Block = null,
/// Style.
style: Style = Style.default,
/// Show vertical scrollbar.
show_scrollbar_v: bool = true,
/// Show horizontal scrollbar.
show_scrollbar_h: bool = false,
/// Scrollbar style.
scrollbar_style: Style = Style.default.fg(Color.white),
/// Content render function.
render_content: ?*const fn (*ScrollView, Rect, *Buffer) void = null,
/// User data.
user_data: ?*anyopaque = null,
/// Creates a new scroll view.
pub fn init() ScrollView {
return .{};
}
/// Sets the block.
pub fn setBlock(self: ScrollView, blk: Block) ScrollView {
var sv = self;
sv.block = blk;
return sv;
}
/// Sets content size.
pub fn setContentSize(self: ScrollView, width: u32, height: u32) ScrollView {
var sv = self;
sv.state.setContentSize(width, height);
return sv;
}
/// Sets whether to show vertical scrollbar.
pub fn setShowScrollbar(self: ScrollView, show: bool) ScrollView {
var sv = self;
sv.show_scrollbar_v = show;
return sv;
}
/// Gets mutable state reference.
pub fn getState(self: *ScrollView) *ScrollState {
return &self.state;
}
/// Scrolls up.
pub fn scrollUp(self: *ScrollView, amount: u32) void {
self.state.scrollUp(amount);
}
/// Scrolls down.
pub fn scrollDown(self: *ScrollView, amount: u32) void {
self.state.scrollDown(amount);
}
/// Page up.
pub fn pageUp(self: *ScrollView) void {
self.state.pageUp();
}
/// Page down.
pub fn pageDown(self: *ScrollView) void {
self.state.pageDown();
}
/// Renders the scroll view.
pub fn render(self: *ScrollView, area: Rect, buf: *Buffer) void {
// Clear area
buf.setStyle(area, self.style);
// Render block
var content_area = area;
if (self.block) |blk| {
blk.render(area, buf);
content_area = blk.inner(area);
}
// Reserve space for scrollbars
var scrollbar_area_v: ?Rect = null;
var scrollbar_area_h: ?Rect = null;
if (self.show_scrollbar_v and content_area.width > 1) {
scrollbar_area_v = Rect.init(
content_area.x + content_area.width - 1,
content_area.y,
1,
content_area.height,
);
content_area.width -= 1;
}
if (self.show_scrollbar_h and content_area.height > 1) {
scrollbar_area_h = Rect.init(
content_area.x,
content_area.y + content_area.height - 1,
content_area.width,
1,
);
content_area.height -= 1;
}
// Update viewport
self.state.setViewport(content_area.width, content_area.height);
// Render content if function is set
if (self.render_content) |render_fn| {
render_fn(self, content_area, buf);
}
// Render scrollbars
if (scrollbar_area_v) |sb_area| {
self.renderVerticalScrollbar(sb_area, buf);
}
if (scrollbar_area_h) |sb_area| {
self.renderHorizontalScrollbar(sb_area, buf);
}
}
fn renderVerticalScrollbar(self: *ScrollView, area: Rect, buf: *Buffer) void {
if (self.state.content_height <= self.state.viewport_height) {
return;
}
var sb_state = ScrollbarState.init();
sb_state.content_length = self.state.content_height;
sb_state.viewport_length = self.state.viewport_height;
sb_state.setPosition(self.state.offset_y);
const scrollbar = Scrollbar.init(ScrollbarOrientation.vertical_right)
.style(self.scrollbar_style);
scrollbar.renderStateful(area, buf, &sb_state);
}
fn renderHorizontalScrollbar(self: *ScrollView, area: Rect, buf: *Buffer) void {
if (self.state.content_width <= self.state.viewport_width) {
return;
}
var sb_state = ScrollbarState.init();
sb_state.content_length = self.state.content_width;
sb_state.viewport_length = self.state.viewport_width;
sb_state.setPosition(self.state.offset_x);
const scrollbar = Scrollbar.init(ScrollbarOrientation.horizontal_bottom)
.style(self.scrollbar_style);
scrollbar.renderStateful(area, buf, &sb_state);
}
};
// ============================================================================
// VirtualList
// ============================================================================
/// Callback for rendering a single item.
pub const ItemRenderFn = *const fn (index: usize, area: Rect, buf: *Buffer, user_data: ?*anyopaque) void;
/// Virtual scrolling list for large datasets.
/// Only renders items that are visible in the viewport.
pub const VirtualList = struct {
/// Total number of items.
item_count: usize = 0,
/// Height of each item in cells.
item_height: u16 = 1,
/// Current scroll offset (item index).
scroll_offset: usize = 0,
/// Selected item index.
selected: ?usize = null,
/// Block wrapper.
block: ?Block = null,
/// Base style.
style: Style = Style.default,
/// Selected item style.
selected_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Show scrollbar.
show_scrollbar: bool = true,
/// Item render callback.
render_item: ?ItemRenderFn = null,
/// User data passed to render callback.
user_data: ?*anyopaque = null,
/// Creates a new virtual list.
pub fn init() VirtualList {
return .{};
}
/// Sets the item count.
pub fn setItemCount(self: VirtualList, count: usize) VirtualList {
var vl = self;
vl.item_count = count;
return vl;
}
/// Sets the item height.
pub fn setItemHeight(self: VirtualList, height: u16) VirtualList {
var vl = self;
vl.item_height = height;
return vl;
}
/// Sets the render callback.
pub fn setRenderItem(self: VirtualList, render_fn: ItemRenderFn) VirtualList {
var vl = self;
vl.render_item = render_fn;
return vl;
}
/// Sets user data.
pub fn setUserData(self: VirtualList, data: *anyopaque) VirtualList {
var vl = self;
vl.user_data = data;
return vl;
}
/// Sets the block.
pub fn setBlock(self: VirtualList, blk: Block) VirtualList {
var vl = self;
vl.block = blk;
return vl;
}
/// Selects an item.
pub fn select(self: *VirtualList, index: ?usize) void {
if (index) |i| {
if (i < self.item_count) {
self.selected = i;
self.ensureVisible(i);
}
} else {
self.selected = null;
}
}
/// Selects next item.
pub fn selectNext(self: *VirtualList) void {
if (self.selected) |s| {
if (s + 1 < self.item_count) {
self.select(s + 1);
}
} else if (self.item_count > 0) {
self.select(0);
}
}
/// Selects previous item.
pub fn selectPrev(self: *VirtualList) void {
if (self.selected) |s| {
if (s > 0) {
self.select(s - 1);
}
} else if (self.item_count > 0) {
self.select(0);
}
}
/// Ensures an item is visible.
pub fn ensureVisible(self: *VirtualList, index: usize) void {
if (index < self.scroll_offset) {
self.scroll_offset = index;
}
// Note: We need viewport height to calculate if item is below viewport
// This will be done in render
}
/// Returns visible range of items.
pub fn getVisibleRange(self: VirtualList, viewport_height: u16) struct { start: usize, end: usize } {
const items_per_page = viewport_height / self.item_height;
const start = self.scroll_offset;
const end = @min(start + items_per_page, self.item_count);
return .{ .start = start, .end = end };
}
/// Renders the virtual list.
pub fn render(self: *VirtualList, area: Rect, buf: *Buffer) void {
// Clear area
buf.setStyle(area, self.style);
// Render block
var content_area = area;
if (self.block) |blk| {
blk.render(area, buf);
content_area = blk.inner(area);
}
// Reserve scrollbar space
var scrollbar_area: ?Rect = null;
if (self.show_scrollbar and content_area.width > 1) {
scrollbar_area = Rect.init(
content_area.x + content_area.width - 1,
content_area.y,
1,
content_area.height,
);
content_area.width -= 1;
}
// Adjust scroll to ensure selected is visible
if (self.selected) |s| {
const items_per_page = content_area.height / self.item_height;
if (s < self.scroll_offset) {
self.scroll_offset = s;
} else if (s >= self.scroll_offset + items_per_page) {
self.scroll_offset = s - items_per_page + 1;
}
}
// Render visible items
if (self.render_item) |render_fn| {
const range = self.getVisibleRange(content_area.height);
var y = content_area.y;
for (range.start..range.end) |i| {
const item_area = Rect.init(
content_area.x,
y,
content_area.width,
self.item_height,
);
// Highlight selected
if (self.selected) |s| {
if (s == i) {
buf.setStyle(item_area, self.selected_style);
}
}
render_fn(i, item_area, buf, self.user_data);
y += self.item_height;
}
}
// Render scrollbar
if (scrollbar_area) |sb_area| {
self.renderScrollbar(sb_area, buf, content_area.height);
}
}
fn renderScrollbar(self: *VirtualList, area: Rect, buf: *Buffer, viewport_height: u16) void {
const content_height = self.item_count * self.item_height;
if (content_height <= viewport_height) return;
var sb_state = ScrollbarState.init();
sb_state.content_length = @intCast(content_height);
sb_state.viewport_length = viewport_height;
sb_state.setPosition(@intCast(self.scroll_offset * self.item_height));
const scrollbar = Scrollbar.init(ScrollbarOrientation.vertical_right);
scrollbar.renderStateful(area, buf, &sb_state);
}
};
// ============================================================================
// InfiniteScroll
// ============================================================================
/// Callback for loading more items.
pub const LoadMoreFn = *const fn (current_count: usize, user_data: ?*anyopaque) usize;
/// Infinite scroll container - loads more content when reaching end.
pub const InfiniteScroll = struct {
/// Underlying virtual list.
list: VirtualList = .{},
/// Load more callback.
load_more: ?LoadMoreFn = null,
/// Threshold for loading more (items from bottom).
load_threshold: usize = 5,
/// Whether currently loading.
loading: bool = false,
/// Whether there's more content to load.
has_more: bool = true,
/// Creates a new infinite scroll.
pub fn init() InfiniteScroll {
return .{};
}
/// Sets the load more callback.
pub fn setLoadMore(self: InfiniteScroll, load_fn: LoadMoreFn) InfiniteScroll {
var is = self;
is.load_more = load_fn;
return is;
}
/// Sets the load threshold.
pub fn setLoadThreshold(self: InfiniteScroll, threshold: usize) InfiniteScroll {
var is = self;
is.load_threshold = threshold;
return is;
}
/// Gets the underlying list.
pub fn getList(self: *InfiniteScroll) *VirtualList {
return &self.list;
}
/// Checks if more content should be loaded.
pub fn checkLoadMore(self: *InfiniteScroll) bool {
if (self.loading or !self.has_more) return false;
if (self.load_more == null) return false;
if (self.list.selected) |s| {
const remaining = self.list.item_count -| s;
if (remaining <= self.load_threshold) {
return true;
}
}
return false;
}
/// Triggers loading more content.
pub fn loadMore(self: *InfiniteScroll) void {
if (self.load_more) |load_fn| {
self.loading = true;
const new_count = load_fn(self.list.item_count, self.list.user_data);
if (new_count == self.list.item_count) {
self.has_more = false;
} else {
self.list.item_count = new_count;
}
self.loading = false;
}
}
/// Selects next item (loads more if needed).
pub fn selectNext(self: *InfiniteScroll) void {
self.list.selectNext();
if (self.checkLoadMore()) {
self.loadMore();
}
}
/// Selects previous item.
pub fn selectPrev(self: *InfiniteScroll) void {
self.list.selectPrev();
}
/// Renders the infinite scroll.
pub fn render(self: *InfiniteScroll, area: Rect, buf: *Buffer) void {
self.list.render(area, buf);
}
};
// ============================================================================
// Tests
// ============================================================================
test "ScrollState basic" {
var state = ScrollState.init();
state.setContentSize(100, 200);
state.setViewport(40, 20);
try std.testing.expectEqual(@as(u32, 0), state.offset_y);
try std.testing.expect(state.isAtTop());
state.scrollDown(10);
try std.testing.expectEqual(@as(u32, 10), state.offset_y);
state.scrollToBottom();
try std.testing.expect(state.isAtBottom());
try std.testing.expectEqual(@as(u32, 180), state.offset_y);
}
test "ScrollState clamp" {
var state = ScrollState.init();
state.setContentSize(100, 50);
state.setViewport(40, 100);
// Content fits in viewport
state.scrollDown(1000);
try std.testing.expectEqual(@as(u32, 0), state.offset_y);
}
test "VirtualList visible range" {
var list = VirtualList.init()
.setItemCount(100)
.setItemHeight(2);
list.scroll_offset = 10;
const range = list.getVisibleRange(20);
try std.testing.expectEqual(@as(usize, 10), range.start);
try std.testing.expectEqual(@as(usize, 20), range.end);
}
test "VirtualList selection" {
var list = VirtualList.init()
.setItemCount(100)
.setItemHeight(1);
try std.testing.expectEqual(@as(?usize, null), list.selected);
list.selectNext();
try std.testing.expectEqual(@as(?usize, 0), list.selected);
list.selectNext();
try std.testing.expectEqual(@as(?usize, 1), list.selected);
list.selectPrev();
try std.testing.expectEqual(@as(?usize, 0), list.selected);
list.selectPrev();
try std.testing.expectEqual(@as(?usize, 0), list.selected); // Stay at 0
}