Z-Design V2: Scrollbars más anchos para mejor usabilidad. - list.zig: 8→14px - grid.zig: 8→14px - advanced_table/drawing.zig: 12→14px - virtual_advanced_table/drawing.zig: 12→14px - virtual_scroll.zig: 12→14px (default config) - table/render.zig: 12→14px El bisel interno Laravel ya estaba implementado en drawBeveledRect. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
442 lines
14 KiB
Zig
442 lines
14 KiB
Zig
//! 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 = 14; // Z-Design V2: más ancho
|
|
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();
|
|
}
|