Compare commits

...

6 commits

Author SHA1 Message Date
08ffcdbac5 refactor(tables): FASE 4.5 - AdvancedTable usa drawRowsWithDataSource
- Añadir RowState enum a table_core.zig
- Añadir getRowState a TableDataSource.VTable (opcional)
- Añadir colores de estado a RowRenderColors
- Añadir draw_row_borders a DrawRowsConfig
- Añadir getRowState a MemoryDataSource
- Nueva función handleRowClicks() separando input de rendering
- AdvancedTable usa drawRowsWithDataSource (sin bucle for propio)
- Eliminar drawRow() y drawStateIndicator() locales (~160 líneas)

Objetivo cumplido: un solo bloque de código para renderizar filas
2025-12-27 18:52:31 +01:00
b8199aec38 refactor(tables): FASE 4 - Add unified drawRowsWithDataSource
Add unified row rendering function to table_core.zig that uses
TableDataSource interface for data access.

Changes:
- Add ColumnRenderDef, RowRenderColors, DrawRowsConfig types
- Add drawRowsWithDataSource() unified rendering function
- Update VirtualAdvancedTable.drawRows to use unified function
  with PagedDataSource

Note: AdvancedTable not yet integrated due to additional complexity
(state indicators, click handling interleaved with drawing).
See PLAN_REFACTOR_TABLES_CONTINUIDAD.md for details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:02:45 +01:00
cf2f91f8bc refactor(tables): FASE 4 - Add DataSource adapters
Add MemoryDataSource for AdvancedTable (in-memory ArrayList) and
PagedDataSource for VirtualAdvancedTable (paged DB data).

Both implement TableDataSource interface from table_core.zig,
enabling unified rendering patterns while respecting memory ownership.

New files:
- advanced_table/datasource.zig - MemoryDataSource
- virtual_advanced_table/paged_datasource.zig - PagedDataSource

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 17:37:04 +01:00
473bbdb648 feat(table_core): Add TableDataSource interface (FASE 3)
- TableDataSource: vtable interface for data abstraction
- getCellValueInto: buffer-based pattern to avoid memory issues
- getRowCount, getRowId: basic data access methods
- isCellEditable, invalidate: optional methods
- makeTableDataSource: helper to create from concrete types
- isGhostRow: convenience method for new row detection

This interface enables unified rendering for both memory and
paginated tables while enforcing safe memory patterns.
2025-12-27 17:13:38 +01:00
37e3b61aca refactor(states): Embed CellEditState in AdvancedTableState and VirtualAdvancedTableState
FASE 2 del refactor de tablas:
- AdvancedTableState: Embed cell_edit, delegate editing methods
- VirtualAdvancedTableState: Embed cell_edit, replace editing_cell/edit_buffer
- Update advanced_table.zig to use isEditing() and cell_edit.*
- Update virtual_advanced_table.zig to use getEditingCell()
- Update cell_editor.zig to use cell_edit.*

Reduces code duplication, centralizes editing logic in table_core
2025-12-27 16:45:58 +01:00
6819919060 refactor(table_core): Add CellEditState + NavigationState for composition
- Delete obsolete 'table_core (conflicted).zig'
- Add memory ownership protocol documentation
- Add CellEditState: embeddable edit state with buffer, cursor, escape handling
- Add NavigationState: embeddable nav state with active_col, scroll, Tab methods
- Maintains backward compatibility with existing EditState
2025-12-27 16:11:24 +01:00
9 changed files with 1229 additions and 818 deletions

View file

