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>
347 lines
10 KiB
Zig
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);
|
|
}
|