Widgets implemented (13 total): - Label: Static text with alignment - Button: With importance levels (primary/normal/danger) - TextInput: Single-line text entry with cursor - Checkbox: Boolean toggle - Select: Dropdown selection - List: Scrollable selectable list - Focus: Focus manager with tab navigation - Table: Editable table with dirty tracking, keyboard nav - Split: HSplit/VSplit draggable panels - Panel: Container with title bar, collapsible - Modal: Dialogs (alert, confirm, inputDialog) - AutoComplete: ComboBox with prefix/contains/fuzzy matching Core improvements: - InputState now tracks keyboard state (keys_down, key_events) - Full keyboard navigation for Table widget Research documentation: - WIDGET_COMPARISON.md: zcatgui vs DVUI vs Gio vs zcatui - SIMIFACTU_ADVANCEDTABLE.md: Analysis of 10K LOC table component - LEGO_PANELS_SYSTEM.md: Modular panel composition architecture Examples: - widgets_demo.zig: All basic widgets showcase - table_demo.zig: Table, Split, Panel demonstration All tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
983 lines
29 KiB
Zig
983 lines
29 KiB
Zig
//! Table Widget - Editable data table
|
|
//!
|
|
//! A full-featured table widget with:
|
|
//! - Keyboard navigation (arrows, Tab, Enter, Escape)
|
|
//! - In-place cell editing
|
|
//! - Row state indicators (new, modified, deleted)
|
|
//! - Column headers with optional sorting
|
|
//! - Virtualized rendering (only visible rows)
|
|
//! - Scrolling support
|
|
|
|
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");
|
|
const text_input = @import("text_input.zig");
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
/// Row state for dirty tracking
|
|
pub const RowState = enum {
|
|
/// Unchanged from original
|
|
clean,
|
|
/// Newly added row
|
|
new,
|
|
/// Modified row
|
|
modified,
|
|
/// Marked for deletion
|
|
deleted,
|
|
};
|
|
|
|
/// Column type for formatting/validation
|
|
pub const ColumnType = enum {
|
|
text,
|
|
number,
|
|
money,
|
|
date,
|
|
select,
|
|
};
|
|
|
|
/// Column definition
|
|
pub const Column = struct {
|
|
/// Column header text
|
|
name: []const u8,
|
|
/// Column width in pixels
|
|
width: u32,
|
|
/// Column type for formatting
|
|
column_type: ColumnType = .text,
|
|
/// Whether cells in this column are editable
|
|
editable: bool = true,
|
|
/// Minimum width when resizing
|
|
min_width: u32 = 40,
|
|
};
|
|
|
|
/// Table configuration
|
|
pub const TableConfig = struct {
|
|
/// Height of header row
|
|
header_height: u32 = 28,
|
|
/// Height of each data row
|
|
row_height: u32 = 24,
|
|
/// Show row state indicators
|
|
show_state_indicators: bool = true,
|
|
/// Width of state indicator column
|
|
state_indicator_width: u32 = 24,
|
|
/// Allow keyboard navigation
|
|
keyboard_nav: bool = true,
|
|
/// Allow cell editing
|
|
allow_edit: bool = true,
|
|
/// Show column headers
|
|
show_headers: bool = true,
|
|
/// Alternating row colors
|
|
alternating_rows: bool = true,
|
|
};
|
|
|
|
/// Table colors
|
|
pub const TableColors = struct {
|
|
header_bg: Style.Color = Style.Color.rgb(50, 50, 50),
|
|
header_fg: Style.Color = Style.Color.rgb(220, 220, 220),
|
|
row_even: Style.Color = Style.Color.rgb(35, 35, 35),
|
|
row_odd: Style.Color = Style.Color.rgb(40, 40, 40),
|
|
row_hover: Style.Color = Style.Color.rgb(50, 50, 60),
|
|
row_selected: Style.Color = Style.Color.rgb(66, 135, 245),
|
|
cell_editing: Style.Color = Style.Color.rgb(60, 60, 80),
|
|
cell_text: Style.Color = Style.Color.rgb(220, 220, 220),
|
|
cell_text_selected: Style.Color = Style.Color.rgb(255, 255, 255),
|
|
border: Style.Color = Style.Color.rgb(60, 60, 60),
|
|
state_new: Style.Color = Style.Color.rgb(76, 175, 80),
|
|
state_modified: Style.Color = Style.Color.rgb(255, 152, 0),
|
|
state_deleted: Style.Color = Style.Color.rgb(244, 67, 54),
|
|
};
|
|
|
|
/// Result of table interaction
|
|
pub const TableResult = struct {
|
|
/// Cell was selected
|
|
selection_changed: bool = false,
|
|
/// Cell value was edited
|
|
cell_edited: bool = false,
|
|
/// Row was added
|
|
row_added: bool = false,
|
|
/// Row was deleted
|
|
row_deleted: bool = false,
|
|
/// Editing started
|
|
edit_started: bool = false,
|
|
/// Editing ended
|
|
edit_ended: bool = false,
|
|
};
|
|
|
|
// =============================================================================
|
|
// Table State
|
|
// =============================================================================
|
|
|
|
/// Maximum columns supported
|
|
pub const MAX_COLUMNS = 32;
|
|
/// Maximum edit buffer size
|
|
pub const MAX_EDIT_BUFFER = 256;
|
|
|
|
/// Table state (caller-managed)
|
|
pub const TableState = struct {
|
|
/// Number of rows
|
|
row_count: usize = 0,
|
|
|
|
/// Selected row (-1 for none)
|
|
selected_row: i32 = -1,
|
|
/// Selected column (-1 for none)
|
|
selected_col: i32 = -1,
|
|
|
|
/// Whether a cell is being edited
|
|
editing: bool = false,
|
|
/// Edit buffer
|
|
edit_buffer: [MAX_EDIT_BUFFER]u8 = undefined,
|
|
/// Edit state (for TextInput)
|
|
edit_state: text_input.TextInputState = undefined,
|
|
|
|
/// Scroll offset (first visible row)
|
|
scroll_row: usize = 0,
|
|
/// Horizontal scroll offset
|
|
scroll_x: i32 = 0,
|
|
|
|
/// Whether table has focus
|
|
focused: bool = false,
|
|
|
|
/// Row states for dirty tracking
|
|
row_states: [1024]RowState = [_]RowState{.clean} ** 1024,
|
|
|
|
const Self = @This();
|
|
|
|
/// Initialize table state
|
|
pub fn init() Self {
|
|
var state = Self{};
|
|
state.edit_state = text_input.TextInputState.init(&state.edit_buffer);
|
|
return state;
|
|
}
|
|
|
|
/// Set row count
|
|
pub fn setRowCount(self: *Self, count: usize) void {
|
|
self.row_count = count;
|
|
// Reset states for new rows
|
|
for (0..@min(count, self.row_states.len)) |i| {
|
|
if (self.row_states[i] == .clean) {
|
|
// Keep existing state
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get selected cell
|
|
pub fn selectedCell(self: Self) ?struct { row: usize, col: usize } {
|
|
if (self.selected_row < 0 or self.selected_col < 0) return null;
|
|
return .{
|
|
.row = @intCast(self.selected_row),
|
|
.col = @intCast(self.selected_col),
|
|
};
|
|
}
|
|
|
|
/// Select a cell
|
|
pub fn selectCell(self: *Self, row: usize, col: usize) void {
|
|
self.selected_row = @intCast(row);
|
|
self.selected_col = @intCast(col);
|
|
}
|
|
|
|
/// Clear selection
|
|
pub fn clearSelection(self: *Self) void {
|
|
self.selected_row = -1;
|
|
self.selected_col = -1;
|
|
self.editing = false;
|
|
}
|
|
|
|
/// Start editing current cell
|
|
pub fn startEditing(self: *Self, initial_text: []const u8) void {
|
|
self.editing = true;
|
|
self.edit_state.setText(initial_text);
|
|
self.edit_state.focused = true;
|
|
}
|
|
|
|
/// Stop editing
|
|
pub fn stopEditing(self: *Self) void {
|
|
self.editing = false;
|
|
self.edit_state.focused = false;
|
|
}
|
|
|
|
/// Get edit text
|
|
pub fn getEditText(self: *Self) []const u8 {
|
|
return self.edit_state.text();
|
|
}
|
|
|
|
/// Mark row as modified
|
|
pub fn markModified(self: *Self, row: usize) void {
|
|
if (row < self.row_states.len) {
|
|
if (self.row_states[row] == .clean) {
|
|
self.row_states[row] = .modified;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mark row as new
|
|
pub fn markNew(self: *Self, row: usize) void {
|
|
if (row < self.row_states.len) {
|
|
self.row_states[row] = .new;
|
|
}
|
|
}
|
|
|
|
/// Mark row as deleted
|
|
pub fn markDeleted(self: *Self, row: usize) void {
|
|
if (row < self.row_states.len) {
|
|
self.row_states[row] = .deleted;
|
|
}
|
|
}
|
|
|
|
/// Get row state
|
|
pub fn getRowState(self: Self, row: usize) RowState {
|
|
if (row < self.row_states.len) {
|
|
return self.row_states[row];
|
|
}
|
|
return .clean;
|
|
}
|
|
|
|
/// Ensure selected row is visible
|
|
pub fn ensureVisible(self: *Self, visible_rows: usize) void {
|
|
if (self.selected_row < 0) return;
|
|
const row: usize = @intCast(self.selected_row);
|
|
|
|
if (row < self.scroll_row) {
|
|
self.scroll_row = row;
|
|
} else if (row >= self.scroll_row + visible_rows) {
|
|
self.scroll_row = row - visible_rows + 1;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Navigation
|
|
// =========================================================================
|
|
|
|
/// Move selection up
|
|
pub fn moveUp(self: *Self) void {
|
|
if (self.selected_row > 0) {
|
|
self.selected_row -= 1;
|
|
}
|
|
}
|
|
|
|
/// Move selection down
|
|
pub fn moveDown(self: *Self) void {
|
|
if (self.selected_row < @as(i32, @intCast(self.row_count)) - 1) {
|
|
self.selected_row += 1;
|
|
}
|
|
}
|
|
|
|
/// Move selection left
|
|
pub fn moveLeft(self: *Self) void {
|
|
if (self.selected_col > 0) {
|
|
self.selected_col -= 1;
|
|
}
|
|
}
|
|
|
|
/// Move selection right
|
|
pub fn moveRight(self: *Self, col_count: usize) void {
|
|
if (self.selected_col < @as(i32, @intCast(col_count)) - 1) {
|
|
self.selected_col += 1;
|
|
}
|
|
}
|
|
|
|
/// Move to first row
|
|
pub fn moveToFirst(self: *Self) void {
|
|
if (self.row_count > 0) {
|
|
self.selected_row = 0;
|
|
}
|
|
}
|
|
|
|
/// Move to last row
|
|
pub fn moveToLast(self: *Self) void {
|
|
if (self.row_count > 0) {
|
|
self.selected_row = @intCast(self.row_count - 1);
|
|
}
|
|
}
|
|
|
|
/// Page up
|
|
pub fn pageUp(self: *Self, visible_rows: usize) void {
|
|
if (self.selected_row > 0) {
|
|
const jump = @as(i32, @intCast(visible_rows));
|
|
self.selected_row = @max(0, self.selected_row - jump);
|
|
}
|
|
}
|
|
|
|
/// Page down
|
|
pub fn pageDown(self: *Self, visible_rows: usize) void {
|
|
const max_row = @as(i32, @intCast(self.row_count)) - 1;
|
|
if (self.selected_row < max_row) {
|
|
const jump = @as(i32, @intCast(visible_rows));
|
|
self.selected_row = @min(max_row, self.selected_row + jump);
|
|
}
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Table Widget
|
|
// =============================================================================
|
|
|
|
/// Cell data provider callback
|
|
pub const CellDataFn = *const fn (row: usize, col: usize) []const u8;
|
|
|
|
/// Cell edit callback (called when edit is committed)
|
|
pub const CellEditFn = *const fn (row: usize, col: usize, new_value: []const u8) void;
|
|
|
|
/// Draw a table
|
|
pub fn table(
|
|
ctx: *Context,
|
|
state: *TableState,
|
|
columns: []const Column,
|
|
get_cell: CellDataFn,
|
|
) TableResult {
|
|
return tableEx(ctx, state, columns, get_cell, null, .{}, .{});
|
|
}
|
|
|
|
/// Draw a table with full options
|
|
pub fn tableEx(
|
|
ctx: *Context,
|
|
state: *TableState,
|
|
columns: []const Column,
|
|
get_cell: CellDataFn,
|
|
on_edit: ?CellEditFn,
|
|
config: TableConfig,
|
|
colors: TableColors,
|
|
) TableResult {
|
|
const bounds = ctx.layout.nextRect();
|
|
return tableRect(ctx, bounds, state, columns, get_cell, on_edit, config, colors);
|
|
}
|
|
|
|
/// Draw a table in a specific rectangle
|
|
pub fn tableRect(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
state: *TableState,
|
|
columns: []const Column,
|
|
get_cell: CellDataFn,
|
|
on_edit: ?CellEditFn,
|
|
config: TableConfig,
|
|
colors: TableColors,
|
|
) TableResult {
|
|
var result = TableResult{};
|
|
|
|
if (bounds.isEmpty() or columns.len == 0) return result;
|
|
|
|
const mouse = ctx.input.mousePos();
|
|
const table_hovered = bounds.contains(mouse.x, mouse.y);
|
|
|
|
// Click for focus
|
|
if (table_hovered and ctx.input.mousePressed(.left)) {
|
|
state.focused = true;
|
|
}
|
|
|
|
// Calculate dimensions
|
|
const header_h = if (config.show_headers) config.header_height else 0;
|
|
const state_col_w = if (config.show_state_indicators) config.state_indicator_width else 0;
|
|
|
|
// Calculate total column width
|
|
var total_col_width: u32 = state_col_w;
|
|
for (columns) |col| {
|
|
total_col_width += col.width;
|
|
}
|
|
|
|
// Data area
|
|
const data_area = Layout.Rect.init(
|
|
bounds.x,
|
|
bounds.y + @as(i32, @intCast(header_h)),
|
|
bounds.w,
|
|
bounds.h -| header_h,
|
|
);
|
|
|
|
// Visible rows
|
|
const visible_rows = data_area.h / config.row_height;
|
|
|
|
// Clamp scroll
|
|
if (state.row_count <= visible_rows) {
|
|
state.scroll_row = 0;
|
|
} else if (state.scroll_row > state.row_count - visible_rows) {
|
|
state.scroll_row = state.row_count - visible_rows;
|
|
}
|
|
|
|
// Handle scroll wheel
|
|
if (table_hovered) {
|
|
if (ctx.input.scroll_y < 0 and state.scroll_row > 0) {
|
|
state.scroll_row -= 1;
|
|
} else if (ctx.input.scroll_y > 0 and state.scroll_row < state.row_count -| visible_rows) {
|
|
state.scroll_row += 1;
|
|
}
|
|
}
|
|
|
|
// Draw background
|
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.row_even));
|
|
|
|
// Draw border
|
|
const border_color = if (state.focused) Style.Color.primary else colors.border;
|
|
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color));
|
|
|
|
// Clip to table bounds
|
|
ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h));
|
|
|
|
// Draw header
|
|
if (config.show_headers) {
|
|
drawHeader(ctx, bounds, columns, state_col_w, config, colors);
|
|
}
|
|
|
|
// Draw rows
|
|
const end_row = @min(state.scroll_row + visible_rows + 1, state.row_count);
|
|
var row_y = data_area.y;
|
|
|
|
for (state.scroll_row..end_row) |row| {
|
|
if (row_y >= data_area.bottom()) break;
|
|
|
|
const row_bounds = Layout.Rect.init(
|
|
data_area.x,
|
|
row_y,
|
|
data_area.w,
|
|
config.row_height,
|
|
);
|
|
|
|
const row_result = drawRow(
|
|
ctx,
|
|
row_bounds,
|
|
state,
|
|
row,
|
|
columns,
|
|
get_cell,
|
|
on_edit,
|
|
state_col_w,
|
|
config,
|
|
colors,
|
|
);
|
|
|
|
if (row_result.selection_changed) result.selection_changed = true;
|
|
if (row_result.cell_edited) result.cell_edited = true;
|
|
if (row_result.edit_started) result.edit_started = true;
|
|
if (row_result.edit_ended) result.edit_ended = true;
|
|
|
|
row_y += @as(i32, @intCast(config.row_height));
|
|
}
|
|
|
|
// Draw scrollbar if needed
|
|
if (state.row_count > visible_rows) {
|
|
drawScrollbar(ctx, bounds, state, visible_rows, config, colors);
|
|
}
|
|
|
|
// End clip
|
|
ctx.pushCommand(Command.clipEnd());
|
|
|
|
// Handle keyboard if focused and not editing
|
|
if (state.focused and config.keyboard_nav and !state.editing) {
|
|
handleKeyboard(ctx, state, columns.len, visible_rows, get_cell, on_edit, config, &result);
|
|
}
|
|
|
|
// Ensure selection is visible after navigation
|
|
state.ensureVisible(visible_rows);
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Drawing Helpers
|
|
// =============================================================================
|
|
|
|
fn drawHeader(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
columns: []const Column,
|
|
state_col_w: u32,
|
|
config: TableConfig,
|
|
colors: TableColors,
|
|
) void {
|
|
const header_bounds = Layout.Rect.init(
|
|
bounds.x,
|
|
bounds.y,
|
|
bounds.w,
|
|
config.header_height,
|
|
);
|
|
|
|
// Header background
|
|
ctx.pushCommand(Command.rect(
|
|
header_bounds.x,
|
|
header_bounds.y,
|
|
header_bounds.w,
|
|
header_bounds.h,
|
|
colors.header_bg,
|
|
));
|
|
|
|
// Header border
|
|
ctx.pushCommand(Command.line(
|
|
header_bounds.x,
|
|
header_bounds.bottom() - 1,
|
|
header_bounds.right(),
|
|
header_bounds.bottom() - 1,
|
|
colors.border,
|
|
));
|
|
|
|
// State indicator column header (empty)
|
|
var col_x = bounds.x + @as(i32, @intCast(state_col_w));
|
|
|
|
// Draw column headers
|
|
const char_height: u32 = 8;
|
|
const text_y = header_bounds.y + @as(i32, @intCast((config.header_height -| char_height) / 2));
|
|
|
|
for (columns) |col| {
|
|
// Column text
|
|
const text_x = col_x + 4; // Padding
|
|
ctx.pushCommand(Command.text(text_x, text_y, col.name, colors.header_fg));
|
|
|
|
// Column separator
|
|
col_x += @as(i32, @intCast(col.width));
|
|
ctx.pushCommand(Command.line(
|
|
col_x,
|
|
header_bounds.y,
|
|
col_x,
|
|
header_bounds.bottom(),
|
|
colors.border,
|
|
));
|
|
}
|
|
}
|
|
|
|
fn drawRow(
|
|
ctx: *Context,
|
|
row_bounds: Layout.Rect,
|
|
state: *TableState,
|
|
row: usize,
|
|
columns: []const Column,
|
|
get_cell: CellDataFn,
|
|
on_edit: ?CellEditFn,
|
|
state_col_w: u32,
|
|
config: TableConfig,
|
|
colors: TableColors,
|
|
) TableResult {
|
|
var result = TableResult{};
|
|
|
|
const mouse = ctx.input.mousePos();
|
|
const is_selected = state.selected_row == @as(i32, @intCast(row));
|
|
const row_hovered = row_bounds.contains(mouse.x, mouse.y);
|
|
|
|
// Row background
|
|
const row_bg = if (is_selected)
|
|
colors.row_selected
|
|
else if (row_hovered)
|
|
colors.row_hover
|
|
else if (config.alternating_rows and row % 2 == 1)
|
|
colors.row_odd
|
|
else
|
|
colors.row_even;
|
|
|
|
ctx.pushCommand(Command.rect(row_bounds.x, row_bounds.y, row_bounds.w, row_bounds.h, row_bg));
|
|
|
|
// State indicator
|
|
if (config.show_state_indicators) {
|
|
const indicator_bounds = Layout.Rect.init(
|
|
row_bounds.x,
|
|
row_bounds.y,
|
|
state_col_w,
|
|
config.row_height,
|
|
);
|
|
drawStateIndicator(ctx, indicator_bounds, state.getRowState(row), colors);
|
|
}
|
|
|
|
// Draw cells
|
|
var col_x = row_bounds.x + @as(i32, @intCast(state_col_w));
|
|
const char_height: u32 = 8;
|
|
const text_y = row_bounds.y + @as(i32, @intCast((config.row_height -| char_height) / 2));
|
|
|
|
for (columns, 0..) |col, col_idx| {
|
|
const cell_bounds = Layout.Rect.init(
|
|
col_x,
|
|
row_bounds.y,
|
|
col.width,
|
|
config.row_height,
|
|
);
|
|
|
|
const is_cell_selected = is_selected and state.selected_col == @as(i32, @intCast(col_idx));
|
|
const cell_hovered = cell_bounds.contains(mouse.x, mouse.y);
|
|
|
|
// Cell selection highlight
|
|
if (is_cell_selected and !state.editing) {
|
|
ctx.pushCommand(Command.rectOutline(
|
|
cell_bounds.x + 1,
|
|
cell_bounds.y + 1,
|
|
cell_bounds.w - 2,
|
|
cell_bounds.h - 2,
|
|
Style.Color.primary,
|
|
));
|
|
}
|
|
|
|
// Handle cell click
|
|
if (cell_hovered and ctx.input.mousePressed(.left)) {
|
|
const was_selected = is_cell_selected;
|
|
state.selectCell(row, col_idx);
|
|
result.selection_changed = true;
|
|
|
|
// Double-click to edit (or click on already selected)
|
|
if (was_selected and config.allow_edit and col.editable) {
|
|
const cell_text = get_cell(row, col_idx);
|
|
state.startEditing(cell_text);
|
|
result.edit_started = true;
|
|
}
|
|
}
|
|
|
|
// Draw cell content
|
|
if (state.editing and is_cell_selected) {
|
|
// Draw edit field
|
|
ctx.pushCommand(Command.rect(
|
|
cell_bounds.x + 1,
|
|
cell_bounds.y + 1,
|
|
cell_bounds.w - 2,
|
|
cell_bounds.h - 2,
|
|
colors.cell_editing,
|
|
));
|
|
|
|
// Handle text input
|
|
const text_in = ctx.input.getTextInput();
|
|
if (text_in.len > 0) {
|
|
state.edit_state.insert(text_in);
|
|
}
|
|
|
|
// Draw edit text
|
|
const edit_text = state.getEditText();
|
|
ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.cell_text));
|
|
|
|
// Draw cursor
|
|
const cursor_x = col_x + 4 + @as(i32, @intCast(state.edit_state.cursor * 8));
|
|
ctx.pushCommand(Command.rect(
|
|
cursor_x,
|
|
cell_bounds.y + 2,
|
|
2,
|
|
cell_bounds.h - 4,
|
|
colors.cell_text,
|
|
));
|
|
} else {
|
|
// Normal cell display
|
|
const cell_text = get_cell(row, col_idx);
|
|
const text_color = if (is_selected) colors.cell_text_selected else colors.cell_text;
|
|
ctx.pushCommand(Command.text(col_x + 4, text_y, cell_text, text_color));
|
|
}
|
|
|
|
// Column separator
|
|
col_x += @as(i32, @intCast(col.width));
|
|
ctx.pushCommand(Command.line(
|
|
col_x,
|
|
row_bounds.y,
|
|
col_x,
|
|
row_bounds.bottom(),
|
|
colors.border,
|
|
));
|
|
}
|
|
|
|
// Row bottom border
|
|
ctx.pushCommand(Command.line(
|
|
row_bounds.x,
|
|
row_bounds.bottom() - 1,
|
|
row_bounds.right(),
|
|
row_bounds.bottom() - 1,
|
|
colors.border,
|
|
));
|
|
|
|
// Handle edit commit on Enter or when moving away
|
|
if (state.editing and is_selected) {
|
|
// This will be handled by keyboard handler
|
|
_ = on_edit;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
fn drawStateIndicator(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
row_state: RowState,
|
|
colors: TableColors,
|
|
) void {
|
|
const indicator_size: u32 = 8;
|
|
const x = bounds.x + @as(i32, @intCast((bounds.w -| indicator_size) / 2));
|
|
const y = bounds.y + @as(i32, @intCast((bounds.h -| indicator_size) / 2));
|
|
|
|
const color: ?Style.Color = switch (row_state) {
|
|
.clean => null,
|
|
.new => colors.state_new,
|
|
.modified => colors.state_modified,
|
|
.deleted => colors.state_deleted,
|
|
};
|
|
|
|
if (color) |c| {
|
|
ctx.pushCommand(Command.rect(x, y, indicator_size, indicator_size, c));
|
|
}
|
|
}
|
|
|
|
fn drawScrollbar(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
state: *TableState,
|
|
visible_rows: usize,
|
|
config: TableConfig,
|
|
colors: TableColors,
|
|
) void {
|
|
_ = config;
|
|
|
|
const scrollbar_w: u32 = 12;
|
|
const header_h: u32 = 28; // Assume header
|
|
|
|
const track_x = bounds.right() - @as(i32, @intCast(scrollbar_w));
|
|
const track_y = bounds.y + @as(i32, @intCast(header_h));
|
|
const track_h = bounds.h -| header_h;
|
|
|
|
// Track
|
|
ctx.pushCommand(Command.rect(
|
|
track_x,
|
|
track_y,
|
|
scrollbar_w,
|
|
track_h,
|
|
colors.row_odd,
|
|
));
|
|
|
|
// Thumb
|
|
if (state.row_count > 0) {
|
|
const visible_rows_u32: u32 = @intCast(visible_rows);
|
|
const row_count_u32: u32 = @intCast(state.row_count);
|
|
const thumb_h: u32 = @max((visible_rows_u32 * track_h) / row_count_u32, 20);
|
|
const scroll_range = state.row_count - visible_rows;
|
|
const scroll_row_u32: u32 = @intCast(state.scroll_row);
|
|
const scroll_range_u32: u32 = @intCast(scroll_range);
|
|
const thumb_offset: u32 = if (scroll_range > 0)
|
|
(scroll_row_u32 * (track_h - thumb_h)) / scroll_range_u32
|
|
else
|
|
0;
|
|
|
|
ctx.pushCommand(Command.rect(
|
|
track_x + 2,
|
|
track_y + @as(i32, @intCast(thumb_offset)),
|
|
scrollbar_w - 4,
|
|
thumb_h,
|
|
colors.header_bg,
|
|
));
|
|
}
|
|
}
|
|
|
|
fn handleKeyboard(
|
|
ctx: *Context,
|
|
state: *TableState,
|
|
col_count: usize,
|
|
visible_rows: usize,
|
|
get_cell: CellDataFn,
|
|
on_edit: ?CellEditFn,
|
|
config: TableConfig,
|
|
result: *TableResult,
|
|
) void {
|
|
// Check for navigation keys
|
|
if (ctx.input.navKeyPressed()) |key| {
|
|
switch (key) {
|
|
.up => {
|
|
if (state.selected_row > 0) {
|
|
state.selected_row -= 1;
|
|
result.selection_changed = true;
|
|
state.ensureVisible(visible_rows);
|
|
}
|
|
},
|
|
.down => {
|
|
if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) {
|
|
state.selected_row += 1;
|
|
result.selection_changed = true;
|
|
state.ensureVisible(visible_rows);
|
|
}
|
|
},
|
|
.left => {
|
|
if (state.selected_col > 0) {
|
|
state.selected_col -= 1;
|
|
result.selection_changed = true;
|
|
}
|
|
},
|
|
.right => {
|
|
if (state.selected_col < @as(i32, @intCast(col_count)) - 1) {
|
|
state.selected_col += 1;
|
|
result.selection_changed = true;
|
|
}
|
|
},
|
|
.home => {
|
|
if (ctx.input.modifiers.ctrl) {
|
|
// Ctrl+Home: go to first row
|
|
state.selected_row = 0;
|
|
state.scroll_row = 0;
|
|
} else {
|
|
// Home: go to first column
|
|
state.selected_col = 0;
|
|
}
|
|
result.selection_changed = true;
|
|
},
|
|
.end => {
|
|
if (ctx.input.modifiers.ctrl) {
|
|
// Ctrl+End: go to last row
|
|
state.selected_row = @as(i32, @intCast(state.row_count)) - 1;
|
|
state.ensureVisible(visible_rows);
|
|
} else {
|
|
// End: go to last column
|
|
state.selected_col = @as(i32, @intCast(col_count)) - 1;
|
|
}
|
|
result.selection_changed = true;
|
|
},
|
|
.page_up => {
|
|
const jump = @as(i32, @intCast(visible_rows));
|
|
state.selected_row = @max(0, state.selected_row - jump);
|
|
state.ensureVisible(visible_rows);
|
|
result.selection_changed = true;
|
|
},
|
|
.page_down => {
|
|
const jump = @as(i32, @intCast(visible_rows));
|
|
const max_row = @as(i32, @intCast(state.row_count)) - 1;
|
|
state.selected_row = @min(max_row, state.selected_row + jump);
|
|
state.ensureVisible(visible_rows);
|
|
result.selection_changed = true;
|
|
},
|
|
.tab => {
|
|
// Tab: next cell, Shift+Tab: previous cell
|
|
if (ctx.input.modifiers.shift) {
|
|
if (state.selected_col > 0) {
|
|
state.selected_col -= 1;
|
|
} else if (state.selected_row > 0) {
|
|
state.selected_row -= 1;
|
|
state.selected_col = @as(i32, @intCast(col_count)) - 1;
|
|
}
|
|
} else {
|
|
if (state.selected_col < @as(i32, @intCast(col_count)) - 1) {
|
|
state.selected_col += 1;
|
|
} else if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) {
|
|
state.selected_row += 1;
|
|
state.selected_col = 0;
|
|
}
|
|
}
|
|
state.ensureVisible(visible_rows);
|
|
result.selection_changed = true;
|
|
},
|
|
.enter => {
|
|
// Enter: start editing if not editing
|
|
if (!state.editing and config.allow_edit) {
|
|
if (state.selectedCell()) |cell| {
|
|
const current_text = get_cell(cell.row, cell.col);
|
|
state.startEditing(current_text);
|
|
result.edit_started = true;
|
|
}
|
|
}
|
|
},
|
|
.escape => {
|
|
// Escape: cancel editing
|
|
if (state.editing) {
|
|
state.stopEditing();
|
|
result.edit_ended = true;
|
|
}
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
// F2 also starts editing
|
|
if (ctx.input.keyPressed(.f2) and !state.editing and config.allow_edit) {
|
|
if (state.selectedCell()) |cell| {
|
|
const current_text = get_cell(cell.row, cell.col);
|
|
state.startEditing(current_text);
|
|
result.edit_started = true;
|
|
}
|
|
}
|
|
|
|
// Handle edit commit for Enter during editing
|
|
if (state.editing and ctx.input.keyPressed(.enter)) {
|
|
if (on_edit) |edit_fn| {
|
|
if (state.selectedCell()) |cell| {
|
|
edit_fn(cell.row, cell.col, state.getEditText());
|
|
}
|
|
}
|
|
state.stopEditing();
|
|
result.cell_edited = true;
|
|
result.edit_ended = true;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
fn testGetCell(row: usize, col: usize) []const u8 {
|
|
_ = row;
|
|
_ = col;
|
|
return "test";
|
|
}
|
|
|
|
test "TableState init" {
|
|
var state = TableState.init();
|
|
try std.testing.expect(state.selectedCell() == null);
|
|
|
|
state.selectCell(2, 3);
|
|
const sel = state.selectedCell().?;
|
|
try std.testing.expectEqual(@as(usize, 2), sel.row);
|
|
try std.testing.expectEqual(@as(usize, 3), sel.col);
|
|
}
|
|
|
|
test "TableState navigation" {
|
|
var state = TableState.init();
|
|
state.setRowCount(10);
|
|
state.selectCell(5, 2);
|
|
|
|
state.moveUp();
|
|
try std.testing.expectEqual(@as(i32, 4), state.selected_row);
|
|
|
|
state.moveDown();
|
|
try std.testing.expectEqual(@as(i32, 5), state.selected_row);
|
|
|
|
state.moveToFirst();
|
|
try std.testing.expectEqual(@as(i32, 0), state.selected_row);
|
|
|
|
state.moveToLast();
|
|
try std.testing.expectEqual(@as(i32, 9), state.selected_row);
|
|
}
|
|
|
|
test "TableState row states" {
|
|
var state = TableState.init();
|
|
state.setRowCount(5);
|
|
|
|
try std.testing.expectEqual(RowState.clean, state.getRowState(0));
|
|
|
|
state.markNew(0);
|
|
try std.testing.expectEqual(RowState.new, state.getRowState(0));
|
|
|
|
state.markModified(1);
|
|
try std.testing.expectEqual(RowState.modified, state.getRowState(1));
|
|
|
|
state.markDeleted(2);
|
|
try std.testing.expectEqual(RowState.deleted, state.getRowState(2));
|
|
}
|
|
|
|
test "TableState editing" {
|
|
var state = TableState.init();
|
|
|
|
try std.testing.expect(!state.editing);
|
|
|
|
state.startEditing("initial");
|
|
try std.testing.expect(state.editing);
|
|
try std.testing.expectEqualStrings("initial", state.getEditText());
|
|
|
|
state.stopEditing();
|
|
try std.testing.expect(!state.editing);
|
|
}
|
|
|
|
test "table generates commands" {
|
|
var ctx = Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = TableState.init();
|
|
state.setRowCount(5);
|
|
|
|
const columns = [_]Column{
|
|
.{ .name = "Name", .width = 150 },
|
|
.{ .name = "Value", .width = 100 },
|
|
};
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 200;
|
|
|
|
_ = table(&ctx, &state, &columns, testGetCell);
|
|
|
|
// Should generate many commands (background, headers, rows, etc.)
|
|
try std.testing.expect(ctx.commands.items.len > 10);
|
|
|
|
ctx.endFrame();
|
|
}
|