//! 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); }