//! Grid Widget - Layout grid with cells //! //! A grid layout that arranges items in rows and columns. //! Supports scrolling, selection, and responsive column count. 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"); /// Grid state pub const State = struct { /// Scroll offset (vertical) scroll_y: i32 = 0, /// Scroll offset (horizontal, if enabled) scroll_x: i32 = 0, /// Currently selected cell index selected: ?usize = null, /// Hovered cell index hovered: ?usize = null, pub fn init() State { return .{}; } /// Select next cell pub fn selectNext(self: *State, total_items: usize, columns: u16) void { if (total_items == 0) return; if (self.selected) |sel| { if (sel + 1 < total_items) { self.selected = sel + 1; } } else { self.selected = 0; } _ = columns; } /// Select previous cell pub fn selectPrev(self: *State, total_items: usize, columns: u16) void { if (total_items == 0) return; if (self.selected) |sel| { if (sel > 0) { self.selected = sel - 1; } } else { self.selected = total_items - 1; } _ = columns; } /// Select cell below pub fn selectDown(self: *State, total_items: usize, columns: u16) void { if (total_items == 0) return; if (self.selected) |sel| { const next = sel + columns; if (next < total_items) { self.selected = next; } } else { self.selected = 0; } } /// Select cell above pub fn selectUp(self: *State, total_items: usize, columns: u16) void { if (total_items == 0) return; if (self.selected) |sel| { if (sel >= columns) { self.selected = sel - columns; } } else { self.selected = 0; } } }; /// Grid configuration pub const Config = struct { /// Number of columns columns: u16 = 3, /// Cell height (null = auto based on width for square cells) cell_height: ?u16 = null, /// Gap between cells gap: u16 = 8, /// Padding around the grid padding: u16 = 8, /// Enable keyboard navigation keyboard_nav: bool = true, /// Enable cell selection selectable: bool = true, /// Enable horizontal scrolling scroll_horizontal: bool = false, }; /// Grid colors pub const Colors = struct { /// Background background: Style.Color = Style.Color.rgba(0, 0, 0, 0), /// Cell background cell_bg: Style.Color = Style.Color.rgb(50, 50, 50), /// Cell background (hovered) cell_hover: Style.Color = Style.Color.rgb(60, 60, 60), /// Cell background (selected) cell_selected: Style.Color = Style.Color.rgb(66, 133, 244), /// Cell border cell_border: Style.Color = Style.Color.rgb(70, 70, 70), /// Scrollbar scrollbar: Style.Color = Style.Color.rgb(80, 80, 80), /// Scrollbar thumb scrollbar_thumb: Style.Color = Style.Color.rgb(120, 120, 120), pub fn fromTheme(theme: Style.Theme) Colors { return .{ .background = Style.Color.transparent, .cell_bg = theme.input_bg, .cell_hover = theme.input_bg.lighten(10), .cell_selected = theme.primary, .cell_border = theme.border, .scrollbar = theme.secondary, .scrollbar_thumb = theme.foreground.darken(40), }; } }; /// Grid cell info returned for each visible cell pub const CellInfo = struct { /// Cell index in the items array index: usize, /// Cell bounds bounds: Layout.Rect, /// Row index row: usize, /// Column index col: usize, /// Is this cell selected selected: bool, /// Is this cell hovered hovered: bool, }; /// Grid result pub const Result = struct { /// Visible cells (caller should iterate and draw content) visible_cells: []CellInfo, /// Cell that was clicked (index) clicked: ?usize, /// Cell that was double-clicked double_clicked: ?usize, /// Grid bounds bounds: Layout.Rect, /// Content area (inside padding) content_rect: Layout.Rect, /// Total content height total_height: u32, /// Whether grid needs scrolling needs_scroll: bool, }; /// Maximum visible cells we track const MAX_VISIBLE_CELLS = 256; /// Draw grid and get cell info for rendering pub fn grid( ctx: *Context, state: *State, item_count: usize, config: Config, colors: Colors, ) Result { const bounds = ctx.layout.nextRect(); return gridRect(ctx, bounds, state, item_count, config, colors); } /// Grid in specific rectangle pub fn gridRect( ctx: *Context, bounds: Layout.Rect, state: *State, item_count: usize, config: Config, colors: Colors, ) Result { // Static buffer for visible cells const S = struct { var cells: [MAX_VISIBLE_CELLS]CellInfo = undefined; }; if (bounds.isEmpty() or item_count == 0) { return .{ .visible_cells = S.cells[0..0], .clicked = null, .double_clicked = null, .bounds = bounds, .content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }, .total_height = 0, .needs_scroll = false, }; } // Handle keyboard navigation if (config.keyboard_nav and config.selectable) { if (ctx.input.keyPressed(.right)) { state.selectNext(item_count, config.columns); } if (ctx.input.keyPressed(.left)) { state.selectPrev(item_count, config.columns); } if (ctx.input.keyPressed(.down)) { state.selectDown(item_count, config.columns); } if (ctx.input.keyPressed(.up)) { state.selectUp(item_count, config.columns); } } // Draw background if (colors.background.a > 0) { ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.background)); } // Calculate content area const content_x = bounds.x + @as(i32, config.padding); const content_y = bounds.y + @as(i32, config.padding); const content_w = bounds.w -| (config.padding * 2); const content_h = bounds.h -| (config.padding * 2); // Calculate cell dimensions const total_gap_w = config.gap * (config.columns - 1); const cell_w = (content_w -| total_gap_w) / config.columns; const cell_h = config.cell_height orelse cell_w; // Square by default // Calculate total rows and height const total_rows = (item_count + config.columns - 1) / config.columns; const total_height = @as(u32, @intCast(total_rows)) * (cell_h + config.gap); const needs_scroll = total_height > content_h; // Handle scrolling if (needs_scroll) { const scroll_amount = ctx.input.scroll_y; state.scroll_y -= scroll_amount * @as(i32, @intCast(cell_h / 2)); state.scroll_y = @max(0, @min(state.scroll_y, @as(i32, @intCast(total_height -| content_h)))); } // Clip content ctx.pushCommand(Command.clip(content_x, content_y, content_w, content_h)); // Find visible range const first_visible_row = @as(usize, @intCast(@max(0, @divTrunc(state.scroll_y, @as(i32, @intCast(cell_h + config.gap)))))); const visible_rows = (content_h / (cell_h + config.gap)) + 2; const last_visible_row = @min(first_visible_row + visible_rows, total_rows); // Mouse interaction const mouse = ctx.input.mousePos(); var clicked: ?usize = null; var cell_count: usize = 0; // Update hovered state.hovered = null; // Draw visible cells var row: usize = first_visible_row; while (row < last_visible_row) : (row += 1) { var col: u16 = 0; while (col < config.columns) : (col += 1) { const index = row * config.columns + col; if (index >= item_count) break; const cell_x = content_x + @as(i32, @intCast(col * (cell_w + config.gap))); const cell_y = content_y + @as(i32, @intCast(row * (cell_h + config.gap))) - state.scroll_y; const cell_bounds = Layout.Rect{ .x = cell_x, .y = cell_y, .w = cell_w, .h = cell_h, }; // Check if visible (clipped) if (cell_y + @as(i32, @intCast(cell_h)) < content_y or cell_y > content_y + @as(i32, @intCast(content_h))) { continue; } const is_hovered = cell_bounds.contains(mouse.x, mouse.y); const is_selected = state.selected == index; if (is_hovered) { state.hovered = index; } // Handle click if (is_hovered and ctx.input.mouseReleased(.left) and config.selectable) { state.selected = index; clicked = index; } // Draw cell background const bg_color = if (is_selected) colors.cell_selected else if (is_hovered) colors.cell_hover else colors.cell_bg; ctx.pushCommand(Command.rect(cell_x, cell_y, cell_w, cell_h, bg_color)); // Store cell info if (cell_count < MAX_VISIBLE_CELLS) { S.cells[cell_count] = .{ .index = index, .bounds = cell_bounds, .row = row, .col = col, .selected = is_selected, .hovered = is_hovered, }; cell_count += 1; } } } // End clip ctx.pushCommand(.clip_end); // Draw scrollbar if needed if (needs_scroll) { drawScrollbar(ctx, bounds, state.scroll_y, total_height, content_h, colors); } return .{ .visible_cells = S.cells[0..cell_count], .clicked = clicked, .double_clicked = null, // TODO: implement double-click tracking .bounds = bounds, .content_rect = Layout.Rect{ .x = content_x, .y = content_y, .w = content_w, .h = content_h, }, .total_height = total_height, .needs_scroll = needs_scroll, }; } fn drawScrollbar(ctx: *Context, bounds: Layout.Rect, scroll_y: i32, total_height: u32, visible_height: u32, colors: Colors) void { const scrollbar_width: u32 = 8; const scrollbar_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(scrollbar_width)) - 2; const scrollbar_y = bounds.y + 2; const scrollbar_h = bounds.h -| 4; // Track ctx.pushCommand(Command.rect(scrollbar_x, scrollbar_y, scrollbar_width, scrollbar_h, colors.scrollbar)); // Thumb const thumb_ratio = @as(f32, @floatFromInt(visible_height)) / @as(f32, @floatFromInt(total_height)); const thumb_h = @max(20, @as(u32, @intFromFloat(@as(f32, @floatFromInt(scrollbar_h)) * thumb_ratio))); const scroll_ratio = @as(f32, @floatFromInt(scroll_y)) / @as(f32, @floatFromInt(total_height - visible_height)); const thumb_y = scrollbar_y + @as(i32, @intFromFloat(@as(f32, @floatFromInt(scrollbar_h - thumb_h)) * scroll_ratio)); ctx.pushCommand(Command.rect(scrollbar_x, thumb_y, scrollbar_width, thumb_h, colors.scrollbar_thumb)); } // ============================================================================= // Tests // ============================================================================= test "grid state navigation" { var state = State.init(); const total = 12; const cols: u16 = 3; state.selectNext(total, cols); try std.testing.expectEqual(@as(?usize, 0), state.selected); state.selectNext(total, cols); try std.testing.expectEqual(@as(?usize, 1), state.selected); state.selectDown(total, cols); try std.testing.expectEqual(@as(?usize, 4), state.selected); state.selectUp(total, cols); try std.testing.expectEqual(@as(?usize, 1), state.selected); } test "grid generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State.init(); ctx.beginFrame(); ctx.layout.row_height = 400; const result = grid(&ctx, &state, 9, .{ .columns = 3 }, .{}); // Should have visible cells try std.testing.expect(result.visible_cells.len > 0); try std.testing.expect(ctx.commands.items.len >= 1); ctx.endFrame(); } test "grid cell info" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State.init(); ctx.beginFrame(); ctx.layout.row_height = 400; const result = grid(&ctx, &state, 6, .{ .columns = 3 }, .{}); // Should have 6 visible cells (2 rows x 3 cols) try std.testing.expectEqual(@as(usize, 6), result.visible_cells.len); // Check first cell try std.testing.expectEqual(@as(usize, 0), result.visible_cells[0].index); try std.testing.expectEqual(@as(usize, 0), result.visible_cells[0].row); try std.testing.expectEqual(@as(usize, 0), result.visible_cells[0].col); ctx.endFrame(); } test "empty grid" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State.init(); ctx.beginFrame(); ctx.layout.row_height = 400; const result = grid(&ctx, &state, 0, .{}, .{}); try std.testing.expectEqual(@as(usize, 0), result.visible_cells.len); ctx.endFrame(); }