zcatgui/src/widgets/list.zig
reugenio 8adc93a345 feat: zcatgui v0.6.0 - Phase 1 Optimization Complete
Performance Infrastructure:
- FrameArena: O(1) per-frame allocator with automatic reset
- ObjectPool: Generic object pool for frequently allocated types
- CommandPool: Specialized pool for draw commands
- RingBuffer: Circular buffer for streaming data
- ScopedArena: RAII pattern for temporary allocations

Dirty Rectangle System:
- Context now tracks dirty regions for partial redraws
- Automatic rect merging to reduce overdraw
- invalidateRect(), needsRedraw(), getDirtyRects() API
- Falls back to full redraw when > 32 dirty rects

Benchmark Suite:
- Timer: High-resolution timing
- Benchmark: Stats collection (avg, min, max, stddev, median)
- FrameTimer: FPS and frame time tracking
- AllocationTracker: Memory usage monitoring
- Pre-built benchmarks for arena, pool, and commands

Context Improvements:
- Integrated FrameArena for zero-allocation hot paths
- frameAllocator() for per-frame widget allocations
- FrameStats for performance monitoring
- Context.init() now returns error union (breaking change)

New Widgets (from previous session):
- Slider: Horizontal/vertical with customization
- ScrollArea: Scrollable content region
- Tabs: Tab container with keyboard navigation
- RadioButton: Radio button groups
- Menu: Dropdown menus (foundation)

Theme System Expansion:
- 5 built-in themes: dark, light, high_contrast, nord, dracula
- ThemeManager with runtime switching
- TTF font support via stb_truetype

Documentation:
- DEVELOPMENT_PLAN.md: 9-phase roadmap to DVUI/Gio parity
- Updated WIDGET_COMPARISON.md with detailed analysis
- Lego Panels architecture documented

Stats: 17 widgets, 123 tests, 5 themes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 12:45:00 +01:00

347 lines
10 KiB
Zig