@ -47,6 +47,10 @@ pub const state = @import("state.zig");
pub const AdvancedTableState = state.AdvancedTableState; pub const AdvancedTableState = state.AdvancedTableState;
pub const AdvancedTableResult = state.AdvancedTableResult; pub const AdvancedTableResult = state.AdvancedTableResult;
// Re-export datasource
pub const datasource = @import("datasource.zig");
pub const MemoryDataSource = datasource.MemoryDataSource;
// Re-export table_core types // Re-export table_core types
pub const NavigateDirection = table_core.NavigateDirection; pub const NavigateDirection = table_core.NavigateDirection;
@ -135,21 +139,62 @@ pub fn advancedTableRect(
const first_visible = table_state.scroll_row; const first_visible = table_state.scroll_row;
const last_visible = @min(first_visible + visible_rows, table_state.getRowCount()); const last_visible = @min(first_visible + visible_rows, table_state.getRowCount());
// Draw visible rows // Manejar clicks en filas (separado del renderizado)
for (first_visible..last_visible) |row_idx| { handleRowClicks(ctx, bounds, table_state, table_schema, header_h, state_col_w, first_visible, last_visible, &result);
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( // Construir ColumnRenderDefs para la función unificada
bounds.x, var col_defs: [64]table_core.ColumnRenderDef = undefined;
row_y, var col_count: usize = 0;
bounds.w, for (table_schema.columns) |col| {
config.row_height, if (col_count >= 64) break;
); col_defs[col_count] = .{
.width = col.width,
drawRow(ctx, row_bounds, table_state, table_schema, row_idx, state_col_w, colors, has_focus, &result); .visible = col.visible,
.text_align = 0, // Por ahora left-align
};
col_count += 1;
} }
// Crear MemoryDataSource y dibujar filas con función unificada
var memory_ds = MemoryDataSource.init(table_state, table_schema.columns);
const data_src = memory_ds.toDataSource();
// Construir RowRenderColors manualmente (los dos TableColors son tipos diferentes)
const render_colors = table_core.RowRenderColors{
.row_normal = colors.row_normal,
.row_alternate = colors.row_alternate,
.selected_row = colors.selected_row,
.selected_row_unfocus = colors.selected_row_unfocus,
.selected_cell = colors.selected_cell,
.selected_cell_unfocus = Style.Color.rgb(80, 80, 90), // Default similar a table_core
.text_normal = colors.text_normal,
.text_selected = colors.text_selected,
.border = colors.border,
.state_modified = colors.state_modified,
.state_new = colors.state_new,
.state_deleted = colors.state_deleted,
.state_error = colors.state_error,
};
var cell_buffer: [256]u8 = undefined;
_ = table_core.drawRowsWithDataSource(ctx, data_src, .{
.bounds_x = bounds.x,
.bounds_y = bounds.y + @as(i32, @intCast(header_h)),
.bounds_w = bounds.w,
.row_height = config.row_height,
.first_row = first_visible,
.last_row = last_visible,
.has_focus = has_focus,
.selected_row = table_state.selected_row,
.active_col = @intCast(@max(0, table_state.selected_col)),
.colors = render_colors,
.columns = col_defs[0..col_count],
.state_indicator_width = state_col_w,
.apply_state_colors = true,
.draw_row_borders = true,
.alternating_rows = config.alternating_rows,
}, &cell_buffer);
// End clipping // End clipping
ctx.pushCommand(Command.clipEnd()); ctx.pushCommand(Command.clipEnd());
@ -175,7 +220,7 @@ pub fn advancedTableRect(
// Handle keyboard // Handle keyboard
if (has_focus) { if (has_focus) {
if (table_state.editing) { if (table_state.isEditing()) {
// Handle editing keyboard // Handle editing keyboard
handleEditingKeyboard(ctx, table_state, table_schema, &result); handleEditingKeyboard(ctx, table_state, table_schema, &result);
@ -255,6 +300,101 @@ fn invokeCallbacks(
} }
} }
// =============================================================================
// 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.last_click_row == @as(i32, @intCast(row_idx)) and
table_state.last_click_col == @as(i32, @intCast(col_idx));
const time_diff = current_time -| table_state.last_click_time;
const is_double_click = same_cell and time_diff < table_state.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.last_click_time = 0;
table_state.last_click_row = -1;
table_state.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.last_click_time = current_time;
table_state.last_click_row = @intCast(row_idx);
table_state.last_click_col = @intCast(col_idx);
}
}
// ============================================================================= // =============================================================================
// Internal Rendering // Internal Rendering
// ============================================================================= // =============================================================================
@ -350,167 +490,6 @@ fn drawHeader(
} }
} }
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 - más visible que antes
if (is_selected_cell and has_focus) {
// Fondo con tinte más visible (0.35 en lugar de 0.15)
ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, blendColor(row_bg, colors.selected_cell, 0.35)));
// Borde doble para mayor visibilidad
ctx.pushCommand(Command.rectOutline(col_x, bounds.y, col.width, config.row_height, colors.selected_cell));
ctx.pushCommand(Command.rectOutline(col_x + 1, bounds.y + 1, col.width -| 2, config.row_height -| 2, colors.selected_cell));
} else if (is_selected_cell) {
// Sin focus: indicación más sutil
ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, blendColor(row_bg, colors.selected_cell, 0.15)));
ctx.pushCommand(Command.rectOutline(col_x, bounds.y, col.width, config.row_height, colors.border));
}
// 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 and double-click
if (cell_clicked) {
const current_time = ctx.current_time_ms;
const same_cell = table_state.last_click_row == @as(i32, @intCast(row_idx)) and
table_state.last_click_col == @as(i32, @intCast(col_idx));
const time_diff = current_time -| table_state.last_click_time;
const is_double_click = same_cell and time_diff < table_state.double_click_threshold_ms;
if (is_double_click and config.allow_edit and col.editable and !table_state.editing) {
// Double-click: start editing
if (table_state.getRow(row_idx)) |row| {
const value = row.get(col.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.last_click_time = 0;
table_state.last_click_row = -1;
table_state.last_click_col = -1;
} else {
// Single click: select cell
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;
}
// Update click tracking for potential double-click
table_state.last_click_time = current_time;
table_state.last_click_row = @intCast(row_idx);
table_state.last_click_col = @intCast(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( fn drawScrollbar(
ctx: *Context, ctx: *Context,
bounds: Layout.Rect, bounds: Layout.Rect,
@ -594,7 +573,7 @@ fn drawEditingOverlay(
ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.text_selected)); ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.text_selected));
// Draw cursor // Draw cursor
const cursor_x = col_x + 4 + @as(i32, @intCast(table_state.edit_cursor * 8)); 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)); ctx.pushCommand(Command.rect(cursor_x, text_y, 1, 8, colors.text_selected));
} }
@ -933,10 +912,10 @@ fn handleEditingKeyboard(
// Usar table_core para procesamiento de teclado (DRY) // Usar table_core para procesamiento de teclado (DRY)
const kb_result = table_core.handleEditingKeyboard( const kb_result = table_core.handleEditingKeyboard(
ctx, ctx,
&table_state.edit_buffer, &table_state.cell_edit.edit_buffer,
&table_state.edit_len, &table_state.cell_edit.edit_len,
&table_state.edit_cursor, &table_state.cell_edit.edit_cursor,
&table_state.escape_count, &table_state.cell_edit.escape_count,
original_text, original_text,
); );

View file

@ -0,0 +1,140 @@
//! MemoryDataSource - Adaptador de TableDataSource para datos en memoria
//!
//! Wrappea AdvancedTableState + columnas para implementar la interfaz
//! TableDataSource de table_core. Permite que AdvancedTable use el mismo
//! patrón de renderizado que VirtualAdvancedTable.
const std = @import("std");
const table_core = @import("../table_core.zig");
const state_mod = @import("state.zig");
const schema_mod = @import("schema.zig");
const types = @import("types.zig");
const AdvancedTableState = state_mod.AdvancedTableState;
const ColumnDef = schema_mod.ColumnDef;
const CellValue = types.CellValue;
const Row = types.Row;
const TableDataSource = table_core.TableDataSource;
// =============================================================================
// MemoryDataSource
// =============================================================================
/// Adaptador que implementa TableDataSource para datos en memoria (ArrayList)
/// Usa el patrón getCellValueInto para escritura segura en buffer proporcionado
pub const MemoryDataSource = struct {
/// Referencia al estado de la tabla
state: *AdvancedTableState,
/// Definiciones de columnas (para mapear índice -> nombre)
columns: []const ColumnDef,
/// Buffer interno para formateo (evita allocaciones)
format_buffer: [256]u8 = undefined,
const Self = @This();
/// Crea un nuevo MemoryDataSource
pub fn init(state: *AdvancedTableState, columns: []const ColumnDef) Self {
return .{
.state = state,
.columns = columns,
};
}
// =========================================================================
// Implementación de TableDataSource
// =========================================================================
/// Retorna el número total de filas
pub fn getRowCount(self: *Self) usize {
return self.state.getRowCount();
}
/// Escribe el valor de una celda en el buffer proporcionado
/// Retorna slice del buffer con el contenido
pub fn getCellValueInto(self: *Self, row: usize, col: usize, buf: []u8) []const u8 {
// Validar índices
if (col >= self.columns.len) return "";
// Obtener fila
const row_data = self.state.getRowConst(row) orelse return "";
// Obtener nombre de columna
const col_name = self.columns[col].name;
// Obtener valor de celda
const cell_value = row_data.get(col_name);
// Formatear en el buffer proporcionado
return cell_value.format(buf);
}
/// Retorna el ID único de una fila
/// Para datos en memoria, usamos el índice como ID
/// TODO: Si Row tuviera campo 'id', lo usaríamos
pub fn getRowId(self: *Self, row: usize) i64 {
_ = self;
// Por ahora usamos el índice como ID
// En el futuro, Row podría tener un campo 'id' explícito
return @intCast(row);
}
/// Verifica si una celda es editable
pub fn isCellEditable(self: *Self, row: usize, col: usize) bool {
_ = row;
// Verificar configuración de columna
if (col >= self.columns.len) return false;
return self.columns[col].editable;
}
/// Invalida cache (no aplica para datos en memoria)
pub fn invalidate(self: *Self) void {
_ = self;
// No hay cache que invalidar en datos en memoria
}
/// Retorna el estado de una fila (normal, modified, new, deleted, error)
pub fn getRowState(self: *Self, row: usize) table_core.RowState {
// Delegar a AdvancedTableState que mantiene el tracking de estados
const local_state = self.state.getRowState(row);
// Convertir de types.RowState a table_core.RowState
return switch (local_state) {
.normal => .normal,
.modified => .modified,
.new => .new,
.deleted => .deleted,
.@"error" => .@"error",
};
}
// =========================================================================
// Conversión a TableDataSource
// =========================================================================
/// Crea TableDataSource vtable para este adaptador
pub fn toDataSource(self: *Self) TableDataSource {
return table_core.makeTableDataSource(Self, self);
}
};
// =============================================================================
// Tests
// =============================================================================
test "MemoryDataSource basic" {
// Test básico de que compila y los tipos son correctos
const columns = [_]ColumnDef{
.{ .name = "id", .title = "ID", .width = 50 },
.{ .name = "name", .title = "Name", .width = 200 },
};
var state = AdvancedTableState.init(std.testing.allocator);
defer state.deinit();
var ds = MemoryDataSource.init(&state, &columns);
const tds = ds.toDataSource();
try std.testing.expectEqual(@as(usize, 0), tds.getRowCount());
}

View file

@ -90,27 +90,16 @@ pub const AdvancedTableState = struct {
last_validation_message_len: usize = 0, last_validation_message_len: usize = 0,
// ========================================================================= // =========================================================================
// Editing // Editing (usa CellEditState de table_core para composición)
// ========================================================================= // =========================================================================
/// Is currently editing a cell /// Estado de edición embebido (Fase 2 refactor)
editing: bool = false, cell_edit: table_core.CellEditState = .{},
/// Edit buffer for current edit
edit_buffer: [MAX_EDIT_BUFFER]u8 = undefined,
/// Length of text in edit buffer
edit_len: usize = 0,
/// Cursor position in edit buffer
edit_cursor: usize = 0,
/// Original value before editing (for revert on Escape) /// Original value before editing (for revert on Escape)
/// NOTA: Mantenemos esto porque CellValue es más rico que buffer crudo
original_value: ?CellValue = null, original_value: ?CellValue = null,
/// Escape count (1 = revert, 2 = cancel)
escape_count: u8 = 0,
// ========================================================================= // =========================================================================
// Double-click detection // Double-click detection
// ========================================================================= // =========================================================================
@ -303,9 +292,7 @@ pub const AdvancedTableState = struct {
self.prev_selected_col = -1; self.prev_selected_col = -1;
// Clear editing // Clear editing
self.editing = false; self.cell_edit.stopEditing();
self.edit_len = 0;
self.escape_count = 0;
// Clear sorting // Clear sorting
self.sort_column = -1; self.sort_column = -1;
@ -689,76 +676,81 @@ pub const AdvancedTableState = struct {
} }
// ========================================================================= // =========================================================================
// Editing // Editing (delega a cell_edit embebido)
// ========================================================================= // =========================================================================
/// Start editing current cell /// Start editing current cell
/// Usa la celda seleccionada (selected_row, selected_col)
pub fn startEditing(self: *AdvancedTableState, initial_value: []const u8) void { pub fn startEditing(self: *AdvancedTableState, initial_value: []const u8) void {
self.editing = true; const row: usize = if (self.selected_row >= 0) @intCast(self.selected_row) else 0;
self.escape_count = 0; const col: usize = if (self.selected_col >= 0) @intCast(self.selected_col) else 0;
self.cell_edit.startEditing(row, col, initial_value, null);
// Copy initial value to edit buffer
const len = @min(initial_value.len, MAX_EDIT_BUFFER);
@memcpy(self.edit_buffer[0..len], initial_value[0..len]);
self.edit_len = len;
self.edit_cursor = len;
} }
/// Stop editing /// Stop editing
pub fn stopEditing(self: *AdvancedTableState) void { pub fn stopEditing(self: *AdvancedTableState) void {
self.editing = false; self.cell_edit.stopEditing();
self.edit_len = 0;
self.edit_cursor = 0;
self.escape_count = 0;
self.original_value = null; self.original_value = null;
} }
/// Get current edit text /// Get current edit text
pub fn getEditText(self: *const AdvancedTableState) []const u8 { pub fn getEditText(self: *const AdvancedTableState) []const u8 {
return self.edit_buffer[0..self.edit_len]; return self.cell_edit.getEditText();
}
/// Check if currently editing
pub fn isEditing(self: *const AdvancedTableState) bool {
return self.cell_edit.editing;
} }
/// Insert text at cursor position /// Insert text at cursor position
pub fn insertText(self: *AdvancedTableState, text: []const u8) void { pub fn insertText(self: *AdvancedTableState, text: []const u8) void {
for (text) |c| { for (text) |c| {
if (self.edit_len < MAX_EDIT_BUFFER) { if (self.cell_edit.edit_len < table_core.MAX_EDIT_BUFFER_SIZE) {
// Shift text after cursor // Shift text after cursor
var i = self.edit_len; var i = self.cell_edit.edit_len;
while (i > self.edit_cursor) : (i -= 1) { while (i > self.cell_edit.edit_cursor) : (i -= 1) {
self.edit_buffer[i] = self.edit_buffer[i - 1]; self.cell_edit.edit_buffer[i] = self.cell_edit.edit_buffer[i - 1];
} }
self.edit_buffer[self.edit_cursor] = c; self.cell_edit.edit_buffer[self.cell_edit.edit_cursor] = c;
self.edit_len += 1; self.cell_edit.edit_len += 1;
self.edit_cursor += 1; self.cell_edit.edit_cursor += 1;
} }
} }
} }
/// Delete character before cursor (backspace) /// Delete character before cursor (backspace)
pub fn deleteBackward(self: *AdvancedTableState) void { pub fn deleteBackward(self: *AdvancedTableState) void {
if (self.edit_cursor > 0) { if (self.cell_edit.edit_cursor > 0) {
// Shift text after cursor var i = self.cell_edit.edit_cursor - 1;
var i = self.edit_cursor - 1; while (i < self.cell_edit.edit_len - 1) : (i += 1) {
while (i < self.edit_len - 1) : (i += 1) { self.cell_edit.edit_buffer[i] = self.cell_edit.edit_buffer[i + 1];
self.edit_buffer[i] = self.edit_buffer[i + 1];
} }
self.edit_len -= 1; self.cell_edit.edit_len -= 1;
self.edit_cursor -= 1; self.cell_edit.edit_cursor -= 1;
} }
} }
/// Delete character at cursor (delete) /// Delete character at cursor (delete)
pub fn deleteForward(self: *AdvancedTableState) void { pub fn deleteForward(self: *AdvancedTableState) void {
if (self.edit_cursor < self.edit_len) { if (self.cell_edit.edit_cursor < self.cell_edit.edit_len) {
// Shift text after cursor var i = self.cell_edit.edit_cursor;
var i = self.edit_cursor; while (i < self.cell_edit.edit_len - 1) : (i += 1) {
while (i < self.edit_len - 1) : (i += 1) { self.cell_edit.edit_buffer[i] = self.cell_edit.edit_buffer[i + 1];
self.edit_buffer[i] = self.edit_buffer[i + 1];
} }
self.edit_len -= 1; self.cell_edit.edit_len -= 1;
} }
} }
/// Handle Escape key (revert or cancel)
pub fn handleEditEscape(self: *AdvancedTableState) table_core.CellEditState.EscapeAction {
const action = self.cell_edit.handleEscape();
if (action == .cancelled) {
self.original_value = null;
}
return action;
}
// ========================================================================= // =========================================================================
// Snapshots (for Auto-CRUD) // Snapshots (for Auto-CRUD)
// ========================================================================= // =========================================================================
@ -1037,10 +1029,10 @@ test "AdvancedTableState editing" {
var state = AdvancedTableState.init(std.testing.allocator); var state = AdvancedTableState.init(std.testing.allocator);
defer state.deinit(); defer state.deinit();
try std.testing.expect(!state.editing); try std.testing.expect(!state.isEditing());
state.startEditing("Hello"); state.startEditing("Hello");
try std.testing.expect(state.editing); try std.testing.expect(state.isEditing());
try std.testing.expectEqualStrings("Hello", state.getEditText()); try std.testing.expectEqualStrings("Hello", state.getEditText());
state.insertText(" World"); state.insertText(" World");
@ -1050,7 +1042,7 @@ test "AdvancedTableState editing" {
try std.testing.expectEqualStrings("Hello Worl", state.getEditText()); try std.testing.expectEqualStrings("Hello Worl", state.getEditText());
state.stopEditing(); state.stopEditing();
try std.testing.expect(!state.editing); try std.testing.expect(!state.isEditing());
} }
test "AdvancedTableState sorting" { test "AdvancedTableState sorting" {

View file

@ -1,422 +0,0 @@
//! Table Core - Funciones compartidas para renderizado de tablas
//!
//! Este módulo contiene la lógica común de renderizado utilizada por:
//! - AdvancedTable (datos en memoria)
//! - VirtualAdvancedTable (datos paginados desde DataProvider)
//!
//! Principio: Una sola implementación de UI, dos estrategias de datos.
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");
// =============================================================================
// Tipos comunes
// =============================================================================
/// Colores para renderizado de tabla
pub const TableColors = struct {
// Fondos
background: Style.Color = Style.Color.rgb(30, 30, 35),
row_normal: Style.Color = Style.Color.rgb(35, 35, 40),
row_alternate: Style.Color = Style.Color.rgb(40, 40, 45),
row_hover: Style.Color = Style.Color.rgb(50, 50, 60),
selected_row: Style.Color = Style.Color.rgb(0, 90, 180),
selected_row_unfocus: Style.Color = Style.Color.rgb(60, 60, 70),
// Celda activa
selected_cell: Style.Color = Style.Color.rgb(100, 150, 255),
selected_cell_unfocus: Style.Color = Style.Color.rgb(80, 80, 90),
// Edición
cell_editing_bg: Style.Color = Style.Color.rgb(255, 255, 255),
cell_editing_border: Style.Color = Style.Color.rgb(0, 120, 215),
cell_editing_text: Style.Color = Style.Color.rgb(0, 0, 0),
// Header
header_bg: Style.Color = Style.Color.rgb(45, 45, 50),
header_fg: Style.Color = Style.Color.rgb(200, 200, 200),
// Texto
text_normal: Style.Color = Style.Color.rgb(220, 220, 220),
text_selected: Style.Color = Style.Color.rgb(255, 255, 255),
text_placeholder: Style.Color = Style.Color.rgb(128, 128, 128),
// Bordes
border: Style.Color = Style.Color.rgb(60, 60, 65),
focus_ring: Style.Color = Style.Color.rgb(0, 120, 215),
};
/// Información de una celda para renderizado
pub const CellRenderInfo = struct {
/// Texto a mostrar
text: []const u8,
/// Posición X de la celda
x: i32,
/// Ancho de la celda
width: u32,
/// Es la celda actualmente seleccionada
is_selected: bool = false,
/// Es editable
is_editable: bool = true,
/// Alineación del texto (0=left, 1=center, 2=right)
text_align: u2 = 0,
};
/// Estado de edición para renderizado
pub const EditState = struct {
/// Está en modo edición
editing: bool = false,
/// Fila en edición
edit_row: i32 = -1,
/// Columna en edición
edit_col: i32 = -1,
/// Buffer de texto actual
edit_text: []const u8 = "",
/// Posición del cursor
edit_cursor: usize = 0,
};
/// Estado de doble-click
pub const DoubleClickState = struct {
last_click_time: u64 = 0,
last_click_row: i32 = -1,
last_click_col: i32 = -1,
threshold_ms: u64 = 400,
};
/// Resultado de procesar click en celda
pub const CellClickResult = struct {
/// Hubo click
clicked: bool = false,
/// Fue doble-click
double_click: bool = false,
/// Fila clickeada
row: usize = 0,
/// Columna clickeada
col: usize = 0,
};
// =============================================================================
// Funciones de renderizado
// =============================================================================
/// Dibuja el indicador de celda activa (fondo + borde)
/// Llamar ANTES de dibujar el texto de la celda
pub fn drawCellActiveIndicator(
ctx: *Context,
x: i32,
y: i32,
width: u32,
height: u32,
row_bg: Style.Color,
colors: *const TableColors,
has_focus: bool,
) void {
if (has_focus) {
// Con focus: fondo más visible + borde doble
const tinted_bg = blendColor(row_bg, colors.selected_cell, 0.35);
ctx.pushCommand(Command.rect(x, y, width, height, tinted_bg));
ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.selected_cell));
ctx.pushCommand(Command.rectOutline(x + 1, y + 1, width -| 2, height -| 2, colors.selected_cell));
} else {
// Sin focus: indicación más sutil
const tinted_bg = blendColor(row_bg, colors.selected_cell_unfocus, 0.15);
ctx.pushCommand(Command.rect(x, y, width, height, tinted_bg));
ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.border));
}
}
/// Dibuja el overlay de edición de celda
pub fn drawEditingOverlay(
ctx: *Context,
x: i32,
y: i32,
width: u32,
height: u32,
edit_text: []const u8,
cursor_pos: usize,
colors: *const TableColors,
) void {
// Fondo blanco
ctx.pushCommand(Command.rect(x, y, width, height, colors.cell_editing_bg));
// Borde azul
ctx.pushCommand(Command.rectOutline(x, y, width, height, colors.cell_editing_border));
// Texto
const text_y = y + @as(i32, @intCast((height -| 16) / 2));
const text_to_show = if (edit_text.len > 0) edit_text else "";
ctx.pushCommand(Command.text(x + 4, text_y, text_to_show, colors.cell_editing_text));
// Cursor parpadeante (simplificado: siempre visible)
// Calcular posición X del cursor basado en caracteres
const cursor_x = x + 4 + @as(i32, @intCast(cursor_pos * 8)); // Asumiendo fuente monospace 8px
ctx.pushCommand(Command.rect(cursor_x, text_y, 2, 16, colors.cell_editing_border));
}
/// Dibuja el texto de una celda
pub fn drawCellText(
ctx: *Context,
x: i32,
y: i32,
width: u32,
height: u32,
text: []const u8,
color: Style.Color,
text_align: u2,
) void {
const text_y = y + @as(i32, @intCast((height -| 16) / 2));
const text_x = switch (text_align) {
0 => x + 4, // Left
1 => x + @as(i32, @intCast(width / 2)) - @as(i32, @intCast(text.len * 4)), // Center (aprox)
2 => x + @as(i32, @intCast(width)) - @as(i32, @intCast(text.len * 8 + 4)), // Right
3 => x + 4, // Default left
};
ctx.pushCommand(Command.text(text_x, text_y, text, color));
}
/// Detecta si un click es doble-click
pub fn detectDoubleClick(
state: *DoubleClickState,
current_time: u64,
row: i32,
col: i32,
) bool {
const same_cell = state.last_click_row == row and state.last_click_col == col;
const time_diff = current_time -| state.last_click_time;
const is_double = same_cell and time_diff < state.threshold_ms;
if (is_double) {
// Reset para no detectar triple-click
state.last_click_time = 0;
state.last_click_row = -1;
state.last_click_col = -1;
} else {
// Guardar para próximo click
state.last_click_time = current_time;
state.last_click_row = row;
state.last_click_col = col;
}
return is_double;
}
// =============================================================================
// Manejo de teclado para edición
// =============================================================================
/// Resultado de procesar teclado en modo edición
pub const EditKeyboardResult = struct {
/// Se confirmó la edición (Enter)
committed: bool = false,
/// Se canceló la edición (Escape)
cancelled: bool = false,
/// Se revirtió al valor original (primer Escape)
reverted: bool = false,
/// Se debe navegar a siguiente celda (Tab)
navigate_next: bool = false,
/// Se debe navegar a celda anterior (Shift+Tab)
navigate_prev: bool = false,
/// El buffer de edición cambió
text_changed: bool = false,
};
/// Procesa teclado en modo edición
/// Modifica edit_buffer, edit_len, edit_cursor según las teclas
pub fn handleEditingKeyboard(
ctx: *Context,
edit_buffer: []u8,
edit_len: *usize,
edit_cursor: *usize,
escape_count: *u8,
original_text: ?[]const u8,
) EditKeyboardResult {
var result = EditKeyboardResult{};
// Escape: cancelar o revertir
if (ctx.input.keyPressed(.escape)) {
escape_count.* += 1;
if (escape_count.* >= 2 or original_text == null) {
result.cancelled = true;
} else {
// Revertir al valor original
if (original_text) |orig| {
const len = @min(orig.len, edit_buffer.len);
@memcpy(edit_buffer[0..len], orig[0..len]);
edit_len.* = len;
edit_cursor.* = len;
result.reverted = true;
}
}
return result;
}
// Reset escape count en cualquier otra tecla
escape_count.* = 0;
// Enter: confirmar
if (ctx.input.keyPressed(.enter)) {
result.committed = true;
return result;
}
// Tab: confirmar y navegar
if (ctx.input.keyPressed(.tab)) {
result.committed = true;
if (ctx.input.modifiers.shift) {
result.navigate_prev = true;
} else {
result.navigate_next = true;
}
return result;
}
// Movimiento del cursor
if (ctx.input.keyPressed(.left)) {
if (edit_cursor.* > 0) edit_cursor.* -= 1;
return result;
}
if (ctx.input.keyPressed(.right)) {
if (edit_cursor.* < edit_len.*) edit_cursor.* += 1;
return result;
}
if (ctx.input.keyPressed(.home)) {
edit_cursor.* = 0;
return result;
}
if (ctx.input.keyPressed(.end)) {
edit_cursor.* = edit_len.*;
return result;
}
// Backspace
if (ctx.input.keyPressed(.backspace)) {
if (edit_cursor.* > 0) {
// Shift characters left
var i: usize = edit_cursor.* - 1;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
edit_cursor.* -= 1;
result.text_changed = true;
}
return result;
}
// Delete
if (ctx.input.keyPressed(.delete)) {
if (edit_cursor.* < edit_len.*) {
var i: usize = edit_cursor.*;
while (i < edit_len.* - 1) : (i += 1) {
edit_buffer[i] = edit_buffer[i + 1];
}
edit_len.* -= 1;
result.text_changed = true;
}
return result;
}
// 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 (edit_len.* < edit_buffer.len - 1) {
// Shift characters right
var i: usize = edit_len.*;
while (i > edit_cursor.*) : (i -= 1) {
edit_buffer[i] = edit_buffer[i - 1];
}
edit_buffer[edit_cursor.*] = ch;
edit_len.* += 1;
edit_cursor.* += 1;
result.text_changed = true;
}
}
}
}
return result;
}
// =============================================================================
// Utilidades
// =============================================================================
/// Mezcla dos colores con un factor alpha
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,
);
}
/// Compara strings case-insensitive para búsqueda incremental
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 "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 "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"));
}
test "detectDoubleClick" {
var state = DoubleClickState{};
// Primer click
const first = detectDoubleClick(&state, 1000, 0, 0);
try std.testing.expect(!first);
// Segundo click rápido en misma celda = doble click
const second = detectDoubleClick(&state, 1200, 0, 0);
try std.testing.expect(second);
// Tercer click (estado reseteado)
const third = detectDoubleClick(&state, 1400, 0, 0);
try std.testing.expect(!third);
}

