Añade rectángulo de fondo con row_normal color ANTES de dibujar las filas. Esto asegura que: - Tablas vacías muestren color de fondo correcto (no negro) - Áreas debajo de las últimas filas no queden sin pintar VirtualAdvancedTable ya tenía este comportamiento. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
392 lines
14 KiB
Zig
392 lines
14 KiB
Zig
//! AdvancedTable Widget - Schema-driven data table
|
|
//!
|
|
//! A full-featured table widget with:
|
|
//! - Schema-driven configuration (TableSchema + ColumnDef)
|
|
//! - Excel-style cell editing with overlay
|
|
//! - Auto-CRUD (automatic CREATE/UPDATE/DELETE detection)
|
|
//! - Keyboard navigation (arrows, Tab, Enter, Escape)
|
|
//! - Column sorting (click header)
|
|
//! - Row operations (Ctrl+N/A/B, Ctrl+arrows)
|
|
//! - Visual state indicators (normal, modified, new, deleted, error)
|
|
//! - Lookup & auto-fill for related data
|
|
//!
|
|
//! This module re-exports types from the advanced_table/ subdirectory.
|
|
|
|
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 table_core = @import("../table_core/table_core.zig");
|
|
|
|
// Import submodules
|
|
const drawing = @import("drawing.zig");
|
|
const input = @import("input.zig");
|
|
const helpers = @import("helpers.zig");
|
|
const sorting = @import("sorting.zig");
|
|
|
|
// Re-export types
|
|
pub const types = @import("types.zig");
|
|
pub const CellValue = types.CellValue;
|
|
pub const ColumnType = types.ColumnType;
|
|
pub const RowState = types.RowState;
|
|
pub const RowLockState = types.RowLockState;
|
|
pub const SortDirection = types.SortDirection;
|
|
pub const CRUDAction = types.CRUDAction;
|
|
pub const ValidationResult = types.ValidationResult;
|
|
pub const TableColors = types.TableColors;
|
|
pub const BasicColors = types.BasicColors;
|
|
pub const TableConfig = types.TableConfig;
|
|
pub const Row = types.Row;
|
|
|
|
// Re-export schema
|
|
pub const schema = @import("schema.zig");
|
|
pub const ColumnDef = schema.ColumnDef;
|
|
pub const ColumnAlign = schema.ColumnAlign;
|
|
pub const AutoFillMapping = schema.AutoFillMapping;
|
|
pub const SelectOption = schema.SelectOption;
|
|
pub const TableSchema = schema.TableSchema;
|
|
pub const DataStore = schema.DataStore;
|
|
|
|
// Re-export state
|
|
pub const state = @import("state.zig");
|
|
pub const AdvancedTableState = state.AdvancedTableState;
|
|
pub const AdvancedTableResult = state.AdvancedTableResult;
|
|
|
|
// Re-export datasource
|
|
pub const datasource = @import("datasource.zig");
|
|
pub const MemoryDataSource = datasource.MemoryDataSource;
|
|
|
|
// Re-export table_core types
|
|
pub const NavigateDirection = table_core.NavigateDirection;
|
|
|
|
// Re-export helpers for external use
|
|
pub const blendColor = helpers.blendColor;
|
|
pub const parseValue = helpers.parseValue;
|
|
pub const startsWithIgnoreCase = sorting.startsWithIgnoreCase;
|
|
|
|
// =============================================================================
|
|
// Public API
|
|
// =============================================================================
|
|
|
|
/// Draw an AdvancedTable with default layout
|
|
pub fn advancedTable(
|
|
ctx: *Context,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
) AdvancedTableResult {
|
|
return advancedTableEx(ctx, table_state, table_schema, null);
|
|
}
|
|
|
|
/// Draw an AdvancedTable with custom colors
|
|
pub fn advancedTableEx(
|
|
ctx: *Context,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
colors: ?*const TableColors,
|
|
) AdvancedTableResult {
|
|
const bounds = ctx.layout.nextRect();
|
|
return advancedTableRect(ctx, bounds, table_state, table_schema, colors);
|
|
}
|
|
|
|
/// Draw an AdvancedTable in a specific rectangle
|
|
pub fn advancedTableRect(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
custom_colors: ?*const TableColors,
|
|
) AdvancedTableResult {
|
|
var result = AdvancedTableResult{};
|
|
|
|
if (bounds.isEmpty() or table_schema.columns.len == 0) return result;
|
|
|
|
// Get colors
|
|
const default_colors = TableColors{};
|
|
const colors = custom_colors orelse table_schema.colors orelse &default_colors;
|
|
const config = table_schema.config;
|
|
|
|
// Ensure valid selection if table has data (like Table widget does)
|
|
if (table_state.getRowCount() > 0 and table_schema.columns.len > 0) {
|
|
if (table_state.selected_row < 0) table_state.selected_row = 0;
|
|
if (table_state.selected_col < 0) table_state.selected_col = 0;
|
|
}
|
|
|
|
// Generate unique ID for focus system
|
|
const widget_id: u64 = @intFromPtr(table_state);
|
|
|
|
// Register as focusable
|
|
ctx.registerFocusable(widget_id);
|
|
|
|
// Check mouse interaction
|
|
const mouse = ctx.input.mousePos();
|
|
const hovered = bounds.contains(mouse.x, mouse.y);
|
|
const clicked = hovered and ctx.input.mousePressed(.left);
|
|
|
|
if (clicked) {
|
|
ctx.requestFocus(widget_id);
|
|
result.clicked = true;
|
|
}
|
|
|
|
// Check if we have focus
|
|
const has_focus = ctx.hasFocus(widget_id);
|
|
table_state.nav.has_focus = has_focus;
|
|
|
|
// Calculate dimensions
|
|
const state_col_w: u32 = if (config.show_row_state_indicators) config.state_indicator_width else 0;
|
|
const header_h: u32 = if (config.show_headers) config.header_height else 0;
|
|
const content_h = bounds.h -| header_h;
|
|
const visible_rows: usize = @intCast(content_h / config.row_height);
|
|
|
|
// Begin clipping
|
|
ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h));
|
|
|
|
// Draw header
|
|
if (config.show_headers) {
|
|
drawing.drawHeader(ctx, bounds, table_state, table_schema, state_col_w, colors, &result);
|
|
}
|
|
|
|
// Calculate visible row range
|
|
const first_visible = table_state.nav.scroll_row;
|
|
const last_visible = @min(first_visible + visible_rows, table_state.getRowCount());
|
|
|
|
// Manejar clicks en filas (separado del renderizado)
|
|
input.handleRowClicks(ctx, bounds, table_state, table_schema, header_h, state_col_w, first_visible, last_visible, &result);
|
|
|
|
// Construir ColumnRenderDefs para la función unificada
|
|
var col_defs: [64]table_core.ColumnRenderDef = undefined;
|
|
var col_count: usize = 0;
|
|
for (table_schema.columns) |col| {
|
|
if (col_count >= 64) break;
|
|
col_defs[col_count] = .{
|
|
.width = col.width,
|
|
.visible = col.visible,
|
|
.text_align = 0, // Por ahora left-align
|
|
};
|
|
col_count += 1;
|
|
}
|
|
|
|
// Crear MemoryDataSource y dibujar filas con función unificada
|
|
var memory_ds = MemoryDataSource.init(table_state, table_schema.columns);
|
|
const data_src = memory_ds.toDataSource();
|
|
|
|
// Z-Design: Pintar fondo del área de contenido ANTES de las filas
|
|
// Esto asegura que tablas vacías o con pocas filas no muestren negro
|
|
ctx.pushCommand(Command.rect(
|
|
bounds.x,
|
|
bounds.y + @as(i32, @intCast(header_h)),
|
|
bounds.w,
|
|
content_h,
|
|
colors.row_normal,
|
|
));
|
|
|
|
// Construir RowRenderColors manualmente (los dos TableColors son tipos diferentes)
|
|
const render_colors = table_core.RowRenderColors{
|
|
.row_normal = colors.row_normal,
|
|
.row_alternate = colors.row_alternate,
|
|
.selected_row = colors.selected_row,
|
|
.selected_row_unfocus = colors.selected_row_unfocus,
|
|
.selected_cell = colors.selected_cell,
|
|
.selected_cell_unfocus = Style.Color.rgb(80, 80, 90), // Default similar a table_core
|
|
.text_normal = colors.text_normal,
|
|
.text_selected = colors.text_selected,
|
|
.border = colors.border,
|
|
.state_modified = colors.state_modified,
|
|
.state_new = colors.state_new,
|
|
.state_deleted = colors.state_deleted,
|
|
.state_error = colors.state_error,
|
|
};
|
|
|
|
var cell_buffer: [256]u8 = undefined;
|
|
_ = table_core.drawRowsWithDataSource(ctx, data_src, .{
|
|
.bounds_x = bounds.x,
|
|
.bounds_y = bounds.y + @as(i32, @intCast(header_h)),
|
|
.bounds_w = bounds.w,
|
|
.row_height = config.row_height,
|
|
.first_row = first_visible,
|
|
.last_row = last_visible,
|
|
.has_focus = has_focus,
|
|
.selected_row = table_state.selected_row,
|
|
.active_col = @intCast(@max(0, table_state.selected_col)),
|
|
.colors = render_colors,
|
|
.columns = col_defs[0..col_count],
|
|
.state_indicator_width = state_col_w,
|
|
.apply_state_colors = true,
|
|
.draw_row_borders = true,
|
|
.alternating_rows = config.alternating_rows,
|
|
.edit_buffer = &table_state.row_edit_buffer,
|
|
}, &cell_buffer);
|
|
|
|
// End clipping
|
|
ctx.pushCommand(Command.clipEnd());
|
|
|
|
// Draw focus ring (outside clip)
|
|
if (has_focus) {
|
|
if (Style.isFancy()) {
|
|
ctx.pushCommand(Command.focusRing(bounds.x, bounds.y, bounds.w, bounds.h, 4));
|
|
} else {
|
|
ctx.pushCommand(Command.rectOutline(
|
|
bounds.x - 1,
|
|
bounds.y - 1,
|
|
bounds.w + 2,
|
|
bounds.h + 2,
|
|
colors.focus_ring,
|
|
));
|
|
}
|
|
}
|
|
|
|
// Draw scrollbar if needed
|
|
if (table_state.getRowCount() > visible_rows) {
|
|
drawing.drawScrollbar(ctx, bounds, table_state, visible_rows, config, colors);
|
|
}
|
|
|
|
// Handle keyboard
|
|
if (has_focus) {
|
|
if (table_state.isEditing()) {
|
|
// Handle editing keyboard
|
|
input.handleEditingKeyboard(ctx, table_state, table_schema, &result);
|
|
|
|
// Draw editing overlay
|
|
drawing.drawEditingOverlay(ctx, bounds, table_state, table_schema, header_h, state_col_w, colors);
|
|
} else if (config.keyboard_nav) {
|
|
// Handle navigation keyboard
|
|
input.handleKeyboard(ctx, table_state, table_schema, visible_rows, &result);
|
|
}
|
|
}
|
|
|
|
// Ensure selection is visible
|
|
helpers.ensureSelectionVisible(table_state, visible_rows);
|
|
|
|
// Auto-CRUD detection (when row changes)
|
|
if (config.auto_crud_enabled and result.selection_changed and table_state.rowChanged()) {
|
|
result.crud_action = helpers.detectCRUDAction(table_state, table_schema);
|
|
|
|
// Capture snapshot of new row
|
|
if (table_state.selected_row >= 0) {
|
|
table_state.captureSnapshot(@intCast(table_state.selected_row)) catch {};
|
|
}
|
|
}
|
|
|
|
// Phase 8: Invoke callbacks
|
|
helpers.invokeCallbacks(ctx, table_state, table_schema, &result);
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "AdvancedTable basic rendering" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var table_state = AdvancedTableState.init(std.testing.allocator);
|
|
defer table_state.deinit();
|
|
|
|
const columns = [_]ColumnDef{
|
|
.{ .name = "id", .title = "ID", .width = 50, .editable = false },
|
|
.{ .name = "name", .title = "Name", .width = 150 },
|
|
.{ .name = "value", .title = "Value", .width = 100 },
|
|
};
|
|
|
|
const table_schema = TableSchema{
|
|
.table_name = "test",
|
|
.columns = &columns,
|
|
};
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 200;
|
|
|
|
_ = advancedTable(&ctx, &table_state, &table_schema);
|
|
|
|
// Should generate commands
|
|
try std.testing.expect(ctx.commands.items.len > 0);
|
|
|
|
ctx.endFrame();
|
|
}
|
|
|
|
test "blendColor" {
|
|
const white = Style.Color.rgb(255, 255, 255);
|
|
const black = Style.Color.rgb(0, 0, 0);
|
|
|
|
const gray = blendColor(white, black, 0.5);
|
|
try std.testing.expectEqual(@as(u8, 127), gray.r);
|
|
try std.testing.expectEqual(@as(u8, 127), gray.g);
|
|
try std.testing.expectEqual(@as(u8, 127), gray.b);
|
|
}
|
|
|
|
test "AdvancedTableResult lookup_success field" {
|
|
var result = AdvancedTableResult{};
|
|
|
|
// Default is null (no lookup attempted)
|
|
try std.testing.expect(result.lookup_success == null);
|
|
|
|
// Can be set to true (lookup found)
|
|
result.lookup_success = true;
|
|
try std.testing.expect(result.lookup_success.? == true);
|
|
|
|
// Can be set to false (lookup not found)
|
|
result.lookup_success = false;
|
|
try std.testing.expect(result.lookup_success.? == false);
|
|
}
|
|
|
|
test "AdvancedTableState callback fields" {
|
|
var table_state = AdvancedTableState.init(std.testing.allocator);
|
|
defer table_state.deinit();
|
|
|
|
// Initial state
|
|
try std.testing.expectEqual(@as(u64, 0), table_state.last_callback_time_ms);
|
|
try std.testing.expectEqual(@as(i32, -1), table_state.last_notified_row);
|
|
|
|
// Can be updated
|
|
table_state.last_callback_time_ms = 1000;
|
|
table_state.last_notified_row = 5;
|
|
|
|
try std.testing.expectEqual(@as(u64, 1000), table_state.last_callback_time_ms);
|
|
try std.testing.expectEqual(@as(i32, 5), table_state.last_notified_row);
|
|
}
|
|
|
|
test "ColumnDef hasLookup" {
|
|
// Column without lookup
|
|
const col_no_lookup = ColumnDef{
|
|
.name = "test",
|
|
.title = "Test",
|
|
};
|
|
try std.testing.expect(!col_no_lookup.hasLookup());
|
|
|
|
// Column with lookup enabled but no config
|
|
const col_partial = ColumnDef{
|
|
.name = "test",
|
|
.title = "Test",
|
|
.enable_lookup = true,
|
|
};
|
|
try std.testing.expect(!col_partial.hasLookup()); // Missing table/key
|
|
|
|
// Column with full lookup config
|
|
const col_full = ColumnDef{
|
|
.name = "test",
|
|
.title = "Test",
|
|
.enable_lookup = true,
|
|
.lookup_table = "other_table",
|
|
.lookup_key_column = "id",
|
|
};
|
|
try std.testing.expect(col_full.hasLookup());
|
|
}
|
|
|
|
test "startsWithIgnoreCase" {
|
|
// Basic match
|
|
try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello"));
|
|
try std.testing.expect(startsWithIgnoreCase("Hello World", "hello"));
|
|
try std.testing.expect(startsWithIgnoreCase("hello world", "HELLO"));
|
|
|
|
// Empty needle matches everything
|
|
try std.testing.expect(startsWithIgnoreCase("anything", ""));
|
|
|
|
// Non-match
|
|
try std.testing.expect(!startsWithIgnoreCase("Hello", "World"));
|
|
try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello"));
|
|
|
|
// Needle longer than haystack
|
|
try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello World"));
|
|
}
|