//! List Widget - Scrollable list of selectable items
//!
//! A vertical list with keyboard navigation and single selection.
//! Supports virtualized rendering for large lists.
const std = @import("std");
const Context = @import("../core/context.zig").Context;
const Command = @import("../core/command.zig");
const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig");
const Input = @import("../core/input.zig");
/// List state (caller-managed)
pub const ListState = struct {
/// Currently selected index (-1 for none)
selected: i32 = -1,
/// Scroll offset (first visible item index)
scroll_offset: usize = 0,
/// Whether the list has focus
focused: bool = false,
/// Get selected index as optional usize
pub fn selectedIndex(self: ListState) ?usize {
if (self.selected < 0) return null;
return @intCast(self.selected);
}
/// Select by index
pub fn selectIndex(self: *ListState, idx: usize) void {
self.selected = @intCast(idx);
}
/// Move selection up
pub fn selectPrev(self: *ListState) void {
if (self.selected > 0) {
self.selected -= 1;
}
}
/// Move selection down
pub fn selectNext(self: *ListState, max: usize) void {
if (self.selected < @as(i32, @intCast(max)) - 1) {
self.selected += 1;
}
}
/// Ensure selected item is visible
pub fn ensureVisible(self: *ListState, visible_count: usize) void {
if (self.selected < 0) return;
const sel: usize = @intCast(self.selected);
if (sel < self.scroll_offset) {
self.scroll_offset = sel;
} else if (sel >= self.scroll_offset + visible_count) {
self.scroll_offset = sel - visible_count + 1;
}
}
};
/// List configuration
pub const ListConfig = struct {
/// Height of each item
item_height: u32 = 24,
/// Padding inside each item
item_padding: u32 = 4,
/// Show border around list
show_border: bool = true,
/// Allow keyboard navigation
keyboard_nav: bool = true,
};
/// List result
pub const ListResult = struct {
/// Selection changed this frame
changed: bool,
/// Item was double-clicked
activated: bool,
/// Newly selected index (valid if changed)
new_index: ?usize,
/// List was clicked (for focus)
clicked: bool,
};
/// Draw a list
pub fn list(
ctx: *Context,
state: *ListState,
items: []const []const u8,
) ListResult {
return listEx(ctx, state, items, .{});
}
/// Draw a list with custom configuration
pub fn listEx(
ctx: *Context,
state: *ListState,
items: []const []const u8,
config: ListConfig,
) ListResult {
const bounds = ctx.layout.nextRect();
return listRect(ctx, bounds, state, items, config);
}
/// Draw a list in a specific rectangle
pub fn listRect(
ctx: *Context,
bounds: Layout.Rect,
state: *ListState,
items: []const []const u8,
config: ListConfig,
) ListResult {
var result = ListResult{
.changed = false,
.activated = false,
.new_index = null,
.clicked = false,
};
if (bounds.isEmpty()) return result;
if (items.len == 0) {
// Draw empty list
if (config.show_border) {
const theme = Style.Theme.dark;
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, theme.background));
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, theme.border));
}
return result;
}
const theme = Style.Theme.dark;
const mouse = ctx.input.mousePos();
const list_hovered = bounds.contains(mouse.x, mouse.y);
// Click detection for focus
if (list_hovered and ctx.input.mousePressed(.left)) {
state.focused = true;
result.clicked = true;
}
// Draw background
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, theme.background));
// Draw border if enabled
if (config.show_border) {
const border_color = if (state.focused) theme.primary else theme.border;
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color));
}
// Calculate visible items
const inner = if (config.show_border) bounds.shrink(1) else bounds;
const visible_count = inner.h / config.item_height;
// Ensure scroll offset is valid
if (items.len <= visible_count) {
state.scroll_offset = 0;
} else if (state.scroll_offset > items.len - visible_count) {
state.scroll_offset = items.len - visible_count;
}
// Handle scroll
if (list_hovered) {
const scroll = ctx.input.scroll_y;
if (scroll < 0 and state.scroll_offset > 0) {
state.scroll_offset -= 1;
} else if (scroll > 0 and state.scroll_offset < items.len - visible_count) {
state.scroll_offset += 1;
}
}
// Clip to list bounds
ctx.pushCommand(Command.clip(inner.x, inner.y, inner.w, inner.h));
// Draw visible items
var item_y = inner.y;
const end_idx = @min(state.scroll_offset + visible_count + 1, items.len);
for (state.scroll_offset..end_idx) |i| {
const item_bounds = Layout.Rect.init(
inner.x,
item_y,
inner.w,
config.item_height,
);
// Check if item is visible
if (item_y >= inner.bottom()) break;
const item_hovered = item_bounds.contains(mouse.x, mouse.y) and list_hovered;
const item_clicked = item_hovered and ctx.input.mouseReleased(.left);
// Determine item background
const is_selected = state.selected == @as(i32, @intCast(i));
const item_bg = if (is_selected)
theme.selection_bg
else if (item_hovered)
theme.button_hover
else
Style.Color.transparent;
if (item_bg.a > 0) {
ctx.pushCommand(Command.rect(
item_bounds.x,
item_bounds.y,
item_bounds.w,
item_bounds.h,
item_bg,
));
}
// Draw item text
const text_color = if (is_selected) theme.selection_fg else theme.foreground;
const char_height: u32 = 8;
const text_x = item_bounds.x + @as(i32, @intCast(config.item_padding));
const text_y = item_bounds.y + @as(i32, @intCast((config.item_height -| char_height) / 2));
ctx.pushCommand(Command.text(text_x, text_y, items[i], text_color));
// Handle click
if (item_clicked) {
const old_selected = state.selected;
state.selected = @intCast(i);
if (old_selected != state.selected) {
result.changed = true;
result.new_index = i;
}
}
item_y += @as(i32, @intCast(config.item_height));
}
// End clip
ctx.pushCommand(Command.clipEnd());
// Draw scrollbar if needed
if (items.len > visible_count) {
const scrollbar_w: u32 = 8;
const scrollbar_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(scrollbar_w + 1));
// Scrollbar track
ctx.pushCommand(Command.rect(
scrollbar_x,
inner.y,
scrollbar_w,
inner.h,
theme.background.darken(10),
));
// Scrollbar thumb
const thumb_h = @max((visible_count * inner.h) / @as(u32, @intCast(items.len)), 20);
const track_h = inner.h - thumb_h;
const thumb_offset = if (items.len > visible_count)
(state.scroll_offset * track_h) / (items.len - visible_count)
else
0;
ctx.pushCommand(Command.rect(
scrollbar_x,
inner.y + @as(i32, @intCast(thumb_offset)),
scrollbar_w,
thumb_h,
theme.secondary,
));
}
return result;
}
/// Get selected item text
pub fn getSelectedText(state: ListState, items: []const []const u8) ?[]const u8 {
if (state.selectedIndex()) |idx| {
if (idx < items.len) {
return items[idx];
}
}
return null;
}
// =============================================================================
// Tests
// =============================================================================
test "ListState navigation" {
var state = ListState{};
state.selectIndex(2);
try std.testing.expectEqual(@as(?usize, 2), state.selectedIndex());
state.selectPrev();
try std.testing.expectEqual(@as(?usize, 1), state.selectedIndex());
state.selectNext(5);
try std.testing.expectEqual(@as(?usize, 2), state.selectedIndex());
}
test "ListState ensureVisible" {
var state = ListState{ .selected = 10, .scroll_offset = 0 };
state.ensureVisible(5);
// Selected item 10 should now be visible (scroll to 6)
try std.testing.expectEqual(@as(usize, 6), state.scroll_offset);
}
test "list generates commands" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = ListState{};
const items = [_][]const u8{ "Item 1", "Item 2", "Item 3" };
ctx.beginFrame();
ctx.layout.row_height = 100;
_ = list(&ctx, &state, &items);
// Should generate background + border + clip + items + clip_end
try std.testing.expect(ctx.commands.items.len >= 4);
ctx.endFrame();
}
test "list selection" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = ListState{};
const items = [_][]const u8{ "A", "B", "C" };
// Frame 1: Click on item
ctx.beginFrame();
ctx.layout.row_height = 100;
ctx.input.setMousePos(50, 36); // Should be item 1 (y=24+12)
ctx.input.setMouseButton(.left, true);
_ = list(&ctx, &state, &items);
ctx.endFrame();
// Frame 2: Release
ctx.beginFrame();
ctx.layout.row_height = 100;
ctx.input.setMousePos(50, 36);
ctx.input.setMouseButton(.left, false);
const result = list(&ctx, &state, &items);
ctx.endFrame();
try std.testing.expect(result.changed);
}