View file

@ -5,6 +5,20 @@
//! - VirtualAdvancedTable (datos paginados desde DataProvider) //! - VirtualAdvancedTable (datos paginados desde DataProvider)
//! //!
//! Principio: Una sola implementación de UI, dos estrategias de datos. //! Principio: Una sola implementación de UI, dos estrategias de datos.
//!
//! ## Protocolo de Propiedad de Memoria
//!
//! 1. **Strings de celda:** El DataSource retorna punteros a memoria estable.
//! El widget NO libera estos strings. Son válidos hasta el próximo fetch.
//!
//! 2. **Buffers de edición:** El widget mantiene edit_buffer[256] propio.
//! Los cambios se copian al DataSource solo en commit.
//!
//! 3. **Rendering:** Todos los strings pasados a ctx.pushCommand() deben ser
//! estables durante todo el frame. Usar buffers persistentes, NO stack.
//!
//! 4. **getValueInto pattern:** Cuando se necesita formatear valores,
//! el caller provee el buffer destino para evitar memory ownership issues.
const std = @import("std"); const std = @import("std");
const Context = @import("../core/context.zig").Context; const Context = @import("../core/context.zig").Context;
@ -65,7 +79,8 @@ pub const CellRenderInfo = struct {
text_align: u2 = 0, text_align: u2 = 0,
}; };
/// Estado de edición para renderizado /// Estado de edición para renderizado (info para draw)
/// NOTA: Para estado embebible en widgets, usar CellEditState
pub const EditState = struct { pub const EditState = struct {
/// Está en modo edición /// Está en modo edición
editing: bool = false, editing: bool = false,
@ -79,6 +94,201 @@ pub const EditState = struct {
edit_cursor: usize = 0, edit_cursor: usize = 0,
}; };
/// Estado de una fila (para indicadores visuales)
/// Compatible con advanced_table.types.RowState
pub const RowState = enum {
normal, // Sin cambios
modified, // Editada, pendiente de guardar
new, // Fila nueva, no existe en BD
deleted, // Marcada para eliminar
@"error", // Error de validación
};
// =============================================================================
// Estados embebibles (para composición en AdvancedTableState/VirtualAdvancedTableState)
// =============================================================================
/// Tamaño máximo del buffer de edición
pub const MAX_EDIT_BUFFER_SIZE: usize = 256;
/// Estado completo de edición de celda
/// Diseñado para ser embebido en AdvancedTableState y VirtualAdvancedTableState
pub const CellEditState = struct {
/// Está en modo edición
editing: bool = false,
/// Celda en edición (fila, columna)
edit_row: usize = 0,
edit_col: usize = 0,
/// Buffer de texto actual
edit_buffer: [MAX_EDIT_BUFFER_SIZE]u8 = undefined,
edit_len: usize = 0,
/// Posición del cursor
edit_cursor: usize = 0,
/// Valor original (para revertir con Escape)
original_buffer: [MAX_EDIT_BUFFER_SIZE]u8 = undefined,
original_len: usize = 0,
/// Contador de Escapes (1=revertir, 2=cancelar)
escape_count: u8 = 0,
/// Flag: el valor cambió respecto al original
value_changed: bool = false,
const Self = @This();
/// Inicia edición de una celda
pub fn startEditing(self: *Self, row: usize, col: usize, current_value: []const u8, initial_char: ?u8) void {
self.editing = true;
self.edit_row = row;
self.edit_col = col;
self.escape_count = 0;
self.value_changed = false;
// Guardar valor original
const orig_len = @min(current_value.len, MAX_EDIT_BUFFER_SIZE);
@memcpy(self.original_buffer[0..orig_len], current_value[0..orig_len]);
self.original_len = orig_len;
// Inicializar buffer de edición
if (initial_char) |c| {
// Tecla alfanumérica: empezar con ese caracter
self.edit_buffer[0] = c;
self.edit_len = 1;
self.edit_cursor = 1;
} else {
// F2/Space/DoubleClick: mostrar valor actual
@memcpy(self.edit_buffer[0..orig_len], current_value[0..orig_len]);
self.edit_len = orig_len;
self.edit_cursor = orig_len;
}
}
/// Obtiene el texto actual del editor
pub fn getEditText(self: *const Self) []const u8 {
return self.edit_buffer[0..self.edit_len];
}
/// Obtiene el valor original
pub fn getOriginalValue(self: *const Self) []const u8 {
return self.original_buffer[0..self.original_len];
}
/// Verifica si el valor cambió
pub fn hasChanged(self: *const Self) bool {
const current = self.getEditText();
const original = self.getOriginalValue();
return !std.mem.eql(u8, current, original);
}
/// Revierte al valor original (Escape 1)
pub fn revertToOriginal(self: *Self) void {
const orig = self.getOriginalValue();
@memcpy(self.edit_buffer[0..orig.len], orig);
self.edit_len = orig.len;
self.edit_cursor = orig.len;
}
/// Finaliza edición
pub fn stopEditing(self: *Self) void {
self.editing = false;
self.edit_len = 0;
self.edit_cursor = 0;
self.escape_count = 0;
}
/// Resultado de handleEscape
pub const EscapeAction = enum { reverted, cancelled, none };
/// Maneja Escape (retorna acción a tomar)
pub fn handleEscape(self: *Self) EscapeAction {
if (!self.editing) return .none;
self.escape_count += 1;
if (self.escape_count == 1) {
self.revertToOriginal();
return .reverted;
} else {
self.stopEditing();
return .cancelled;
}
}
/// Convierte a EditState para funciones de renderizado
pub fn toEditState(self: *const Self) EditState {
return .{
.editing = self.editing,
.edit_row = @intCast(self.edit_row),
.edit_col = @intCast(self.edit_col),
.edit_text = self.getEditText(),
.edit_cursor = self.edit_cursor,
};
}
};
/// Estado de navegación compartido
/// Diseñado para ser embebido en AdvancedTableState y VirtualAdvancedTableState
pub const NavigationState = struct {
/// Columna activa (para Tab navigation)
active_col: usize = 0,
/// Scroll vertical (en filas)
scroll_row: usize = 0,
/// Scroll horizontal (en pixels)
scroll_x: i32 = 0,
/// El widget tiene focus
has_focus: bool = false,
/// Double-click state
double_click: DoubleClickState = .{},
const Self = @This();
/// Navega a siguiente celda (Tab)
/// Retorna nueva posición y si navegó o salió del widget
pub fn tabToNextCell(self: *Self, current_row: usize, num_cols: usize, num_rows: usize, wrap: bool) struct { row: usize, col: usize, result: TabNavigateResult } {
const pos = calculateNextCell(current_row, self.active_col, num_cols, num_rows, wrap);
if (pos.result == .navigated) {
self.active_col = pos.col;
}
return .{ .row = pos.row, .col = pos.col, .result = pos.result };
}
/// Navega a celda anterior (Shift+Tab)
pub fn tabToPrevCell(self: *Self, current_row: usize, num_cols: usize, num_rows: usize, wrap: bool) struct { row: usize, col: usize, result: TabNavigateResult } {
const pos = calculatePrevCell(current_row, self.active_col, num_cols, num_rows, wrap);
if (pos.result == .navigated) {
self.active_col = pos.col;
}
return .{ .row = pos.row, .col = pos.col, .result = pos.result };
}
/// Mueve a columna anterior
pub fn moveToPrevCol(self: *Self) void {
if (self.active_col > 0) self.active_col -= 1;
}
/// Mueve a columna siguiente
pub fn moveToNextCol(self: *Self, max_cols: usize) void {
if (self.active_col + 1 < max_cols) self.active_col += 1;
}
/// Va a primera columna
pub fn goToFirstCol(self: *Self) void {
self.active_col = 0;
}
/// Va a última columna
pub fn goToLastCol(self: *Self, max_cols: usize) void {
if (max_cols > 0) self.active_col = max_cols - 1;
}
};
/// Estado de doble-click /// Estado de doble-click
pub const DoubleClickState = struct { pub const DoubleClickState = struct {
last_click_time: u64 = 0, last_click_time: u64 = 0,
@ -180,6 +390,263 @@ pub fn drawCellText(
ctx.pushCommand(Command.text(text_x, text_y, text, color)); ctx.pushCommand(Command.text(text_x, text_y, text, color));
} }
/// Dibuja el indicador de estado de fila (círculo/cuadrado pequeño)
/// Llamado desde drawRowsWithDataSource cuando state_indicator_width > 0
pub fn drawStateIndicator(
ctx: *Context,
x: i32,
y: i32,
w: u32,
h: u32,
row_state: RowState,
colors: *const RowRenderColors,
) void {
// No dibujar nada para estado normal
if (row_state == .normal) return;
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.state_modified,
.new => colors.state_new,
.deleted => colors.state_deleted,
.@"error" => colors.state_error,
.normal => unreachable, // Ya verificado arriba
};
// Dibujar cuadrado indicador
ctx.pushCommand(Command.rect(indicator_x, indicator_y, indicator_size, indicator_size, color));
}
// =============================================================================
// Renderizado unificado de filas (FASE 4)
// =============================================================================
/// Definición de columna para renderizado unificado
pub const ColumnRenderDef = struct {
/// Ancho de la columna en pixels
width: u32,
/// Alineación: 0=left, 1=center, 2=right
text_align: u2 = 0,
/// Columna visible
visible: bool = true,
};
/// Colores para renderizado unificado de filas
pub const RowRenderColors = struct {
// Colores base de fila
row_normal: Style.Color,
row_alternate: Style.Color,
selected_row: Style.Color,
selected_row_unfocus: Style.Color,
selected_cell: Style.Color,
selected_cell_unfocus: Style.Color,
text_normal: Style.Color,
text_selected: Style.Color,
border: Style.Color,
// Colores de estado (para blending)
state_modified: Style.Color = Style.Color.rgb(255, 200, 100), // Naranja
state_new: Style.Color = Style.Color.rgb(100, 200, 100), // Verde
state_deleted: Style.Color = Style.Color.rgb(255, 100, 100), // Rojo
state_error: Style.Color = Style.Color.rgb(255, 50, 50), // Rojo intenso
/// Crea RowRenderColors desde TableColors
pub fn fromTableColors(tc: *const TableColors) RowRenderColors {
return .{
.row_normal = tc.row_normal,
.row_alternate = tc.row_alternate,
.selected_row = tc.selected_row,
.selected_row_unfocus = tc.selected_row_unfocus,
.selected_cell = tc.selected_cell,
.selected_cell_unfocus = tc.selected_cell_unfocus,
.text_normal = tc.text_normal,
.text_selected = tc.text_selected,
.border = tc.border,
};
}
};
/// Configuración para drawRowsWithDataSource
pub const DrawRowsConfig = struct {
/// Bounds del área de contenido
bounds_x: i32,
bounds_y: i32,
bounds_w: u32,
/// Altura de cada fila
row_height: u32,
/// Primera fila a dibujar (índice global)
first_row: usize,
/// Última fila a dibujar (exclusivo)
last_row: usize,
/// Offset horizontal de scroll
scroll_x: i32 = 0,
/// Usar colores alternados
alternating_rows: bool = true,
/// Widget tiene focus
has_focus: bool = false,
/// Fila seleccionada (-1 = ninguna)
selected_row: i32 = -1,
/// Columna activa
active_col: usize = 0,
/// Colores
colors: RowRenderColors,
/// Columnas
columns: []const ColumnRenderDef,
/// Ancho de columna de indicadores de estado (0 = deshabilitada)
state_indicator_width: u32 = 0,
/// Aplicar blending de color según estado de fila
apply_state_colors: bool = false,
/// Dibujar borde inferior en cada fila
draw_row_borders: bool = false,
};
/// Dibuja las filas de una tabla usando TableDataSource.
/// Esta es la función unificada que usan tanto AdvancedTable como VirtualAdvancedTable.
///
/// Parámetros:
/// - ctx: Contexto de renderizado
/// - datasource: Fuente de datos (MemoryDataSource o PagedDataSource)
/// - config: Configuración del renderizado
/// - cell_buffer: Buffer para formatear valores de celda (debe persistir durante el frame)
///
/// Retorna el número de filas dibujadas.
pub fn drawRowsWithDataSource(
ctx: *Context,
datasource: TableDataSource,
config: DrawRowsConfig,
cell_buffer: []u8,
) usize {
var rows_drawn: usize = 0;
var row_y = config.bounds_y;
var row_idx = config.first_row;
while (row_idx < config.last_row) : (row_idx += 1) {
const is_selected = config.selected_row >= 0 and
@as(usize, @intCast(config.selected_row)) == row_idx;
// Obtener estado de la fila
const row_state = datasource.getRowState(row_idx);
// Determinar color de fondo base
const is_alternate = config.alternating_rows and row_idx % 2 == 1;
var row_bg: Style.Color = if (is_alternate)
config.colors.row_alternate
else
config.colors.row_normal;
// Aplicar blending de color según estado (si está habilitado)
if (config.apply_state_colors) {
row_bg = switch (row_state) {
.modified => blendColor(row_bg, config.colors.state_modified, 0.2),
.new => blendColor(row_bg, config.colors.state_new, 0.2),
.deleted => blendColor(row_bg, config.colors.state_deleted, 0.3),
.@"error" => blendColor(row_bg, config.colors.state_error, 0.3),
.normal => row_bg,
};
}
// Aplicar selección (override del estado)
if (is_selected) {
row_bg = if (config.has_focus) config.colors.selected_row else config.colors.selected_row_unfocus;
}
// Dibujar fondo de fila
ctx.pushCommand(Command.rect(
config.bounds_x,
row_y,
config.bounds_w,
config.row_height,
row_bg,
));
// Posición X inicial (después de state indicator si existe)
var col_x = config.bounds_x - config.scroll_x;
// Dibujar columna de indicador de estado (si está habilitada)
if (config.state_indicator_width > 0) {
drawStateIndicator(ctx, config.bounds_x, row_y, config.state_indicator_width, config.row_height, row_state, &config.colors);
col_x += @as(i32, @intCast(config.state_indicator_width));
}
// Dibujar celdas de datos
for (config.columns, 0..) |col, col_idx| {
if (!col.visible) continue;
const col_end = col_x + @as(i32, @intCast(col.width));
// Solo dibujar si la columna es visible en pantalla
if (col_end > config.bounds_x and
col_x < config.bounds_x + @as(i32, @intCast(config.bounds_w)))
{
const is_active_cell = is_selected and config.active_col == col_idx;
// Indicador de celda activa
if (is_active_cell) {
drawCellActiveIndicator(
ctx,
col_x,
row_y,
col.width,
config.row_height,
row_bg,
&TableColors{
.selected_cell = config.colors.selected_cell,
.selected_cell_unfocus = config.colors.selected_cell_unfocus,
.border = config.colors.border,
},
config.has_focus,
);
}
// Obtener texto de la celda
const cell_text = datasource.getCellValueInto(row_idx, col_idx, cell_buffer);
// Copiar a frame allocator para persistencia durante render
const text_to_draw = ctx.frameAllocator().dupe(u8, cell_text) catch cell_text;
// Color de texto
const text_color = if (is_selected and config.has_focus)
config.colors.text_selected
else
config.colors.text_normal;
// Dibujar texto
drawCellText(
ctx,
col_x,
row_y,
col.width,
config.row_height,
text_to_draw,
text_color,
col.text_align,
);
}
col_x = col_end;
}
// Dibujar borde inferior de fila (si está habilitado)
if (config.draw_row_borders) {
ctx.pushCommand(Command.rect(
config.bounds_x,
row_y + @as(i32, @intCast(config.row_height)) - 1,
config.bounds_w,
1,
config.colors.border,
));
}
row_y += @as(i32, @intCast(config.row_height));
rows_drawn += 1;
}
return rows_drawn;
}
/// Detecta si un click es doble-click /// Detecta si un click es doble-click
pub fn detectDoubleClick( pub fn detectDoubleClick(
state: *DoubleClickState, state: *DoubleClickState,
@ -738,6 +1205,133 @@ pub fn toggleSort(
}; };
} }
// =============================================================================
// TableDataSource Interface (FASE 3)
// =============================================================================
//
// ## Interfaz TableDataSource
//
// Abstrae el origen de datos para tablas, permitiendo que el mismo widget
// renderice datos desde memoria (AdvancedTable) o desde BD paginada (VirtualAdvancedTable).
//
// ### Protocolo de Memoria
//
// `getCellValueInto` escribe directamente en el buffer proporcionado por el widget.
// Esto elimina problemas de ownership: el widget controla la vida del buffer.
//
// ### Ejemplo de uso:
// ```zig
// var buf: [256]u8 = undefined;
// const value = data_source.getCellValueInto(row, col, &buf);
// // value es un slice de buf, válido mientras buf exista
// ```
/// Interfaz genérica para proveer datos a tablas
/// Usa vtable pattern para polimorfismo en runtime
pub const TableDataSource = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
/// Retorna el número total de filas en el datasource
getRowCount: *const fn (ptr: *anyopaque) usize,
/// Escribe el valor de una celda en el buffer proporcionado
/// Retorna el slice del buffer con el contenido escrito
/// Si la celda no existe o está vacía, retorna ""
getCellValueInto: *const fn (ptr: *anyopaque, row: usize, col: usize, buf: []u8) []const u8,
/// Retorna el ID único de una fila (para selección persistente)
/// NEW_ROW_ID (-1) indica fila nueva no guardada
getRowId: *const fn (ptr: *anyopaque, row: usize) i64,
/// Verifica si una celda es editable (opcional, default true)
isCellEditable: ?*const fn (ptr: *anyopaque, row: usize, col: usize) bool = null,
/// Retorna el estado de una fila (opcional, default .normal)
/// Usado para colores de estado (modified, new, deleted, error)
getRowState: ?*const fn (ptr: *anyopaque, row: usize) RowState = null,
/// Invalida cache interno (para refresh)
invalidate: ?*const fn (ptr: *anyopaque) void = null,
};
// =========================================================================
// Métodos de conveniencia
// =========================================================================
/// Obtiene el número de filas
pub fn getRowCount(self: TableDataSource) usize {
return self.vtable.getRowCount(self.ptr);
}
/// Escribe valor de celda en buffer
pub fn getCellValueInto(self: TableDataSource, row: usize, col: usize, buf: []u8) []const u8 {
return self.vtable.getCellValueInto(self.ptr, row, col, buf);
}
/// Obtiene ID de fila
pub fn getRowId(self: TableDataSource, row: usize) i64 {
return self.vtable.getRowId(self.ptr, row);
}
/// Verifica si celda es editable
pub fn isCellEditable(self: TableDataSource, row: usize, col: usize) bool {
if (self.vtable.isCellEditable) |func| {
return func(self.ptr, row, col);
}
return true; // Default: todas editables
}
/// Invalida cache
pub fn invalidate(self: TableDataSource) void {
if (self.vtable.invalidate) |func| {
func(self.ptr);
}
}
/// Obtiene el estado de una fila
pub fn getRowState(self: TableDataSource, row: usize) RowState {
if (self.vtable.getRowState) |func| {
return func(self.ptr, row);
}
return .normal; // Default: estado normal
}
/// Verifica si la fila es la ghost row (nueva)
pub fn isGhostRow(self: TableDataSource, row: usize) bool {
return self.getRowId(row) == NEW_ROW_ID;
}
};
/// Helper para crear TableDataSource desde un tipo concreto
/// El tipo T debe tener los métodos: getRowCount, getCellValueInto, getRowId
pub fn makeTableDataSource(comptime T: type, impl: *T) TableDataSource {
const vtable = comptime blk: {
var vt: TableDataSource.VTable = .{
.getRowCount = @ptrCast(&T.getRowCount),
.getCellValueInto = @ptrCast(&T.getCellValueInto),
.getRowId = @ptrCast(&T.getRowId),
};
// Métodos opcionales
if (@hasDecl(T, "isCellEditable")) {
vt.isCellEditable = @ptrCast(&T.isCellEditable);
}
if (@hasDecl(T, "getRowState")) {
vt.getRowState = @ptrCast(&T.getRowState);
}
if (@hasDecl(T, "invalidate")) {
vt.invalidate = @ptrCast(&T.invalidate);
}
break :blk vt;
};
return .{
.ptr = impl,
.vtable = &vtable,
};
}
// ============================================================================= // =============================================================================
// Tests // Tests
// ============================================================================= // =============================================================================

