diff --git a/src/widgets/advanced_table/advanced_table.zig b/src/widgets/advanced_table/advanced_table.zig index a98e896..f95bea2 100644 --- a/src/widgets/advanced_table/advanced_table.zig +++ b/src/widgets/advanced_table/advanced_table.zig @@ -19,6 +19,12 @@ 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; @@ -54,6 +60,11 @@ 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 // ============================================================================= @@ -132,7 +143,7 @@ pub fn advancedTableRect( // Draw header if (config.show_headers) { - drawHeader(ctx, bounds, table_state, table_schema, state_col_w, colors, &result); + drawing.drawHeader(ctx, bounds, table_state, table_schema, state_col_w, colors, &result); } // Calculate visible row range @@ -140,7 +151,7 @@ pub fn advancedTableRect( const last_visible = @min(first_visible + visible_rows, table_state.getRowCount()); // Manejar clicks en filas (separado del renderizado) - handleRowClicks(ctx, bounds, table_state, table_schema, header_h, state_col_w, first_visible, last_visible, &result); + 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; @@ -216,29 +227,29 @@ pub fn advancedTableRect( // Draw scrollbar if needed if (table_state.getRowCount() > visible_rows) { - drawScrollbar(ctx, bounds, table_state, visible_rows, config, colors); + drawing.drawScrollbar(ctx, bounds, table_state, visible_rows, config, colors); } // Handle keyboard if (has_focus) { if (table_state.isEditing()) { // Handle editing keyboard - handleEditingKeyboard(ctx, table_state, table_schema, &result); + input.handleEditingKeyboard(ctx, table_state, table_schema, &result); // Draw editing overlay - drawEditingOverlay(ctx, bounds, table_state, table_schema, header_h, state_col_w, colors); + drawing.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); + input.handleKeyboard(ctx, table_state, table_schema, visible_rows, &result); } } // Ensure selection is visible - ensureSelectionVisible(table_state, visible_rows); + 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 = detectCRUDAction(table_state, table_schema); + result.crud_action = helpers.detectCRUDAction(table_state, table_schema); // Capture snapshot of new row if (table_state.selected_row >= 0) { @@ -247,1083 +258,11 @@ pub fn advancedTableRect( } // Phase 8: Invoke callbacks - invokeCallbacks(ctx, table_state, table_schema, &result); + helpers.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; - } -} - -// ============================================================================= -// Row Click Handling (separado del rendering) -// ============================================================================= - -/// Maneja clicks en las filas de la tabla (single-click y double-click) -/// Retorna si hubo algún cambio de selección o edición iniciada -fn handleRowClicks( - ctx: *Context, - bounds: Layout.Rect, - table_state: *AdvancedTableState, - table_schema: *const TableSchema, - header_h: u32, - state_col_w: u32, - first_visible: usize, - last_visible: usize, - result: *AdvancedTableResult, -) void { - const config = table_schema.config; - const mouse = ctx.input.mousePos(); - - // Solo procesar si hubo click - if (!ctx.input.mousePressed(.left)) return; - - // Verificar si el click está en el área de filas - const rows_area_y = bounds.y + @as(i32, @intCast(header_h)); - if (mouse.y < rows_area_y) return; - if (mouse.x < bounds.x or mouse.x >= bounds.x + @as(i32, @intCast(bounds.w))) return; - - // Calcular fila clickeada - const relative_y = mouse.y - rows_area_y; - if (relative_y < 0) return; - const row_offset: usize = @intCast(@divFloor(relative_y, @as(i32, @intCast(config.row_height)))); - const row_idx = first_visible + row_offset; - - if (row_idx >= last_visible or row_idx >= table_state.getRowCount()) return; - - // Calcular columna clickeada - var col_x = bounds.x + @as(i32, @intCast(state_col_w)); - var clicked_col: ?usize = null; - - for (table_schema.columns, 0..) |col, col_idx| { - if (!col.visible) continue; - - const col_end = col_x + @as(i32, @intCast(col.width)); - if (mouse.x >= col_x and mouse.x < col_end) { - clicked_col = col_idx; - break; - } - col_x = col_end; - } - - if (clicked_col == null) return; - - const col_idx = clicked_col.?; - const is_selected_cell = table_state.selected_row == @as(i32, @intCast(row_idx)) and - table_state.selected_col == @as(i32, @intCast(col_idx)); - - // Detectar doble-click - const current_time = ctx.current_time_ms; - const same_cell = table_state.nav.double_click.last_click_row == @as(i32, @intCast(row_idx)) and - table_state.nav.double_click.last_click_col == @as(i32, @intCast(col_idx)); - const time_diff = current_time -| table_state.nav.double_click.last_click_time; - const is_double_click = same_cell and time_diff < table_state.nav.double_click.threshold_ms; - - if (is_double_click and config.allow_edit and col_idx < table_schema.columns.len and - table_schema.columns[col_idx].editable and !table_state.isEditing()) - { - // Double-click: iniciar edición - if (table_state.getRow(row_idx)) |row| { - const value = row.get(table_schema.columns[col_idx].name); - var format_buf: [128]u8 = undefined; - const edit_text = value.format(&format_buf); - table_state.startEditing(edit_text); - table_state.original_value = value; - result.edit_started = true; - } - // Reset click tracking - table_state.nav.double_click.last_click_time = 0; - table_state.nav.double_click.last_click_row = -1; - table_state.nav.double_click.last_click_col = -1; - } else { - // Single click: seleccionar celda - 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; - } - // Actualizar tracking para posible doble-click - table_state.nav.double_click.last_click_time = current_time; - table_state.nav.double_click.last_click_row = @intCast(row_idx); - table_state.nav.double_click.last_click_col = @intCast(col_idx); - } -} - -// ============================================================================= -// 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 (▴/▾ Unicode glyphs) - if (table_state.sort_column == @as(i32, @intCast(idx))) { - const indicator_x = col_x + @as(i32, @intCast(col.width)) - 16; - const indicator = switch (table_state.sort_direction) { - .ascending => "▴", - .descending => "▾", - .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 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; - - // Usar función unificada de table_core - table_core.drawVerticalScrollbar(ctx, .{ - .track_x = bounds.x + @as(i32, @intCast(bounds.w -| scrollbar_w)), - .track_y = bounds.y + @as(i32, @intCast(header_h)), - .width = scrollbar_w, - .height = scrollbar_h, - .visible_count = visible_rows, - .total_count = total_rows, - .scroll_pos = table_state.nav.scroll_row, - .track_color = colors.border, - .thumb_color = 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.nav.scroll_row) return; - const visible_row = row_idx - table_state.nav.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)); - - // Draw selection highlight if exists (Excel-style) - const sel_start = table_state.cell_edit.selection_start; - const sel_end = table_state.cell_edit.selection_end; - if (sel_start != sel_end and edit_text.len > 0) { - const sel_min = @min(sel_start, sel_end); - const sel_max = @min(@max(sel_start, sel_end), edit_text.len); - if (sel_max > sel_min) { - const sel_x = col_x + 4 + @as(i32, @intCast(sel_min * 8)); - const sel_width = @as(u32, @intCast((sel_max - sel_min) * 8)); - ctx.pushCommand(Command.rect(sel_x, text_y, sel_width, 8, colors.cell_selection_bg)); - } - } - - // Draw text (on top of selection) - ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.text_selected)); - - // Draw cursor only if no selection - if (sel_start == sel_end) { - const cursor_x = col_x + 4 + @as(i32, @intCast(table_state.cell_edit.edit_cursor * 8)); - ctx.pushCommand(Command.rect(cursor_x, text_y, 1, 8, colors.text_selected)); - } -} - -// ============================================================================= -// Keyboard Handling (Brain-in-Core pattern) -// ============================================================================= -// -// Arquitectura: TODA la lógica de decisión está en table_core.processTableEvents() -// Este handler solo aplica flags y maneja lógica específica de AdvancedTable. - -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; - - // ========================================================================= - // BRAIN-IN-CORE: Delegar toda la lógica de decisión al Core - // ========================================================================= - const events = table_core.processTableEvents(ctx, table_state.isEditing()); - - // ========================================================================= - // Aplicar navegación desde el Core - // ========================================================================= - if (events.move_up and 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; - } - - if (events.move_down and 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; - } - - if (events.move_left and 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; - } - - if (events.move_right and 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; - } - - if (events.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; - } - - if (events.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; - } - - if (events.go_to_first_col) { - const new_row: usize = if (events.go_to_first_row) 0 else @intCast(@max(0, table_state.selected_row)); - table_state.selectCell(new_row, 0); - result.selection_changed = true; - result.selected_row = new_row; - result.selected_col = 0; - } else if (events.go_to_first_row) { - const new_col: usize = @intCast(@max(0, table_state.selected_col)); - table_state.selectCell(0, new_col); - result.selection_changed = true; - result.selected_row = 0; - result.selected_col = new_col; - } - - if (events.go_to_last_col) { - const new_row: usize = if (events.go_to_last_row) - (if (row_count > 0) row_count - 1 else 0) - else - @intCast(@max(0, table_state.selected_row)); - const new_col: usize = col_count - 1; - table_state.selectCell(new_row, new_col); - result.selection_changed = true; - result.selected_row = new_row; - result.selected_col = new_col; - } else if (events.go_to_last_row) { - const new_row: usize = if (row_count > 0) row_count - 1 else 0; - 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; - } - - // ========================================================================= - // Tab navigation con commit Excel-style (DRY: lógica en table_core) - // ========================================================================= - if (events.tab_out and config.handle_tab) { - // Wrapper para obtener row_id por índice (en AdvancedTable, usamos índice como ID) - const RowIdGetter = struct { - total: usize, - - pub fn getRowId(self: @This(), row: usize) i64 { - // Ghost row está al final - if (row >= self.total) return table_core.NEW_ROW_ID; - return @intCast(row); - } - }; - - const getter = RowIdGetter{ .total = row_count }; - const current_row: usize = @intCast(@max(0, table_state.selected_row)); - const current_col: usize = @intCast(@max(0, table_state.selected_col)); - const forward = !events.tab_shift; - // AdvancedTable: usar count de filas existentes (no tiene ghost row como VirtualAdvancedTable) - const num_rows = row_count; - - const plan = table_core.planTabNavigation( - &table_state.row_edit_buffer, - current_row, - current_col, - col_count, - num_rows, - forward, - config.wrap_navigation, - getter, - &result.row_changes, - ); - - // Ejecutar el plan - switch (plan.action) { - .move, .move_with_commit => { - table_state.selectCell(plan.new_row, plan.new_col); - result.selection_changed = true; - - if (plan.action == .move_with_commit) { - if (plan.commit_info) |info| { - result.row_committed = true; - result.row_commit_id = info.row_id; - result.row_commit_is_insert = info.is_insert; - result.row_changes_count = info.change_count; - } - } - }, - .exit, .exit_with_commit => { - result.tab_out = true; - result.tab_shift = events.tab_shift; - - if (plan.action == .exit_with_commit) { - if (plan.commit_info) |info| { - result.row_committed = true; - result.row_commit_id = info.row_id; - result.row_commit_is_insert = info.is_insert; - result.row_changes_count = info.change_count; - } - } - }, - } - } - - // ========================================================================= - // Inicio de edición (F2, Space, o tecla alfanumérica desde el Core) - // ========================================================================= - if (events.start_editing and config.allow_edit) { - 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) { - if (table_state.getRow(@intCast(table_state.selected_row))) |row| { - const value = row.get(table_schema.columns[col_idx].name); - if (events.initial_char) |ch| { - // Tecla alfanumérica: empezar con ese caracter - var char_buf: [1]u8 = .{ch}; - table_state.startEditing(&char_buf); - } else { - // F2/Space: empezar con valor actual - 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; - } - } - } - } - - // ========================================================================= - // Operaciones CRUD (Ctrl+N, Ctrl+Delete, Ctrl+B desde el Core) - // ========================================================================= - if (config.allow_row_operations) { - // Ctrl+N: Insert row BELOW current row (inyección local) - if (events.insert_row) { - const insert_idx: usize = if (table_state.selected_row >= 0) - @as(usize, @intCast(table_state.selected_row)) + 1 // +1 = debajo - else - 0; - if (table_state.insertRow(insert_idx)) |new_idx| { - table_state.selectCell(new_idx, 0); - // Inicializar buffer para nueva fila (Excel-style) - table_state.row_edit_buffer.startEdit(table_core.NEW_ROW_ID, new_idx, true); - result.row_inserted = true; - result.selection_changed = true; - } else |_| {} - } - - // Ctrl+Delete o Ctrl+B: Delete row - if (events.delete_row) { - 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 (lógica específica de AdvancedTable) - // ========================================================================= - if (config.allow_multi_select and ctx.input.keyPressed(.a) and ctx.input.modifiers.ctrl) { - table_state.selectAllRows(); - result.selection_changed = true; - } - - // ========================================================================= - // Búsqueda incremental (solo si NO se inició edición) - // Solo para celdas no editables - lógica específica de AdvancedTable - // ========================================================================= - if (!events.start_editing and !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]; - - // Solo búsqueda si la celda actual NO es editable - const current_cell_editable = blk: { - if (!config.allow_edit) break :blk false; - if (table_state.selected_row < 0 or table_state.selected_col < 0) break :blk false; - const col_idx: usize = @intCast(table_state.selected_col); - if (col_idx >= table_schema.columns.len) break :blk false; - break :blk table_schema.columns[col_idx].editable; - }; - - if (!current_cell_editable) { - // Incremental search (type-to-search) in first column - for (text) |char| { - if (char >= 32 and char < 127) { - const search_term = table_state.addSearchChar(char, ctx.current_time_ms); - - 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; - - // Obtener texto original para revert - var orig_format_buf: [128]u8 = undefined; - const original_text: ?[]const u8 = if (table_state.original_value) |orig| - orig.format(&orig_format_buf) - else - null; - - // Usar table_core para procesamiento de teclado (DRY) con soporte selección - const kb_result = table_core.handleEditingKeyboard( - ctx, - &table_state.cell_edit.edit_buffer, - &table_state.cell_edit.edit_len, - &table_state.cell_edit.edit_cursor, - &table_state.cell_edit.escape_count, - original_text, - &table_state.cell_edit.selection_start, - &table_state.cell_edit.selection_end, - ); - - // Si no se procesó ningún evento, salir - if (!kb_result.handled) return; - - // Escape canceló la edición - if (kb_result.cancelled) { - table_state.stopEditing(); - result.edit_ended = true; - return; - } - - // Commit (Enter, Tab, flechas) y navegación - if (kb_result.committed) { - commitEdit(table_state, table_schema, result); - table_state.stopEditing(); - result.edit_ended = true; - - // Procesar navegación después de commit - switch (kb_result.navigate) { - .next_cell, .prev_cell => { - if (!config.handle_tab) return; - - const col_count = table_schema.columns.len; - const row_count = table_state.getRowCount(); - - if (kb_result.navigate == .prev_cell) { - // 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; - }, - .next_row, .prev_row => { - // Enter o flechas arriba/abajo: solo commit, sin navegación adicional aquí - // (La navegación entre filas se maneja en otro lugar si es necesario) - }, - .none => {}, - } - } -} - -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.nav.scroll_row) { - table_state.nav.scroll_row = row; - } else if (row >= table_state.nav.scroll_row + visible_rows) { - table_state.nav.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 // ============================================================================= diff --git a/src/widgets/advanced_table/drawing.zig b/src/widgets/advanced_table/drawing.zig new file mode 100644 index 0000000..7927eef --- /dev/null +++ b/src/widgets/advanced_table/drawing.zig @@ -0,0 +1,218 @@ +//! AdvancedTable - Funciones de Dibujo +//! +//! Funciones de renderizado extraídas del archivo principal. + +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"); + +const types = @import("types.zig"); +const schema = @import("schema.zig"); +const state = @import("state.zig"); + +pub const TableColors = types.TableColors; +pub const TableConfig = types.TableConfig; +pub const ColumnDef = schema.ColumnDef; +pub const TableSchema = schema.TableSchema; +pub const AdvancedTableState = state.AdvancedTableState; +pub const AdvancedTableResult = state.AdvancedTableResult; + +// ============================================================================= +// Draw: Header +// ============================================================================= + +pub 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 sorting = @import("sorting.zig"); + 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 + 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)) - 16; + const indicator = switch (table_state.sort_direction) { + .ascending => "▴", + .descending => "▾", + .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; + + sorting.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)); + } +} + +// ============================================================================= +// Draw: Scrollbar +// ============================================================================= + +pub 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; + + table_core.drawVerticalScrollbar(ctx, .{ + .track_x = bounds.x + @as(i32, @intCast(bounds.w -| scrollbar_w)), + .track_y = bounds.y + @as(i32, @intCast(header_h)), + .width = scrollbar_w, + .height = scrollbar_h, + .visible_count = visible_rows, + .total_count = total_rows, + .scroll_pos = table_state.nav.scroll_row, + .track_color = colors.border, + .thumb_color = colors.header_bg, + }); +} + +// ============================================================================= +// Draw: Editing Overlay +// ============================================================================= + +pub 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.nav.scroll_row) return; + const visible_row = row_idx - table_state.nav.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)); + + // Draw selection highlight + const sel_start = table_state.cell_edit.selection_start; + const sel_end = table_state.cell_edit.selection_end; + if (sel_start != sel_end and edit_text.len > 0) { + const sel_min = @min(sel_start, sel_end); + const sel_max = @min(@max(sel_start, sel_end), edit_text.len); + if (sel_max > sel_min) { + const sel_x = col_x + 4 + @as(i32, @intCast(sel_min * 8)); + const sel_width = @as(u32, @intCast((sel_max - sel_min) * 8)); + ctx.pushCommand(Command.rect(sel_x, text_y, sel_width, 8, colors.cell_selection_bg)); + } + } + + // Draw text + ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.text_selected)); + + // Draw cursor + if (sel_start == sel_end) { + const cursor_x = col_x + 4 + @as(i32, @intCast(table_state.cell_edit.edit_cursor * 8)); + ctx.pushCommand(Command.rect(cursor_x, text_y, 1, 8, colors.text_selected)); + } +} diff --git a/src/widgets/advanced_table/helpers.zig b/src/widgets/advanced_table/helpers.zig new file mode 100644 index 0000000..4ad218b --- /dev/null +++ b/src/widgets/advanced_table/helpers.zig @@ -0,0 +1,309 @@ +//! AdvancedTable - Helper Functions +//! +//! Funciones auxiliares extraídas del archivo principal. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; +const Style = @import("../../core/style.zig"); + +const types = @import("types.zig"); +const schema = @import("schema.zig"); +const state = @import("state.zig"); + +pub const CellValue = types.CellValue; +pub const ColumnType = types.ColumnType; +pub const CRUDAction = types.CRUDAction; +pub const Row = types.Row; +pub const ColumnDef = schema.ColumnDef; +pub const TableSchema = schema.TableSchema; +pub const AdvancedTableState = state.AdvancedTableState; +pub const AdvancedTableResult = state.AdvancedTableResult; + +// ============================================================================= +// Commit Edit +// ============================================================================= + +pub 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); + } + } +} + +// ============================================================================= +// Lookup & Auto-fill +// ============================================================================= + +/// Perform lookup in related table and auto-fill columns +pub 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; + } +} + +// ============================================================================= +// Parse Value +// ============================================================================= + +pub 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 }, + }; +} + +// ============================================================================= +// CRUD Action Detection +// ============================================================================= + +pub 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; +} + +pub 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; +} + +pub 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; +} + +// ============================================================================= +// Selection Visibility +// ============================================================================= + +pub 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.nav.scroll_row) { + table_state.nav.scroll_row = row; + } else if (row >= table_state.nav.scroll_row + visible_rows) { + table_state.nav.scroll_row = row - visible_rows + 1; + } +} + +// ============================================================================= +// Color Helpers +// ============================================================================= + +pub 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, + ); +} + +// ============================================================================= +// Callback System +// ============================================================================= + +pub 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; + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +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); +} diff --git a/src/widgets/advanced_table/input.zig b/src/widgets/advanced_table/input.zig new file mode 100644 index 0000000..569ace1 --- /dev/null +++ b/src/widgets/advanced_table/input.zig @@ -0,0 +1,551 @@ +//! AdvancedTable - Input Handling +//! +//! Funciones de teclado y mouse extraídas del archivo principal. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; +const Layout = @import("../../core/layout.zig"); +const Style = @import("../../core/style.zig"); +const table_core = @import("../table_core/table_core.zig"); + +const types = @import("types.zig"); +const schema = @import("schema.zig"); +const state = @import("state.zig"); +const helpers = @import("helpers.zig"); +const sorting = @import("sorting.zig"); + +pub const CellValue = types.CellValue; +pub const TableColors = types.TableColors; +pub const ColumnDef = schema.ColumnDef; +pub const TableSchema = schema.TableSchema; +pub const AdvancedTableState = state.AdvancedTableState; +pub const AdvancedTableResult = state.AdvancedTableResult; + +// ============================================================================= +// Row Click Handling +// ============================================================================= + +/// Maneja clicks en las filas de la tabla (single-click y double-click) +/// Retorna si hubo algún cambio de selección o edición iniciada +pub fn handleRowClicks( + ctx: *Context, + bounds: Layout.Rect, + table_state: *AdvancedTableState, + table_schema: *const TableSchema, + header_h: u32, + state_col_w: u32, + first_visible: usize, + last_visible: usize, + result: *AdvancedTableResult, +) void { + const config = table_schema.config; + const mouse = ctx.input.mousePos(); + + // Solo procesar si hubo click + if (!ctx.input.mousePressed(.left)) return; + + // Verificar si el click está en el área de filas + const rows_area_y = bounds.y + @as(i32, @intCast(header_h)); + if (mouse.y < rows_area_y) return; + if (mouse.x < bounds.x or mouse.x >= bounds.x + @as(i32, @intCast(bounds.w))) return; + + // Calcular fila clickeada + const relative_y = mouse.y - rows_area_y; + if (relative_y < 0) return; + const row_offset: usize = @intCast(@divFloor(relative_y, @as(i32, @intCast(config.row_height)))); + const row_idx = first_visible + row_offset; + + if (row_idx >= last_visible or row_idx >= table_state.getRowCount()) return; + + // Calcular columna clickeada + var col_x = bounds.x + @as(i32, @intCast(state_col_w)); + var clicked_col: ?usize = null; + + for (table_schema.columns, 0..) |col, col_idx| { + if (!col.visible) continue; + + const col_end = col_x + @as(i32, @intCast(col.width)); + if (mouse.x >= col_x and mouse.x < col_end) { + clicked_col = col_idx; + break; + } + col_x = col_end; + } + + if (clicked_col == null) return; + + const col_idx = clicked_col.?; + const is_selected_cell = table_state.selected_row == @as(i32, @intCast(row_idx)) and + table_state.selected_col == @as(i32, @intCast(col_idx)); + + // Detectar doble-click + const current_time = ctx.current_time_ms; + const same_cell = table_state.nav.double_click.last_click_row == @as(i32, @intCast(row_idx)) and + table_state.nav.double_click.last_click_col == @as(i32, @intCast(col_idx)); + const time_diff = current_time -| table_state.nav.double_click.last_click_time; + const is_double_click = same_cell and time_diff < table_state.nav.double_click.threshold_ms; + + if (is_double_click and config.allow_edit and col_idx < table_schema.columns.len and + table_schema.columns[col_idx].editable and !table_state.isEditing()) + { + // Double-click: iniciar edición + if (table_state.getRow(row_idx)) |row| { + const value = row.get(table_schema.columns[col_idx].name); + var format_buf: [128]u8 = undefined; + const edit_text = value.format(&format_buf); + table_state.startEditing(edit_text); + table_state.original_value = value; + result.edit_started = true; + } + // Reset click tracking + table_state.nav.double_click.last_click_time = 0; + table_state.nav.double_click.last_click_row = -1; + table_state.nav.double_click.last_click_col = -1; + } else { + // Single click: seleccionar celda + 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; + } + // Actualizar tracking para posible doble-click + table_state.nav.double_click.last_click_time = current_time; + table_state.nav.double_click.last_click_row = @intCast(row_idx); + table_state.nav.double_click.last_click_col = @intCast(col_idx); + } +} + +// ============================================================================= +// Keyboard Handling (Brain-in-Core pattern) +// ============================================================================= +// +// Arquitectura: TODA la lógica de decisión está en table_core.processTableEvents() +// Este handler solo aplica flags y maneja lógica específica de AdvancedTable. + +pub 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; + + // ========================================================================= + // BRAIN-IN-CORE: Delegar toda la lógica de decisión al Core + // ========================================================================= + const events = table_core.processTableEvents(ctx, table_state.isEditing()); + + // ========================================================================= + // Aplicar navegación desde el Core + // ========================================================================= + if (events.move_up and 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; + } + + if (events.move_down and 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; + } + + if (events.move_left and 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; + } + + if (events.move_right and 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; + } + + if (events.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; + } + + if (events.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; + } + + if (events.go_to_first_col) { + const new_row: usize = if (events.go_to_first_row) 0 else @intCast(@max(0, table_state.selected_row)); + table_state.selectCell(new_row, 0); + result.selection_changed = true; + result.selected_row = new_row; + result.selected_col = 0; + } else if (events.go_to_first_row) { + const new_col: usize = @intCast(@max(0, table_state.selected_col)); + table_state.selectCell(0, new_col); + result.selection_changed = true; + result.selected_row = 0; + result.selected_col = new_col; + } + + if (events.go_to_last_col) { + const new_row: usize = if (events.go_to_last_row) + (if (row_count > 0) row_count - 1 else 0) + else + @intCast(@max(0, table_state.selected_row)); + const new_col: usize = col_count - 1; + table_state.selectCell(new_row, new_col); + result.selection_changed = true; + result.selected_row = new_row; + result.selected_col = new_col; + } else if (events.go_to_last_row) { + const new_row: usize = if (row_count > 0) row_count - 1 else 0; + 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; + } + + // ========================================================================= + // Tab navigation con commit Excel-style (DRY: lógica en table_core) + // ========================================================================= + if (events.tab_out and config.handle_tab) { + // Wrapper para obtener row_id por índice (en AdvancedTable, usamos índice como ID) + const RowIdGetter = struct { + total: usize, + + pub fn getRowId(self: @This(), row: usize) i64 { + // Ghost row está al final + if (row >= self.total) return table_core.NEW_ROW_ID; + return @intCast(row); + } + }; + + const getter = RowIdGetter{ .total = row_count }; + const current_row: usize = @intCast(@max(0, table_state.selected_row)); + const current_col: usize = @intCast(@max(0, table_state.selected_col)); + const forward = !events.tab_shift; + // AdvancedTable: usar count de filas existentes (no tiene ghost row como VirtualAdvancedTable) + const num_rows = row_count; + + const plan = table_core.planTabNavigation( + &table_state.row_edit_buffer, + current_row, + current_col, + col_count, + num_rows, + forward, + config.wrap_navigation, + getter, + &result.row_changes, + ); + + // Ejecutar el plan + switch (plan.action) { + .move, .move_with_commit => { + table_state.selectCell(plan.new_row, plan.new_col); + result.selection_changed = true; + + if (plan.action == .move_with_commit) { + if (plan.commit_info) |info| { + result.row_committed = true; + result.row_commit_id = info.row_id; + result.row_commit_is_insert = info.is_insert; + result.row_changes_count = info.change_count; + } + } + }, + .exit, .exit_with_commit => { + result.tab_out = true; + result.tab_shift = events.tab_shift; + + if (plan.action == .exit_with_commit) { + if (plan.commit_info) |info| { + result.row_committed = true; + result.row_commit_id = info.row_id; + result.row_commit_is_insert = info.is_insert; + result.row_changes_count = info.change_count; + } + } + }, + } + } + + // ========================================================================= + // Inicio de edición (F2, Space, o tecla alfanumérica desde el Core) + // ========================================================================= + if (events.start_editing and config.allow_edit) { + 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) { + if (table_state.getRow(@intCast(table_state.selected_row))) |row| { + const value = row.get(table_schema.columns[col_idx].name); + if (events.initial_char) |ch| { + // Tecla alfanumérica: empezar con ese caracter + var char_buf: [1]u8 = .{ch}; + table_state.startEditing(&char_buf); + } else { + // F2/Space: empezar con valor actual + 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; + } + } + } + } + + // ========================================================================= + // Operaciones CRUD (Ctrl+N, Ctrl+Delete, Ctrl+B desde el Core) + // ========================================================================= + if (config.allow_row_operations) { + // Ctrl+N: Insert row BELOW current row (inyección local) + if (events.insert_row) { + const insert_idx: usize = if (table_state.selected_row >= 0) + @as(usize, @intCast(table_state.selected_row)) + 1 // +1 = debajo + else + 0; + if (table_state.insertRow(insert_idx)) |new_idx| { + table_state.selectCell(new_idx, 0); + // Inicializar buffer para nueva fila (Excel-style) + table_state.row_edit_buffer.startEdit(table_core.NEW_ROW_ID, new_idx, true); + result.row_inserted = true; + result.selection_changed = true; + } else |_| {} + } + + // Ctrl+Delete o Ctrl+B: Delete row + if (events.delete_row) { + 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 (lógica específica de AdvancedTable) + // ========================================================================= + if (config.allow_multi_select and ctx.input.keyPressed(.a) and ctx.input.modifiers.ctrl) { + table_state.selectAllRows(); + result.selection_changed = true; + } + + // ========================================================================= + // Búsqueda incremental (solo si NO se inició edición) + // Solo para celdas no editables - lógica específica de AdvancedTable + // ========================================================================= + if (!events.start_editing and !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]; + + // Solo búsqueda si la celda actual NO es editable + const current_cell_editable = blk: { + if (!config.allow_edit) break :blk false; + if (table_state.selected_row < 0 or table_state.selected_col < 0) break :blk false; + const col_idx: usize = @intCast(table_state.selected_col); + if (col_idx >= table_schema.columns.len) break :blk false; + break :blk table_schema.columns[col_idx].editable; + }; + + if (!current_cell_editable) { + // Incremental search (type-to-search) in first column + for (text) |char| { + if (char >= 32 and char < 127) { + const search_term = table_state.addSearchChar(char, ctx.current_time_ms); + + 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 (sorting.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 (sorting.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; + } + } + } + } + } + } + } +} + +// ============================================================================= +// Editing Keyboard +// ============================================================================= + +pub fn handleEditingKeyboard( + ctx: *Context, + table_state: *AdvancedTableState, + table_schema: *const TableSchema, + result: *AdvancedTableResult, +) void { + const config = table_schema.config; + + // Obtener texto original para revert + var orig_format_buf: [128]u8 = undefined; + const original_text: ?[]const u8 = if (table_state.original_value) |orig| + orig.format(&orig_format_buf) + else + null; + + // Usar table_core para procesamiento de teclado (DRY) con soporte selección + const kb_result = table_core.handleEditingKeyboard( + ctx, + &table_state.cell_edit.edit_buffer, + &table_state.cell_edit.edit_len, + &table_state.cell_edit.edit_cursor, + &table_state.cell_edit.escape_count, + original_text, + &table_state.cell_edit.selection_start, + &table_state.cell_edit.selection_end, + ); + + // Si no se procesó ningún evento, salir + if (!kb_result.handled) return; + + // Escape canceló la edición + if (kb_result.cancelled) { + table_state.stopEditing(); + result.edit_ended = true; + return; + } + + // Commit (Enter, Tab, flechas) y navegación + if (kb_result.committed) { + helpers.commitEdit(table_state, table_schema, result); + table_state.stopEditing(); + result.edit_ended = true; + + // Procesar navegación después de commit + switch (kb_result.navigate) { + .next_cell, .prev_cell => { + if (!config.handle_tab) return; + + const col_count = table_schema.columns.len; + const row_count = table_state.getRowCount(); + + if (kb_result.navigate == .prev_cell) { + // 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; + }, + .next_row, .prev_row => { + // Enter o flechas arriba/abajo: solo commit, sin navegación adicional aquí + // (La navegación entre filas se maneja en otro lugar si es necesario) + }, + .none => {}, + } + } +} diff --git a/src/widgets/advanced_table/sorting.zig b/src/widgets/advanced_table/sorting.zig new file mode 100644 index 0000000..75a64cb --- /dev/null +++ b/src/widgets/advanced_table/sorting.zig @@ -0,0 +1,113 @@ +//! AdvancedTable - Ordenación y Búsqueda +//! +//! Funciones de ordenación y búsqueda extraídas del archivo principal. + +const std = @import("std"); + +const types = @import("types.zig"); +const state = @import("state.zig"); + +pub const Row = types.Row; +pub const SortDirection = types.SortDirection; +pub const AdvancedTableState = state.AdvancedTableState; + +// ============================================================================= +// Sorting +// ============================================================================= + +/// Sort rows by column value +pub fn sortRows( + table_state: *AdvancedTableState, + column_name: []const u8, + direction: SortDirection, +) void { + if (direction == .none) return; + if (table_state.rows.items.len < 2) return; + + 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) { + std.mem.swap(Row, &table_state.rows.items[i], &table_state.rows.items[i + 1]); + swapRowStates(table_state, i, i + 1); + swapped = true; + } + } + } +} + +/// Swap state map entries between two row indices +pub fn swapRowStates(table_state: *AdvancedTableState, idx_a: usize, idx_b: usize) void { + 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 + } else if (val_a) |v| { + _ = map.remove(a); + map.put(b, v) catch {}; + } else if (val_b) |v| { + _ = map.remove(b); + map.put(a, v) catch {}; + } + } + }.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 +pub 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]; + 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 "startsWithIgnoreCase" { + try std.testing.expect(startsWithIgnoreCase("Hello World", "Hello")); + try std.testing.expect(startsWithIgnoreCase("Hello World", "hello")); + try std.testing.expect(startsWithIgnoreCase("hello world", "HELLO")); + try std.testing.expect(startsWithIgnoreCase("anything", "")); + try std.testing.expect(!startsWithIgnoreCase("Hello", "World")); + try std.testing.expect(!startsWithIgnoreCase("Hi", "Hello World")); +}