zcatgui/src/widgets/grid.zig
R.Eugenio 6eae44dcfd style(scrollbar): Aumentar ancho scrollbars (8/12→14px)
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>
2025-12-30 18:24:46 +01:00

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();
}