View file

@ -89,7 +89,7 @@ pub fn drawCellEditor(
)); ));
// Cursor: posición calculada con measureTextToCursor (TTF-aware) // Cursor: posición calculada con measureTextToCursor (TTF-aware)
const cursor_offset = ctx.measureTextToCursor(text, state.edit_cursor); const cursor_offset = ctx.measureTextToCursor(text, state.cell_edit.edit_cursor);
const cursor_x = geom.x + padding + @as(i32, @intCast(cursor_offset)); const cursor_x = geom.x + padding + @as(i32, @intCast(cursor_offset));
// Visibilidad del cursor usando función compartida de Context // Visibilidad del cursor usando función compartida de Context
@ -109,10 +109,10 @@ pub fn drawCellEditor(
const original_text = state.getOriginalValue(); const original_text = state.getOriginalValue();
const kb_result = table_core.handleEditingKeyboard( const kb_result = table_core.handleEditingKeyboard(
ctx, ctx,
&state.edit_buffer, &state.cell_edit.edit_buffer,
&state.edit_buffer_len, &state.cell_edit.edit_len,
&state.edit_cursor, &state.cell_edit.edit_cursor,
&state.escape_count, &state.cell_edit.escape_count,
if (original_text.len > 0) original_text else null, if (original_text.len > 0) original_text else null,
); );

View file

@ -0,0 +1,184 @@
//! PagedDataSource - Adaptador de TableDataSource para datos paginados
//!
//! Wrappea VirtualAdvancedTableState + DataProvider para implementar la interfaz
//! TableDataSource de table_core. Permite unificar el pattern de renderizado
//! entre AdvancedTable (memoria) y VirtualAdvancedTable (paginado).
const std = @import("std");
const table_core = @import("../table_core.zig");
const state_mod = @import("state.zig");
const types = @import("types.zig");
const data_provider_mod = @import("data_provider.zig");
const VirtualAdvancedTableState = state_mod.VirtualAdvancedTableState;
const ColumnDef = types.ColumnDef;
const RowData = types.RowData;
const DataProvider = data_provider_mod.DataProvider;
const TableDataSource = table_core.TableDataSource;
// =============================================================================
// PagedDataSource
// =============================================================================
/// Adaptador que implementa TableDataSource para datos paginados (ventana virtual).
/// Lee valores desde current_window en el state, que es propiedad del DataProvider.
pub const PagedDataSource = struct {
/// Referencia al estado de la tabla (contiene current_window)
state: *VirtualAdvancedTableState,
/// DataProvider subyacente (para getRowId cuando fuera de ventana)
provider: ?DataProvider,
/// Definiciones de columnas (para validar índices)
columns: []const ColumnDef,
const Self = @This();
/// Crea un nuevo PagedDataSource
pub fn init(
state: *VirtualAdvancedTableState,
columns: []const ColumnDef,
provider: ?DataProvider,
) Self {
return .{
.state = state,
.columns = columns,
.provider = provider,
};
}
// =========================================================================
// Implementación de TableDataSource
// =========================================================================
/// Retorna el número total de filas (filtered count)
pub fn getRowCount(self: *Self) usize {
// Usar conteo filtrado si está disponible
const count_info = self.state.getDisplayCount();
return count_info.value;
}
/// Escribe el valor de una celda en el buffer proporcionado.
/// El row es índice global, se convierte a índice de ventana.
/// Retorna slice del buffer con el contenido.
pub fn getCellValueInto(self: *Self, row: usize, col: usize, buf: []u8) []const u8 {
// Validar columna
if (col >= self.columns.len) return "";
// Convertir índice global a índice de ventana
const window_idx = self.state.globalToWindowIndex(row) orelse {
// Fila fuera de ventana actual - retornar vacío
// (el caller debería asegurarse de pedir solo filas visibles)
return "";
};
// Obtener fila de la ventana
if (window_idx >= self.state.current_window.len) return "";
const row_data = self.state.current_window[window_idx];
// Obtener valor de la columna
if (col >= row_data.values.len) return "";
const value = row_data.values[col];
// Copiar al buffer proporcionado
const copy_len = @min(value.len, buf.len);
@memcpy(buf[0..copy_len], value[0..copy_len]);
return buf[0..copy_len];
}
/// Retorna el ID único de una fila.
/// Si está en ventana, usa window data. Si no, consulta al provider.
pub fn getRowId(self: *Self, row: usize) i64 {
// Intentar obtener de la ventana
if (self.state.globalToWindowIndex(row)) |window_idx| {
if (window_idx < self.state.current_window.len) {
return self.state.current_window[window_idx].id;
}
}
// Fallback al provider si está disponible
if (self.provider) |provider| {
return provider.getRowId(row) orelse -1;
}
// Si no hay provider, usar índice como fallback
return @intCast(row);
}
/// Verifica si una celda es editable.
/// TODO: Podría usar metadata de columnas cuando esté disponible.
pub fn isCellEditable(self: *Self, row: usize, col: usize) bool {
_ = row;
// Por ahora, todas las columnas son no editables por defecto
// La edición se maneja a nivel de VirtualAdvancedTable
if (col >= self.columns.len) return false;
return false; // VirtualAdvancedTable maneja edición de forma específica
}
/// Invalida cache.
/// Delega al state para forzar refetch.
pub fn invalidate(self: *Self) void {
self.state.invalidateWindow();
}
// =========================================================================
// Conversión a TableDataSource
// =========================================================================
/// Crea TableDataSource vtable para este adaptador
pub fn toDataSource(self: *Self) TableDataSource {
return table_core.makeTableDataSource(Self, self);
}
};
// =============================================================================
// Tests
// =============================================================================
test "PagedDataSource basic" {
// Test básico de que compila y los tipos son correctos
const columns = [_]ColumnDef{
.{ .name = "id", .title = "ID", .width = 50 },
.{ .name = "name", .title = "Name", .width = 200 },
};
var state = VirtualAdvancedTableState{};
var ds = PagedDataSource.init(&state, &columns, null);
const tds = ds.toDataSource();
// Sin datos, row count es 0
try std.testing.expectEqual(@as(usize, 0), tds.getRowCount());
}
test "PagedDataSource getCellValueInto" {
const columns = [_]ColumnDef{
.{ .name = "id", .title = "ID", .width = 50 },
.{ .name = "name", .title = "Name", .width = 200 },
};
// Crear datos de prueba
const values1 = [_][]const u8{ "1", "Alice" };
const values2 = [_][]const u8{ "2", "Bob" };
const rows = [_]RowData{
.{ .id = 100, .values = &values1 },
.{ .id = 101, .values = &values2 },
};
var state = VirtualAdvancedTableState{};
state.current_window = &rows;
state.window_start = 10; // Ventana empieza en índice global 10
state.filtered_count = .{ .value = 100, .state = .ready };
var ds = PagedDataSource.init(&state, &columns, null);
// Buffer para valores
var buf: [256]u8 = undefined;
// Fila en ventana (global 10 = window 0)
const val1 = ds.getCellValueInto(10, 1, &buf);
try std.testing.expectEqualStrings("Alice", val1);
// Fila fuera de ventana
const val2 = ds.getCellValueInto(0, 0, &buf);
try std.testing.expectEqualStrings("", val2);
}

View file

@ -147,18 +147,11 @@ pub const VirtualAdvancedTableState = struct {
footer_display_len: usize = 0, footer_display_len: usize = 0,
// ========================================================================= // =========================================================================
// Estado de edición CRUD Excel-style // Estado de edición CRUD Excel-style (usa CellEditState de table_core)
// ========================================================================= // =========================================================================
/// Celda actualmente en edición (null = no editando) /// Estado de edición embebido (Fase 2 refactor)
editing_cell: ?CellId = null, cell_edit: table_core.CellEditState = .{},
/// Valor original de la celda (para Escape revertir)
original_value: [256]u8 = undefined,
original_value_len: usize = 0,
/// Contador de Escapes (1 = revertir celda, 2 = descartar fila)
escape_count: u8 = 0,
/// Fila actual tiene cambios sin guardar en BD /// Fila actual tiene cambios sin guardar en BD
row_dirty: bool = false, row_dirty: bool = false,
@ -166,11 +159,6 @@ pub const VirtualAdvancedTableState = struct {
/// Última fila editada (para detectar cambio de fila) /// Última fila editada (para detectar cambio de fila)
last_edited_row: ?usize = null, last_edited_row: ?usize = null,
/// Buffer de edición (texto actual en el editor)
edit_buffer: [256]u8 = undefined,
edit_buffer_len: usize = 0,
edit_cursor: usize = 0,
/// Tiempo de última edición (para parpadeo cursor) /// Tiempo de última edición (para parpadeo cursor)
last_edit_time_ms: u64 = 0, last_edit_time_ms: u64 = 0,
@ -579,96 +567,76 @@ pub const VirtualAdvancedTableState = struct {
} }
// ========================================================================= // =========================================================================
// Métodos de edición CRUD Excel-style // Métodos de edición CRUD Excel-style (delega a cell_edit embebido)
// ========================================================================= // =========================================================================
/// Verifica si hay una celda en edición /// Verifica si hay una celda en edición
pub fn isEditing(self: *const Self) bool { pub fn isEditing(self: *const Self) bool {
return self.editing_cell != null; return self.cell_edit.editing;
}
/// Obtiene la celda actualmente en edición
pub fn getEditingCell(self: *const Self) ?CellId {
if (!self.cell_edit.editing) return null;
return .{ .row = self.cell_edit.edit_row, .col = self.cell_edit.edit_col };
} }
/// Inicia edición de una celda /// Inicia edición de una celda
/// initial_char: si viene de tecla alfanumérica, el caracter inicial (null = mostrar valor actual) /// initial_char: si viene de tecla alfanumérica, el caracter inicial (null = mostrar valor actual)
pub fn startEditing(self: *Self, cell: CellId, current_value: []const u8, initial_char: ?u8, current_time_ms: u64) void { pub fn startEditing(self: *Self, cell: CellId, current_value: []const u8, initial_char: ?u8, current_time_ms: u64) void {
// Guardar valor original (para Escape) self.cell_edit.startEditing(cell.row, cell.col, current_value, initial_char);
const len = @min(current_value.len, self.original_value.len);
@memcpy(self.original_value[0..len], current_value[0..len]);
self.original_value_len = len;
// Inicializar buffer de edición
if (initial_char) |c| {
// Tecla alfanumérica: empezar con ese caracter
self.edit_buffer[0] = c;
self.edit_buffer_len = 1;
self.edit_cursor = 1;
} else {
// Doble-click/Space: mostrar valor actual
@memcpy(self.edit_buffer[0..len], current_value[0..len]);
self.edit_buffer_len = len;
self.edit_cursor = len;
}
self.editing_cell = cell;
self.escape_count = 0;
self.last_edit_time_ms = current_time_ms; self.last_edit_time_ms = current_time_ms;
self.cell_value_changed = false; self.cell_value_changed = false;
} }
/// Obtiene el texto actual del editor /// Obtiene el texto actual del editor
pub fn getEditText(self: *const Self) []const u8 { pub fn getEditText(self: *const Self) []const u8 {
return self.edit_buffer[0..self.edit_buffer_len]; return self.cell_edit.getEditText();
} }
/// Establece el texto del editor /// Establece el texto del editor
pub fn setEditText(self: *Self, text: []const u8) void { pub fn setEditText(self: *Self, text: []const u8) void {
const len = @min(text.len, self.edit_buffer.len); const len = @min(text.len, table_core.MAX_EDIT_BUFFER_SIZE);
@memcpy(self.edit_buffer[0..len], text[0..len]); @memcpy(self.cell_edit.edit_buffer[0..len], text[0..len]);
self.edit_buffer_len = len; self.cell_edit.edit_len = len;
self.edit_cursor = len; self.cell_edit.edit_cursor = len;
} }
/// Obtiene el valor original (antes de editar) /// Obtiene el valor original (antes de editar)
pub fn getOriginalValue(self: *const Self) []const u8 { pub fn getOriginalValue(self: *const Self) []const u8 {
return self.original_value[0..self.original_value_len]; return self.cell_edit.getOriginalValue();
} }
/// Verifica si el valor ha cambiado respecto al original /// Verifica si el valor ha cambiado respecto al original
pub fn hasValueChanged(self: *const Self) bool { pub fn hasValueChanged(self: *const Self) bool {
const current = self.getEditText(); return self.cell_edit.hasChanged();
const original = self.getOriginalValue();
return !std.mem.eql(u8, current, original);
} }
/// Finaliza edición guardando cambios (retorna true si hubo cambios) /// Finaliza edición guardando cambios (retorna true si hubo cambios)
pub fn commitEdit(self: *Self) bool { pub fn commitEdit(self: *Self) bool {
if (self.editing_cell == null) return false; if (!self.cell_edit.editing) return false;
const changed = self.hasValueChanged(); const changed = self.cell_edit.hasChanged();
if (changed) { if (changed) {
self.row_dirty = true; self.row_dirty = true;
self.cell_value_changed = true; self.cell_value_changed = true;
// Solo actualizar última fila editada si hubo cambios reales // Solo actualizar última fila editada si hubo cambios reales
self.last_edited_row = self.editing_cell.?.row; self.last_edited_row = self.cell_edit.edit_row;
} }
self.editing_cell = null; self.cell_edit.stopEditing();
self.escape_count = 0;
return changed; return changed;
} }
/// Finaliza edición descartando cambios /// Finaliza edición descartando cambios
pub fn cancelEdit(self: *Self) void { pub fn cancelEdit(self: *Self) void {
self.editing_cell = null; self.cell_edit.stopEditing();
self.escape_count = 0;
self.cell_value_changed = false; self.cell_value_changed = false;
} }
/// Revierte el texto de la celda al valor original (Escape 1) /// Revierte el texto de la celda al valor original (Escape 1)
pub fn revertCellText(self: *Self) void { pub fn revertCellText(self: *Self) void {
const original = self.getOriginalValue(); self.cell_edit.revertToOriginal();
@memcpy(self.edit_buffer[0..original.len], original);
self.edit_buffer_len = original.len;
self.edit_cursor = original.len;
} }
/// Maneja la tecla Escape (retorna acción a tomar) /// Maneja la tecla Escape (retorna acción a tomar)
@ -682,20 +650,15 @@ pub const VirtualAdvancedTableState = struct {
}; };
pub fn handleEscape(self: *Self) EscapeAction { pub fn handleEscape(self: *Self) EscapeAction {
if (self.editing_cell == null) return .none; const action = self.cell_edit.handleEscape();
return switch (action) {
self.escape_count += 1; .reverted => .reverted,
.cancelled => blk: {
if (self.escape_count == 1) {
// Escape 1: Revertir texto a valor original
self.revertCellText();
return .reverted;
} else {
// Escape 2+: Descartar cambios de fila
self.cancelEdit();
self.row_dirty = false; self.row_dirty = false;
return .discard_row; break :blk .discard_row;
} },
.none => .none,
};
} }
/// Verifica si cambió de fila (para auto-save) /// Verifica si cambió de fila (para auto-save)
@ -713,12 +676,9 @@ pub const VirtualAdvancedTableState = struct {
/// Resetea el estado de edición completamente /// Resetea el estado de edición completamente
pub fn resetEditState(self: *Self) void { pub fn resetEditState(self: *Self) void {
self.editing_cell = null; self.cell_edit.stopEditing();
self.escape_count = 0;
self.row_dirty = false; self.row_dirty = false;
self.last_edited_row = null; self.last_edited_row = null;
self.edit_buffer_len = 0;
self.edit_cursor = 0;
self.cell_value_changed = false; self.cell_value_changed = false;
self.row_edit_buffer.clear(); self.row_edit_buffer.clear();
} }

View file

@ -26,6 +26,7 @@ pub const types = @import("types.zig");
pub const data_provider = @import("data_provider.zig"); pub const data_provider = @import("data_provider.zig");
pub const state_mod = @import("state.zig"); pub const state_mod = @import("state.zig");
pub const cell_editor = @import("cell_editor.zig"); pub const cell_editor = @import("cell_editor.zig");
pub const paged_datasource = @import("paged_datasource.zig");
// Tipos principales // Tipos principales
pub const RowData = types.RowData; pub const RowData = types.RowData;
@ -41,6 +42,7 @@ pub const CellId = types.CellId;
pub const CellGeometry = types.CellGeometry; pub const CellGeometry = types.CellGeometry;
pub const DataProvider = data_provider.DataProvider; pub const DataProvider = data_provider.DataProvider;
pub const PagedDataSource = paged_datasource.PagedDataSource;
pub const CellEditorColors = cell_editor.CellEditorColors; pub const CellEditorColors = cell_editor.CellEditorColors;
pub const CellEditorResult = cell_editor.CellEditorResult; pub const CellEditorResult = cell_editor.CellEditorResult;
pub const NavigateDirection = cell_editor.NavigateDirection; pub const NavigateDirection = cell_editor.NavigateDirection;
@ -279,7 +281,7 @@ pub fn virtualAdvancedTableRect(
// Draw CellEditor overlay if editing // Draw CellEditor overlay if editing
if (list_state.isEditing()) { if (list_state.isEditing()) {
const editing = list_state.editing_cell.?; const editing = list_state.getEditingCell().?;
// Calculate cell geometry for the editing cell // Calculate cell geometry for the editing cell
if (list_state.getCellGeometry( if (list_state.getCellGeometry(
@ -306,7 +308,7 @@ pub fn virtualAdvancedTableRect(
// Handle editor results // Handle editor results
if (editor_result.committed) { if (editor_result.committed) {
const edited_cell = list_state.editing_cell.?; const edited_cell = list_state.getEditingCell().?;
const new_value = list_state.getEditText(); const new_value = list_state.getEditText();
// Añadir cambio al buffer de fila (NO commit inmediato) // Añadir cambio al buffer de fila (NO commit inmediato)
@ -780,91 +782,73 @@ fn drawRows(
) void { ) void {
_ = result; _ = result;
const row_h = config.row_height; // Crear PagedDataSource para acceso unificado a datos
var datasource = paged_datasource.PagedDataSource.init(list_state, config.columns, null);
const table_ds = datasource.toDataSource();
// Calculate offset within the window buffer // Convertir selected_id a selected_row (índice global)
// scroll_offset es la posición global, window_start es donde empieza el buffer const selected_row: i32 = if (list_state.findSelectedInWindow()) |window_idx|
const window_offset = list_state.scroll_offset -| list_state.window_start; @intCast(list_state.windowToGlobalIndex(window_idx))
// Draw each visible row
var row_idx: usize = 0;
while (row_idx < visible_rows) : (row_idx += 1) {
const data_idx = window_offset + row_idx;
if (data_idx >= list_state.current_window.len) break;
const row_y = content_bounds.y + @as(i32, @intCast(row_idx * row_h));
const global_idx = list_state.scroll_offset + row_idx; // Índice global real
const row = list_state.current_window[data_idx];
// Determine row background
const is_selected = list_state.selected_id != null and row.id == list_state.selected_id.?;
const is_alternate = global_idx % 2 == 1;
const bg_color: Style.Color = if (is_selected)
if (list_state.has_focus) colors.row_selected else colors.row_selected_unfocus
else if (is_alternate)
colors.row_alternate
else else
colors.row_normal; -1;
// Row background // Calcular rango de filas a dibujar
ctx.pushCommand(Command.rect( const first_row = list_state.scroll_offset;
content_bounds.x, const last_row = @min(
row_y, list_state.scroll_offset + visible_rows,
content_bounds.w, list_state.window_start + list_state.current_window.len,
row_h, );
bg_color,
));
// Draw cells (with horizontal scroll offset) // Convertir columnas a ColumnRenderDef
var x: i32 = content_bounds.x - scroll_offset_x; var render_cols: [32]table_core.ColumnRenderDef = undefined;
for (config.columns, 0..) |col, col_idx| { const num_cols = @min(config.columns.len, 32);
const col_end = x + @as(i32, @intCast(col.width)); for (config.columns[0..num_cols], 0..) |col, i| {
// Only draw if column is visible render_cols[i] = .{
if (col_end > content_bounds.x and x < content_bounds.x + @as(i32, @intCast(content_bounds.w))) { .width = col.width,
// Check if this is the active cell .text_align = 0, // TODO: mapear col.alignment si existe
const is_active_cell = is_selected and list_state.active_col == col_idx; .visible = true,
};
}
// Draw active cell indicator BEFORE text // Convertir colores
if (is_active_cell) { const render_colors = table_core.RowRenderColors{
// Usar colores contrastantes para el indicador .row_normal = colors.row_normal,
const tc_colors = table_core.TableColors{ .row_alternate = colors.row_alternate,
// Blanco/cyan brillante para máximo contraste .selected_row = colors.row_selected,
.selected_cell = Style.Color.rgb(100, 200, 255), .selected_row_unfocus = colors.row_selected_unfocus,
.selected_cell = Style.Color.rgb(100, 200, 255), // Cyan brillante
.selected_cell_unfocus = Style.Color.rgb(150, 150, 160), .selected_cell_unfocus = Style.Color.rgb(150, 150, 160),
.text_normal = colors.text,
.text_selected = colors.text_selected,
.border = colors.border, .border = colors.border,
}; };
table_core.drawCellActiveIndicator(
// Buffer para valores de celda
var cell_buffer: [256]u8 = undefined;
// Llamar a la función unificada
_ = table_core.drawRowsWithDataSource(
ctx, ctx,
x, table_ds,
row_y, .{
col.width, .bounds_x = content_bounds.x,
row_h, .bounds_y = content_bounds.y,
bg_color, .bounds_w = content_bounds.w,
&tc_colors, .row_height = config.row_height,
list_state.has_focus, .first_row = first_row,
.last_row = last_row,
.scroll_x = scroll_offset_x,
.alternating_rows = true,
.has_focus = list_state.has_focus,
.selected_row = selected_row,
.active_col = list_state.active_col,
.colors = render_colors,
.columns = render_cols[0..num_cols],
},
&cell_buffer,
); );
} }
if (col_idx < row.values.len) {
const text_color = if (is_selected and list_state.has_focus)
colors.text_selected
else
colors.text;
ctx.pushCommand(Command.text(
x + 4,
row_y + 3, // Centrado vertical mejorado
row.values[col_idx],
text_color,
));
}
}
x = col_end;
}
}
}
// ============================================================================= // =============================================================================
// Draw: Footer // Draw: Footer
// ============================================================================= // =============================================================================