- Replace manual temp variable swap with std.mem.swap (more explicit) - Add documentation warning about pointer invalidation after sort/setRows - Row contains StringHashMap with internal pointers - swap is safe but pointers obtained via getRow() are invalidated after mutations Based on INFORME_AUDITORIA_PROFUNDA_20251225.md §1.2
1432 lines
53 KiB
Zig
1432 lines
53 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");
|
|
|
|
// 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;
|
|
|
|
// =============================================================================
|
|
// 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.focused = 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) {
|
|
drawHeader(ctx, bounds, table_state, table_schema, state_col_w, colors, &result);
|
|
}
|
|
|
|
// Calculate visible row range
|
|
const first_visible = table_state.scroll_row;
|
|
const last_visible = @min(first_visible + visible_rows, table_state.getRowCount());
|
|
|
|
// Draw visible rows
|
|
for (first_visible..last_visible) |row_idx| {
|
|
const row_y = bounds.y + @as(i32, @intCast(header_h)) +
|
|
@as(i32, @intCast((row_idx - first_visible) * config.row_height));
|
|
|
|
const row_bounds = Layout.Rect.init(
|
|
bounds.x,
|
|
row_y,
|
|
bounds.w,
|
|
config.row_height,
|
|
);
|
|
|
|
drawRow(ctx, row_bounds, table_state, table_schema, row_idx, state_col_w, colors, has_focus, &result);
|
|
}
|
|
|
|
// 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) {
|
|
drawScrollbar(ctx, bounds, table_state, visible_rows, config, colors);
|
|
}
|
|
|
|
// Handle keyboard
|
|
if (has_focus) {
|
|
if (table_state.editing) {
|
|
// Handle editing keyboard
|
|
handleEditingKeyboard(ctx, table_state, table_schema, &result);
|
|
|
|
// Draw editing overlay
|
|
drawEditingOverlay(ctx, bounds, table_state, table_schema, header_h, state_col_w, colors);
|
|
} else if (config.keyboard_nav) {
|
|
// Handle navigation keyboard
|
|
handleKeyboard(ctx, table_state, table_schema, visible_rows, &result);
|
|
}
|
|
}
|
|
|
|
// Ensure selection is visible
|
|
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 = 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
|
|
invokeCallbacks(ctx, table_state, table_schema, &result);
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Callback System (Phase 8)
|
|
// =============================================================================
|
|
|
|
fn invokeCallbacks(
|
|
ctx: *Context,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
result: *AdvancedTableResult,
|
|
) void {
|
|
const config = table_schema.config;
|
|
const current_time = ctx.current_time_ms;
|
|
|
|
// Check debounce
|
|
const time_since_last = current_time -| table_state.last_callback_time_ms;
|
|
const debounce_ok = time_since_last >= config.callback_debounce_ms;
|
|
|
|
// on_row_selected: called when selection changes (with debounce)
|
|
if (result.selection_changed and debounce_ok) {
|
|
if (table_schema.on_row_selected) |callback| {
|
|
if (table_state.selected_row >= 0) {
|
|
const row_idx: usize = @intCast(table_state.selected_row);
|
|
if (table_state.getRowConst(row_idx)) |row| {
|
|
callback(row_idx, row);
|
|
table_state.last_callback_time_ms = current_time;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// on_active_row_changed: called when moving to a different row (for loading detail panels)
|
|
// Only fires once per row change, not on every frame
|
|
if (table_state.selected_row != table_state.last_notified_row) {
|
|
if (table_schema.on_active_row_changed) |callback| {
|
|
if (table_state.selected_row >= 0) {
|
|
const new_row_idx: usize = @intCast(table_state.selected_row);
|
|
if (table_state.getRowConst(new_row_idx)) |row| {
|
|
const old_row: ?usize = if (table_state.last_notified_row >= 0)
|
|
@intCast(table_state.last_notified_row)
|
|
else
|
|
null;
|
|
callback(old_row, new_row_idx, row);
|
|
}
|
|
}
|
|
}
|
|
table_state.last_notified_row = table_state.selected_row;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Internal Rendering
|
|
// =============================================================================
|
|
|
|
fn drawHeader(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
state_col_w: u32,
|
|
colors: *const TableColors,
|
|
result: *AdvancedTableResult,
|
|
) void {
|
|
const config = table_schema.config;
|
|
const header_y = bounds.y;
|
|
var col_x = bounds.x;
|
|
|
|
// State indicator column header
|
|
if (state_col_w > 0) {
|
|
ctx.pushCommand(Command.rect(col_x, header_y, state_col_w, config.header_height, colors.header_bg));
|
|
col_x += @as(i32, @intCast(state_col_w));
|
|
}
|
|
|
|
// Column headers
|
|
const mouse = ctx.input.mousePos();
|
|
|
|
for (table_schema.columns, 0..) |col, idx| {
|
|
if (!col.visible) continue;
|
|
|
|
const col_rect = Layout.Rect.init(col_x, header_y, col.width, config.header_height);
|
|
const col_hovered = col_rect.contains(mouse.x, mouse.y);
|
|
const col_clicked = col_hovered and ctx.input.mousePressed(.left);
|
|
|
|
// Determine background color
|
|
var bg_color = colors.header_bg;
|
|
if (table_state.sort_column == @as(i32, @intCast(idx))) {
|
|
bg_color = colors.header_sorted;
|
|
} else if (col_hovered) {
|
|
bg_color = colors.header_hover;
|
|
}
|
|
|
|
// Draw header cell
|
|
if (Style.isFancy()) {
|
|
ctx.pushCommand(Command.roundedRect(col_rect.x, col_rect.y, col_rect.w, col_rect.h, bg_color, 0));
|
|
} else {
|
|
ctx.pushCommand(Command.rect(col_rect.x, col_rect.y, col_rect.w, col_rect.h, bg_color));
|
|
}
|
|
|
|
// Draw header text
|
|
const text_y = header_y + @as(i32, @intCast((config.header_height - 8) / 2));
|
|
ctx.pushCommand(Command.text(col_x + 4, text_y, col.title, colors.header_fg));
|
|
|
|
// Draw lookup indicator (Phase 7c) - small "?" icon
|
|
if (col.hasLookup()) {
|
|
const lookup_x = col_x + @as(i32, @intCast(col.width)) - 24;
|
|
ctx.pushCommand(Command.text(lookup_x, text_y, "?", Style.Color.primary));
|
|
}
|
|
|
|
// Draw sort indicator
|
|
if (table_state.sort_column == @as(i32, @intCast(idx))) {
|
|
const indicator_x = col_x + @as(i32, @intCast(col.width)) - 12;
|
|
const indicator = switch (table_state.sort_direction) {
|
|
.ascending => "^",
|
|
.descending => "v",
|
|
.none => "",
|
|
};
|
|
if (indicator.len > 0) {
|
|
ctx.pushCommand(Command.text(indicator_x, text_y, indicator, colors.sort_indicator));
|
|
}
|
|
}
|
|
|
|
// Handle click for sorting
|
|
if (col_clicked and col.sortable and config.allow_sorting) {
|
|
_ = table_state.toggleSort(idx);
|
|
result.sort_changed = true;
|
|
result.sort_column = idx;
|
|
result.sort_direction = table_state.sort_direction;
|
|
|
|
// Actually sort the rows
|
|
sortRows(table_state, table_schema.columns[idx].name, table_state.sort_direction);
|
|
}
|
|
|
|
// Draw separator
|
|
ctx.pushCommand(Command.rect(
|
|
col_x + @as(i32, @intCast(col.width)) - 1,
|
|
header_y,
|
|
1,
|
|
config.header_height,
|
|
colors.border,
|
|
));
|
|
|
|
col_x += @as(i32, @intCast(col.width));
|
|
}
|
|
}
|
|
|
|
fn drawRow(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
row_idx: usize,
|
|
state_col_w: u32,
|
|
colors: *const TableColors,
|
|
has_focus: bool,
|
|
result: *AdvancedTableResult,
|
|
) void {
|
|
const config = table_schema.config;
|
|
const is_selected_row = table_state.selected_row == @as(i32, @intCast(row_idx));
|
|
const row_state = table_state.getRowState(row_idx);
|
|
|
|
// Determine row background color
|
|
var row_bg = if (config.alternating_rows and row_idx % 2 == 1)
|
|
colors.row_alternate
|
|
else
|
|
colors.row_normal;
|
|
|
|
// Apply state color overlay
|
|
row_bg = switch (row_state) {
|
|
.modified => blendColor(row_bg, colors.state_modified, 0.2),
|
|
.new => blendColor(row_bg, colors.state_new, 0.2),
|
|
.deleted => blendColor(row_bg, colors.state_deleted, 0.3),
|
|
.@"error" => blendColor(row_bg, colors.state_error, 0.3),
|
|
.normal => row_bg,
|
|
};
|
|
|
|
// Selection overlay - SOLO la fila seleccionada cambia de color
|
|
// El color depende de si la tabla tiene focus
|
|
if (is_selected_row) {
|
|
row_bg = if (has_focus) colors.selected_row else colors.selected_row_unfocus;
|
|
}
|
|
// Las filas NO seleccionadas mantienen row_bg (row_normal o row_alternate)
|
|
|
|
// Draw row background
|
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, config.row_height, row_bg));
|
|
|
|
var col_x = bounds.x;
|
|
const mouse = ctx.input.mousePos();
|
|
|
|
// State indicator column
|
|
if (state_col_w > 0) {
|
|
drawStateIndicator(ctx, col_x, bounds.y, state_col_w, config.row_height, row_state, colors);
|
|
col_x += @as(i32, @intCast(state_col_w));
|
|
}
|
|
|
|
// Data cells
|
|
for (table_schema.columns, 0..) |col, col_idx| {
|
|
if (!col.visible) continue;
|
|
|
|
const cell_rect = Layout.Rect.init(col_x, bounds.y, col.width, config.row_height);
|
|
const is_selected_cell = is_selected_row and table_state.selected_col == @as(i32, @intCast(col_idx));
|
|
const cell_clicked = cell_rect.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left);
|
|
|
|
// Cell indicator for selected cell (outline instead of solid fill)
|
|
if (is_selected_cell) {
|
|
// Subtle background tint
|
|
ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, blendColor(row_bg, colors.selected_cell, 0.15)));
|
|
// Border outline
|
|
ctx.pushCommand(Command.rectOutline(col_x, bounds.y, col.width, config.row_height, colors.selected_cell));
|
|
}
|
|
|
|
// Get cell value
|
|
if (table_state.getRow(row_idx)) |row| {
|
|
const value = row.get(col.name);
|
|
var format_buf: [128]u8 = undefined;
|
|
const formatted = value.format(&format_buf);
|
|
|
|
// Copy text to frame arena to ensure it persists until rendering
|
|
// (format_buf is stack-allocated and goes out of scope)
|
|
const text = ctx.frameAllocator().dupe(u8, formatted) catch formatted;
|
|
|
|
// Draw cell text
|
|
const text_y = bounds.y + @as(i32, @intCast((config.row_height - 8) / 2));
|
|
const text_color = if (is_selected_cell) colors.text_selected else colors.text_normal;
|
|
ctx.pushCommand(Command.text(col_x + 4, text_y, text, text_color));
|
|
}
|
|
|
|
// Handle cell click
|
|
if (cell_clicked) {
|
|
if (!is_selected_cell) {
|
|
table_state.selectCell(row_idx, col_idx);
|
|
result.selection_changed = true;
|
|
result.selected_row = row_idx;
|
|
result.selected_col = col_idx;
|
|
}
|
|
}
|
|
|
|
col_x += @as(i32, @intCast(col.width));
|
|
}
|
|
|
|
// Bottom border
|
|
ctx.pushCommand(Command.rect(
|
|
bounds.x,
|
|
bounds.y + @as(i32, @intCast(config.row_height)) - 1,
|
|
bounds.w,
|
|
1,
|
|
colors.border,
|
|
));
|
|
}
|
|
|
|
fn drawStateIndicator(
|
|
ctx: *Context,
|
|
x: i32,
|
|
y: i32,
|
|
w: u32,
|
|
h: u32,
|
|
row_state: RowState,
|
|
colors: *const TableColors,
|
|
) void {
|
|
const indicator_size: u32 = 8;
|
|
const indicator_x = x + @as(i32, @intCast((w - indicator_size) / 2));
|
|
const indicator_y = y + @as(i32, @intCast((h - indicator_size) / 2));
|
|
|
|
const color = switch (row_state) {
|
|
.modified => colors.indicator_modified,
|
|
.new => colors.indicator_new,
|
|
.deleted => colors.indicator_deleted,
|
|
.@"error" => colors.state_error,
|
|
.normal => return, // No indicator
|
|
};
|
|
|
|
// Draw circle indicator
|
|
ctx.pushCommand(Command.rect(indicator_x, indicator_y, indicator_size, indicator_size, color));
|
|
}
|
|
|
|
fn drawScrollbar(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
table_state: *AdvancedTableState,
|
|
visible_rows: usize,
|
|
config: TableConfig,
|
|
colors: *const TableColors,
|
|
) void {
|
|
const total_rows = table_state.getRowCount();
|
|
if (total_rows == 0) return;
|
|
|
|
const scrollbar_w: u32 = 12;
|
|
const header_h: u32 = if (config.show_headers) config.header_height else 0;
|
|
const scrollbar_h = bounds.h -| header_h;
|
|
|
|
const scrollbar_x = bounds.x + @as(i32, @intCast(bounds.w -| scrollbar_w));
|
|
const scrollbar_y = bounds.y + @as(i32, @intCast(header_h));
|
|
|
|
// Background
|
|
ctx.pushCommand(Command.rect(scrollbar_x, scrollbar_y, scrollbar_w, scrollbar_h, colors.border));
|
|
|
|
// Thumb
|
|
const thumb_ratio = @as(f32, @floatFromInt(visible_rows)) / @as(f32, @floatFromInt(total_rows));
|
|
const thumb_h = @max(20, @as(u32, @intFromFloat(@as(f32, @floatFromInt(scrollbar_h)) * thumb_ratio)));
|
|
|
|
const scroll_ratio = @as(f32, @floatFromInt(table_state.scroll_row)) /
|
|
@as(f32, @floatFromInt(@max(1, total_rows - visible_rows)));
|
|
const thumb_y_offset = @as(u32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(scrollbar_h - thumb_h))));
|
|
|
|
ctx.pushCommand(Command.rect(
|
|
scrollbar_x + 2,
|
|
scrollbar_y + @as(i32, @intCast(thumb_y_offset)),
|
|
scrollbar_w - 4,
|
|
thumb_h,
|
|
colors.header_bg,
|
|
));
|
|
}
|
|
|
|
fn drawEditingOverlay(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
header_h: u32,
|
|
state_col_w: u32,
|
|
colors: *const TableColors,
|
|
) void {
|
|
if (table_state.selected_row < 0 or table_state.selected_col < 0) return;
|
|
|
|
const row_idx: usize = @intCast(table_state.selected_row);
|
|
const col_idx: usize = @intCast(table_state.selected_col);
|
|
const config = table_schema.config;
|
|
|
|
// Check if row is visible
|
|
if (row_idx < table_state.scroll_row) return;
|
|
const visible_row = row_idx - table_state.scroll_row;
|
|
const visible_rows = (bounds.h -| header_h) / config.row_height;
|
|
if (visible_row >= visible_rows) return;
|
|
|
|
// Calculate cell position
|
|
var col_x = bounds.x + @as(i32, @intCast(state_col_w));
|
|
for (table_schema.columns[0..col_idx]) |col| {
|
|
if (col.visible) {
|
|
col_x += @as(i32, @intCast(col.width));
|
|
}
|
|
}
|
|
|
|
const col_def = table_schema.columns[col_idx];
|
|
const cell_y = bounds.y + @as(i32, @intCast(header_h + visible_row * config.row_height));
|
|
const cell_h = config.row_height;
|
|
|
|
// Draw editing overlay background
|
|
ctx.pushCommand(Command.rect(col_x, cell_y, col_def.width, cell_h, colors.cell_editing_bg));
|
|
|
|
// Draw border
|
|
ctx.pushCommand(Command.rectOutline(col_x, cell_y, col_def.width, cell_h, colors.cell_editing_border));
|
|
|
|
// Draw edit text
|
|
const edit_text = table_state.getEditText();
|
|
const text_y = cell_y + @as(i32, @intCast((cell_h - 8) / 2));
|
|
ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.text_selected));
|
|
|
|
// Draw cursor
|
|
const cursor_x = col_x + 4 + @as(i32, @intCast(table_state.edit_cursor * 8));
|
|
ctx.pushCommand(Command.rect(cursor_x, text_y, 1, 8, colors.text_selected));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Keyboard Handling
|
|
// =============================================================================
|
|
|
|
fn handleKeyboard(
|
|
ctx: *Context,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
visible_rows: usize,
|
|
result: *AdvancedTableResult,
|
|
) void {
|
|
const row_count = table_state.getRowCount();
|
|
if (row_count == 0) return;
|
|
|
|
const config = table_schema.config;
|
|
const col_count = table_schema.columns.len;
|
|
|
|
// Use navKeyPressed for navigation (includes key repeats)
|
|
if (ctx.input.navKeyPressed()) |nav_key| {
|
|
switch (nav_key) {
|
|
.up => {
|
|
if (table_state.selected_row > 0) {
|
|
const new_row: usize = @intCast(table_state.selected_row - 1);
|
|
const new_col: usize = @intCast(@max(0, table_state.selected_col));
|
|
table_state.selectCell(new_row, new_col);
|
|
result.selection_changed = true;
|
|
result.selected_row = new_row;
|
|
result.selected_col = new_col;
|
|
}
|
|
},
|
|
.down => {
|
|
if (table_state.selected_row < @as(i32, @intCast(row_count)) - 1) {
|
|
const new_row: usize = @intCast(table_state.selected_row + 1);
|
|
const new_col: usize = @intCast(@max(0, table_state.selected_col));
|
|
table_state.selectCell(new_row, new_col);
|
|
result.selection_changed = true;
|
|
result.selected_row = new_row;
|
|
result.selected_col = new_col;
|
|
}
|
|
},
|
|
.left => {
|
|
if (table_state.selected_col > 0) {
|
|
const new_row: usize = @intCast(@max(0, table_state.selected_row));
|
|
const new_col: usize = @intCast(table_state.selected_col - 1);
|
|
table_state.selectCell(new_row, new_col);
|
|
result.selection_changed = true;
|
|
result.selected_row = new_row;
|
|
result.selected_col = new_col;
|
|
}
|
|
},
|
|
.right => {
|
|
if (table_state.selected_col < @as(i32, @intCast(col_count)) - 1) {
|
|
const new_row: usize = @intCast(@max(0, table_state.selected_row));
|
|
const new_col: usize = @intCast(table_state.selected_col + 1);
|
|
table_state.selectCell(new_row, new_col);
|
|
result.selection_changed = true;
|
|
result.selected_row = new_row;
|
|
result.selected_col = new_col;
|
|
}
|
|
},
|
|
.page_up => {
|
|
const new_row: usize = @intCast(@max(0, table_state.selected_row - @as(i32, @intCast(visible_rows))));
|
|
const new_col: usize = @intCast(@max(0, table_state.selected_col));
|
|
table_state.selectCell(new_row, new_col);
|
|
result.selection_changed = true;
|
|
result.selected_row = new_row;
|
|
result.selected_col = new_col;
|
|
},
|
|
.page_down => {
|
|
const new_row: usize = @intCast(@min(
|
|
@as(i32, @intCast(row_count)) - 1,
|
|
table_state.selected_row + @as(i32, @intCast(visible_rows)),
|
|
));
|
|
const new_col: usize = @intCast(@max(0, table_state.selected_col));
|
|
table_state.selectCell(new_row, new_col);
|
|
result.selection_changed = true;
|
|
result.selected_row = new_row;
|
|
result.selected_col = new_col;
|
|
},
|
|
.home => {
|
|
var new_row: usize = undefined;
|
|
var new_col: usize = undefined;
|
|
if (ctx.input.modifiers.ctrl) {
|
|
// Ctrl+Home: first cell
|
|
new_row = 0;
|
|
new_col = 0;
|
|
} else {
|
|
// Home: first column
|
|
new_row = @intCast(@max(0, table_state.selected_row));
|
|
new_col = 0;
|
|
}
|
|
table_state.selectCell(new_row, new_col);
|
|
result.selection_changed = true;
|
|
result.selected_row = new_row;
|
|
result.selected_col = new_col;
|
|
},
|
|
.end => {
|
|
var new_row: usize = undefined;
|
|
var new_col: usize = undefined;
|
|
if (ctx.input.modifiers.ctrl) {
|
|
// Ctrl+End: last cell
|
|
new_row = if (row_count > 0) row_count - 1 else 0;
|
|
new_col = col_count - 1;
|
|
} else {
|
|
// End: last column
|
|
new_row = @intCast(@max(0, table_state.selected_row));
|
|
new_col = col_count - 1;
|
|
}
|
|
table_state.selectCell(new_row, new_col);
|
|
result.selection_changed = true;
|
|
result.selected_row = new_row;
|
|
result.selected_col = new_col;
|
|
},
|
|
.tab => {
|
|
if (config.handle_tab) {
|
|
// Tab navigation handled below
|
|
}
|
|
},
|
|
.enter => {
|
|
// Enter to start editing handled below
|
|
},
|
|
.escape => {},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
// Tab navigation (if handle_tab is enabled)
|
|
if (config.handle_tab and ctx.input.keyPressed(.tab)) {
|
|
const shift = ctx.input.modifiers.shift;
|
|
|
|
if (shift) {
|
|
// Shift+Tab: move left, wrap to previous row
|
|
if (table_state.selected_col > 0) {
|
|
table_state.selectCell(
|
|
@intCast(@max(0, table_state.selected_row)),
|
|
@intCast(table_state.selected_col - 1),
|
|
);
|
|
result.selection_changed = true;
|
|
} else if (table_state.selected_row > 0 and config.wrap_navigation) {
|
|
// Wrap to end of previous row
|
|
table_state.selectCell(
|
|
@intCast(table_state.selected_row - 1),
|
|
col_count - 1,
|
|
);
|
|
result.selection_changed = true;
|
|
}
|
|
} else {
|
|
// Tab: move right, wrap to next row
|
|
if (table_state.selected_col < @as(i32, @intCast(col_count)) - 1) {
|
|
table_state.selectCell(
|
|
@intCast(@max(0, table_state.selected_row)),
|
|
@intCast(table_state.selected_col + 1),
|
|
);
|
|
result.selection_changed = true;
|
|
} else if (table_state.selected_row < @as(i32, @intCast(row_count)) - 1 and config.wrap_navigation) {
|
|
// Wrap to beginning of next row
|
|
table_state.selectCell(
|
|
@intCast(table_state.selected_row + 1),
|
|
0,
|
|
);
|
|
result.selection_changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start editing with F2 or Enter
|
|
if (config.allow_edit and (ctx.input.keyPressed(.f2) or ctx.input.keyPressed(.enter))) {
|
|
if (table_state.selected_row >= 0 and table_state.selected_col >= 0) {
|
|
const col_idx: usize = @intCast(table_state.selected_col);
|
|
if (col_idx < table_schema.columns.len and table_schema.columns[col_idx].editable) {
|
|
// Get current value
|
|
if (table_state.getRow(@intCast(table_state.selected_row))) |row| {
|
|
const value = row.get(table_schema.columns[col_idx].name);
|
|
var format_buf: [128]u8 = undefined;
|
|
const text = value.format(&format_buf);
|
|
table_state.startEditing(text);
|
|
table_state.original_value = value;
|
|
result.edit_started = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Row operations (if allowed)
|
|
if (config.allow_row_operations) {
|
|
// Ctrl+N: Insert row at current position
|
|
if (ctx.input.keyPressed(.n) and ctx.input.modifiers.ctrl) {
|
|
const insert_idx: usize = if (table_state.selected_row >= 0)
|
|
@intCast(table_state.selected_row)
|
|
else
|
|
0;
|
|
if (table_state.insertRow(insert_idx)) |new_idx| {
|
|
table_state.selectCell(new_idx, 0);
|
|
result.row_inserted = true;
|
|
result.selection_changed = true;
|
|
} else |_| {}
|
|
}
|
|
|
|
// Ctrl+Delete: Delete current row
|
|
if (ctx.input.keyPressed(.delete) and ctx.input.modifiers.ctrl) {
|
|
if (table_state.selected_row >= 0) {
|
|
const delete_idx: usize = @intCast(table_state.selected_row);
|
|
table_state.deleteRow(delete_idx);
|
|
result.row_deleted = true;
|
|
|
|
// Adjust selection
|
|
const remaining_rows = table_state.getRowCount();
|
|
if (remaining_rows == 0) {
|
|
table_state.selected_row = -1;
|
|
} else if (delete_idx >= remaining_rows) {
|
|
table_state.selected_row = @intCast(remaining_rows - 1);
|
|
}
|
|
result.selection_changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ctrl+A: Select all rows (if multi-select enabled)
|
|
if (config.allow_multi_select and ctx.input.keyPressed(.a) and ctx.input.modifiers.ctrl) {
|
|
table_state.selectAllRows();
|
|
result.selection_changed = true;
|
|
}
|
|
|
|
// Incremental search (type-to-search)
|
|
// Only when not editing and no modifiers pressed
|
|
if (!ctx.input.modifiers.ctrl and !ctx.input.modifiers.alt) {
|
|
if (ctx.input.text_input_len > 0) {
|
|
const text = ctx.input.text_input[0..ctx.input.text_input_len];
|
|
for (text) |char| {
|
|
if (char >= 32 and char < 127) { // Printable ASCII
|
|
const search_term = table_state.addSearchChar(char, ctx.current_time_ms);
|
|
|
|
// Search for matching row in first column
|
|
if (search_term.len > 0 and table_schema.columns.len > 0) {
|
|
const first_col_name = table_schema.columns[0].name;
|
|
const start_row: usize = if (table_state.selected_row >= 0)
|
|
@intCast(table_state.selected_row)
|
|
else
|
|
0;
|
|
|
|
var found_row: ?usize = null;
|
|
|
|
// Search from current position to end
|
|
for (start_row..row_count) |row| {
|
|
if (table_state.getRowConst(row)) |row_data| {
|
|
const cell_value = row_data.get(first_col_name);
|
|
var format_buf: [128]u8 = undefined;
|
|
const cell_text = cell_value.format(&format_buf);
|
|
if (startsWithIgnoreCase(cell_text, search_term)) {
|
|
found_row = row;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wrap to beginning if not found
|
|
if (found_row == null and start_row > 0) {
|
|
for (0..start_row) |row| {
|
|
if (table_state.getRowConst(row)) |row_data| {
|
|
const cell_value = row_data.get(first_col_name);
|
|
var format_buf: [128]u8 = undefined;
|
|
const cell_text = cell_value.format(&format_buf);
|
|
if (startsWithIgnoreCase(cell_text, search_term)) {
|
|
found_row = row;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move selection if found
|
|
if (found_row) |row_idx| {
|
|
table_state.selectCell(row_idx, @intCast(@max(0, table_state.selected_col)));
|
|
result.selection_changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handleEditingKeyboard(
|
|
ctx: *Context,
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
result: *AdvancedTableResult,
|
|
) void {
|
|
const config = table_schema.config;
|
|
|
|
// Escape: cancel editing (1st = revert, 2nd = exit without save)
|
|
if (ctx.input.keyPressed(.escape)) {
|
|
table_state.escape_count += 1;
|
|
if (table_state.escape_count >= 2 or table_state.original_value == null) {
|
|
// Exit without saving
|
|
table_state.stopEditing();
|
|
result.edit_ended = true;
|
|
} else {
|
|
// Revert to original value
|
|
if (table_state.original_value) |orig| {
|
|
var format_buf: [128]u8 = undefined;
|
|
const text = orig.format(&format_buf);
|
|
table_state.startEditing(text);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Reset escape count on any other key
|
|
table_state.escape_count = 0;
|
|
|
|
// Enter: confirm editing
|
|
if (ctx.input.keyPressed(.enter)) {
|
|
commitEdit(table_state, table_schema, result);
|
|
table_state.stopEditing();
|
|
result.edit_ended = true;
|
|
return;
|
|
}
|
|
|
|
// Tab: confirm and move to next cell
|
|
if (ctx.input.keyPressed(.tab) and config.handle_tab) {
|
|
commitEdit(table_state, table_schema, result);
|
|
table_state.stopEditing();
|
|
result.edit_ended = true;
|
|
|
|
// Move to next/prev cell
|
|
const shift = ctx.input.modifiers.shift;
|
|
const col_count = table_schema.columns.len;
|
|
const row_count = table_state.getRowCount();
|
|
|
|
if (shift) {
|
|
// Shift+Tab: move left
|
|
if (table_state.selected_col > 0) {
|
|
table_state.selectCell(
|
|
@intCast(@max(0, table_state.selected_row)),
|
|
@intCast(table_state.selected_col - 1),
|
|
);
|
|
} else if (table_state.selected_row > 0 and config.wrap_navigation) {
|
|
table_state.selectCell(
|
|
@intCast(table_state.selected_row - 1),
|
|
col_count - 1,
|
|
);
|
|
}
|
|
} else {
|
|
// Tab: move right
|
|
if (table_state.selected_col < @as(i32, @intCast(col_count)) - 1) {
|
|
table_state.selectCell(
|
|
@intCast(@max(0, table_state.selected_row)),
|
|
@intCast(table_state.selected_col + 1),
|
|
);
|
|
} else if (table_state.selected_row < @as(i32, @intCast(row_count)) - 1 and config.wrap_navigation) {
|
|
table_state.selectCell(
|
|
@intCast(table_state.selected_row + 1),
|
|
0,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Auto-start editing in new cell if editable
|
|
const new_col: usize = @intCast(@max(0, table_state.selected_col));
|
|
if (new_col < table_schema.columns.len and table_schema.columns[new_col].editable) {
|
|
if (table_state.getRow(@intCast(@max(0, table_state.selected_row)))) |row| {
|
|
const value = row.get(table_schema.columns[new_col].name);
|
|
var format_buf: [128]u8 = undefined;
|
|
const text = value.format(&format_buf);
|
|
table_state.startEditing(text);
|
|
result.edit_started = true;
|
|
}
|
|
}
|
|
|
|
result.selection_changed = true;
|
|
return;
|
|
}
|
|
|
|
// Cursor movement within edit buffer
|
|
if (ctx.input.keyPressed(.left)) {
|
|
if (table_state.edit_cursor > 0) {
|
|
table_state.edit_cursor -= 1;
|
|
}
|
|
return;
|
|
}
|
|
if (ctx.input.keyPressed(.right)) {
|
|
if (table_state.edit_cursor < table_state.edit_len) {
|
|
table_state.edit_cursor += 1;
|
|
}
|
|
return;
|
|
}
|
|
if (ctx.input.keyPressed(.home)) {
|
|
table_state.edit_cursor = 0;
|
|
return;
|
|
}
|
|
if (ctx.input.keyPressed(.end)) {
|
|
table_state.edit_cursor = table_state.edit_len;
|
|
return;
|
|
}
|
|
|
|
// Backspace: delete char before cursor
|
|
if (ctx.input.keyPressed(.backspace)) {
|
|
if (table_state.edit_cursor > 0) {
|
|
// Shift characters left
|
|
var i: usize = table_state.edit_cursor - 1;
|
|
while (i < table_state.edit_len - 1) : (i += 1) {
|
|
table_state.edit_buffer[i] = table_state.edit_buffer[i + 1];
|
|
}
|
|
table_state.edit_len -= 1;
|
|
table_state.edit_cursor -= 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Delete: delete char at cursor
|
|
if (ctx.input.keyPressed(.delete)) {
|
|
if (table_state.edit_cursor < table_state.edit_len) {
|
|
// Shift characters left
|
|
var i: usize = table_state.edit_cursor;
|
|
while (i < table_state.edit_len - 1) : (i += 1) {
|
|
table_state.edit_buffer[i] = table_state.edit_buffer[i + 1];
|
|
}
|
|
table_state.edit_len -= 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Character input
|
|
if (ctx.input.text_input_len > 0) {
|
|
const text = ctx.input.text_input[0..ctx.input.text_input_len];
|
|
for (text) |ch| {
|
|
if (ch >= 32 and ch < 127) {
|
|
if (table_state.edit_len < types.MAX_EDIT_BUFFER - 1) {
|
|
// Shift characters right
|
|
var i: usize = table_state.edit_len;
|
|
while (i > table_state.edit_cursor) : (i -= 1) {
|
|
table_state.edit_buffer[i] = table_state.edit_buffer[i - 1];
|
|
}
|
|
table_state.edit_buffer[table_state.edit_cursor] = ch;
|
|
table_state.edit_len += 1;
|
|
table_state.edit_cursor += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn commitEdit(
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
result: *AdvancedTableResult,
|
|
) void {
|
|
if (table_state.selected_row < 0 or table_state.selected_col < 0) return;
|
|
|
|
const row_idx: usize = @intCast(table_state.selected_row);
|
|
const col_idx: usize = @intCast(table_state.selected_col);
|
|
|
|
if (col_idx >= table_schema.columns.len) return;
|
|
|
|
const col_def = &table_schema.columns[col_idx];
|
|
const edit_text = table_state.getEditText();
|
|
|
|
// Parse text to CellValue based on column type
|
|
const new_value = parseValue(edit_text, col_def.column_type);
|
|
|
|
// Check if value changed
|
|
if (table_state.original_value) |orig| {
|
|
if (new_value.eql(orig)) {
|
|
return; // No change
|
|
}
|
|
}
|
|
|
|
// Update the row
|
|
if (table_state.getRow(row_idx)) |row| {
|
|
row.set(col_def.name, new_value) catch {};
|
|
table_state.markDirty(row_idx);
|
|
result.cell_edited = true;
|
|
|
|
// Lookup & Auto-fill (Phase 7)
|
|
if (col_def.hasLookup()) {
|
|
performLookupAndAutoFill(table_state, table_schema, row, col_def, new_value, result);
|
|
}
|
|
|
|
// Call on_cell_changed callback (Phase 8)
|
|
if (table_schema.on_cell_changed) |callback| {
|
|
const old_value = table_state.original_value orelse CellValue{ .null_val = {} };
|
|
callback(row_idx, col_idx, old_value, new_value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Perform lookup in related table and auto-fill columns
|
|
fn performLookupAndAutoFill(
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
row: *Row,
|
|
col_def: *const ColumnDef,
|
|
lookup_value: CellValue,
|
|
result: *AdvancedTableResult,
|
|
) void {
|
|
// Need DataStore to perform lookup
|
|
const data_store = table_schema.data_store orelse return;
|
|
|
|
// Get lookup configuration
|
|
const lookup_table = col_def.lookup_table orelse return;
|
|
const lookup_key = col_def.lookup_key_column orelse return;
|
|
|
|
// Perform lookup
|
|
const lookup_result = data_store.lookup(
|
|
lookup_table,
|
|
lookup_key,
|
|
lookup_value,
|
|
table_state.allocator,
|
|
) catch return;
|
|
|
|
// If lookup found a match, auto-fill related columns
|
|
if (lookup_result) |lookup_row| {
|
|
defer {
|
|
// Clean up the lookup row after use
|
|
var mutable_lookup = lookup_row;
|
|
mutable_lookup.deinit();
|
|
}
|
|
|
|
// Auto-fill columns based on mapping
|
|
if (col_def.auto_fill_columns) |mappings| {
|
|
for (mappings) |mapping| {
|
|
const source_value = lookup_row.get(mapping.source_field);
|
|
if (!source_value.isEmpty()) {
|
|
row.set(mapping.target_column, source_value) catch {};
|
|
}
|
|
}
|
|
}
|
|
|
|
result.lookup_success = true;
|
|
} else {
|
|
result.lookup_success = false;
|
|
}
|
|
}
|
|
|
|
fn parseValue(text: []const u8, column_type: ColumnType) CellValue {
|
|
return switch (column_type) {
|
|
.string => CellValue{ .string = text },
|
|
.integer => blk: {
|
|
const val = std.fmt.parseInt(i64, text, 10) catch 0;
|
|
break :blk CellValue{ .integer = val };
|
|
},
|
|
.float, .money => blk: {
|
|
const val = std.fmt.parseFloat(f64, text) catch 0.0;
|
|
break :blk CellValue{ .float = val };
|
|
},
|
|
.boolean => blk: {
|
|
const lower = text;
|
|
const is_true = std.mem.eql(u8, lower, "true") or
|
|
std.mem.eql(u8, lower, "yes") or
|
|
std.mem.eql(u8, lower, "1") or
|
|
std.mem.eql(u8, lower, "Y");
|
|
break :blk CellValue{ .boolean = is_true };
|
|
},
|
|
.date, .select, .lookup => CellValue{ .string = text },
|
|
};
|
|
}
|
|
|
|
fn detectCRUDAction(
|
|
table_state: *AdvancedTableState,
|
|
table_schema: *const TableSchema,
|
|
) ?CRUDAction {
|
|
// Check if previous row was valid
|
|
if (table_state.prev_selected_row < 0) return null;
|
|
|
|
const prev_row_idx: usize = @intCast(table_state.prev_selected_row);
|
|
|
|
// Check if row was marked for deletion
|
|
if (table_state.isDeleted(prev_row_idx)) {
|
|
return .delete;
|
|
}
|
|
|
|
// Get the row (might have been deleted)
|
|
const row = table_state.getRow(prev_row_idx) orelse return null;
|
|
|
|
// Get snapshot for comparison
|
|
const snapshot = table_state.getSnapshot(prev_row_idx);
|
|
|
|
// Check if row is new (was in new_rows map)
|
|
const is_new = table_state.isNew(prev_row_idx);
|
|
|
|
if (is_new) {
|
|
// Check if new row has any data
|
|
if (rowHasData(row, table_schema)) {
|
|
return .create;
|
|
}
|
|
return null; // Empty new row, no action
|
|
}
|
|
|
|
// Check if row was modified
|
|
if (snapshot) |snap| {
|
|
if (rowsAreDifferent(row, snap, table_schema)) {
|
|
return .update;
|
|
}
|
|
} else if (table_state.isDirty(prev_row_idx)) {
|
|
// No snapshot but marked dirty - assume update
|
|
return .update;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
fn rowHasData(row: *const Row, table_schema: *const TableSchema) bool {
|
|
for (table_schema.columns) |col| {
|
|
if (!col.visible) continue;
|
|
const value = row.get(col.name);
|
|
if (!value.isEmpty()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn rowsAreDifferent(row: *const Row, snapshot: *const Row, table_schema: *const TableSchema) bool {
|
|
for (table_schema.columns) |col| {
|
|
if (!col.editable) continue;
|
|
const current = row.get(col.name);
|
|
const original = snapshot.get(col.name);
|
|
if (!current.eql(original)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn ensureSelectionVisible(table_state: *AdvancedTableState, visible_rows: usize) void {
|
|
if (table_state.selected_row < 0) return;
|
|
|
|
const row: usize = @intCast(table_state.selected_row);
|
|
|
|
// Scroll to show selected row
|
|
if (row < table_state.scroll_row) {
|
|
table_state.scroll_row = row;
|
|
} else if (row >= table_state.scroll_row + visible_rows) {
|
|
table_state.scroll_row = row - visible_rows + 1;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Color Helpers
|
|
// =============================================================================
|
|
|
|
fn blendColor(base: Style.Color, overlay: Style.Color, alpha: f32) Style.Color {
|
|
const inv_alpha = 1.0 - alpha;
|
|
|
|
return Style.Color.rgba(
|
|
@intFromFloat(@as(f32, @floatFromInt(base.r)) * inv_alpha + @as(f32, @floatFromInt(overlay.r)) * alpha),
|
|
@intFromFloat(@as(f32, @floatFromInt(base.g)) * inv_alpha + @as(f32, @floatFromInt(overlay.g)) * alpha),
|
|
@intFromFloat(@as(f32, @floatFromInt(base.b)) * inv_alpha + @as(f32, @floatFromInt(overlay.b)) * alpha),
|
|
base.a,
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Sorting
|
|
// =============================================================================
|
|
|
|
/// Sort rows by column value
|
|
fn sortRows(
|
|
table_state: *AdvancedTableState,
|
|
column_name: []const u8,
|
|
direction: SortDirection,
|
|
) void {
|
|
if (direction == .none) return;
|
|
if (table_state.rows.items.len < 2) return;
|
|
|
|
// Simple bubble sort (stable, works for small-medium datasets)
|
|
// For large datasets, consider using std.mem.sort with a context
|
|
const len = table_state.rows.items.len;
|
|
var swapped = true;
|
|
|
|
while (swapped) {
|
|
swapped = false;
|
|
for (0..len - 1) |i| {
|
|
const val_a = table_state.rows.items[i].get(column_name);
|
|
const val_b = table_state.rows.items[i + 1].get(column_name);
|
|
const cmp = val_a.compare(val_b);
|
|
|
|
const should_swap = switch (direction) {
|
|
.ascending => cmp > 0,
|
|
.descending => cmp < 0,
|
|
.none => false,
|
|
};
|
|
|
|
if (should_swap) {
|
|
// Swap rows usando std.mem.swap (seguro para structs con punteros internos)
|
|
// NOTA: Row contiene StringHashMap que tiene punteros a buckets.
|
|
// El swap mueve el struct completo, no clona los datos.
|
|
// Los punteros obtenidos via getRow() se invalidan tras sort.
|
|
std.mem.swap(Row, &table_state.rows.items[i], &table_state.rows.items[i + 1]);
|
|
|
|
// Swap state map entries
|
|
swapRowStates(table_state, i, i + 1);
|
|
|
|
swapped = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Swap state map entries between two row indices
|
|
fn swapRowStates(table_state: *AdvancedTableState, idx_a: usize, idx_b: usize) void {
|
|
// Helper to swap a single map's entries
|
|
const swapInMap = struct {
|
|
fn swap(map: anytype, a: usize, b: usize) void {
|
|
const val_a = map.get(a);
|
|
const val_b = map.get(b);
|
|
|
|
if (val_a != null and val_b != null) {
|
|
// Both exist - no change needed (both stay)
|
|
} else if (val_a) |v| {
|
|
// Only a exists - move to b
|
|
_ = map.remove(a);
|
|
map.put(b, v) catch {};
|
|
} else if (val_b) |v| {
|
|
// Only b exists - move to a
|
|
_ = map.remove(b);
|
|
map.put(a, v) catch {};
|
|
}
|
|
// Neither exists - nothing to do
|
|
}
|
|
}.swap;
|
|
|
|
swapInMap(&table_state.dirty_rows, idx_a, idx_b);
|
|
swapInMap(&table_state.new_rows, idx_a, idx_b);
|
|
swapInMap(&table_state.deleted_rows, idx_a, idx_b);
|
|
swapInMap(&table_state.validation_errors, idx_a, idx_b);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Search Helpers
|
|
// =============================================================================
|
|
|
|
/// Case-insensitive prefix match for incremental search
|
|
fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool {
|
|
if (needle.len > haystack.len) return false;
|
|
if (needle.len == 0) return true;
|
|
|
|
for (needle, 0..) |needle_char, i| {
|
|
const haystack_char = haystack[i];
|
|
// Simple ASCII case-insensitive comparison
|
|
const needle_lower = if (needle_char >= 'A' and needle_char <= 'Z')
|
|
needle_char + 32
|
|
else
|
|
needle_char;
|
|
const haystack_lower = if (haystack_char >= 'A' and haystack_char <= 'Z')
|
|
haystack_char + 32
|
|
else
|
|
haystack_char;
|
|
|
|
if (needle_lower != haystack_lower) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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"));
|
|
}
|