RowCommitInfo now includes: - is_injected: bool (was this an injected row) - injection_index: ?usize (where it was injected) buildCommitInfo() copies these from RowEditBuffer. VirtualAdvancedTable sets result.injection_committed and result.injection_row_idx when committing an injected row, for both Tab navigation and selection change commits. This allows the panel to know when to INSERT vs UPDATE and handle the special case of locally injected rows.
2138 lines
76 KiB
Zig
2138 lines
76 KiB
Zig
//! 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.
|
|
//!
|
|
//! ## 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 Context = @import("../core/context.zig").Context;
|
|
const Command = @import("../core/command.zig");
|
|
const Layout = @import("../core/layout.zig");
|
|
const Style = @import("../core/style.zig");
|
|
|
|
// =============================================================================
|
|
// Tips Proactivos (FASE I)
|
|
// =============================================================================
|
|
|
|
/// Tips de atajos de teclado para mostrar en StatusLine
|
|
/// Rotan cada ~10 segundos para enseñar atajos al usuario
|
|
pub const table_tips = [_][]const u8{
|
|
"Tip: F2 o Space para editar celda",
|
|
"Tip: Tab/Shift+Tab navega entre celdas",
|
|
"Tip: Ctrl+N crea nuevo registro",
|
|
"Tip: Ctrl+Delete o Ctrl+B borra registro",
|
|
"Tip: Ctrl+Shift+1..9 ordena por columna",
|
|
"Tip: Ctrl+Home/End va al inicio/fin",
|
|
"Tip: Enter confirma y baja, Escape cancela",
|
|
"Tip: Al editar, tecla directa reemplaza todo",
|
|
};
|
|
|
|
/// Frames entre rotación de tips (~10s @ 60fps)
|
|
pub const TIP_ROTATION_FRAMES: u32 = 600;
|
|
|
|
// =============================================================================
|
|
// 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),
|
|
cell_selection_bg: Style.Color = Style.Color.rgb(0, 120, 215), // Azul para selección
|
|
|
|
// 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 (info para draw)
|
|
/// NOTA: Para estado embebible en widgets, usar CellEditState
|
|
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 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,
|
|
|
|
/// Selección de texto (Excel-style: todo seleccionado al entrar con F2)
|
|
/// Si selection_start == selection_end, no hay selección (solo cursor)
|
|
selection_start: usize = 0,
|
|
selection_end: usize = 0,
|
|
|
|
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, sin selección
|
|
self.edit_buffer[0] = c;
|
|
self.edit_len = 1;
|
|
self.edit_cursor = 1;
|
|
self.selection_start = 0;
|
|
self.selection_end = 0;
|
|
} else {
|
|
// F2/Space/DoubleClick: mostrar valor actual con TODO seleccionado (Excel-style)
|
|
@memcpy(self.edit_buffer[0..orig_len], current_value[0..orig_len]);
|
|
self.edit_len = orig_len;
|
|
self.edit_cursor = orig_len;
|
|
// Seleccionar todo el texto
|
|
self.selection_start = 0;
|
|
self.selection_end = 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);
|
|
}
|
|
|
|
/// Verifica si hay texto seleccionado
|
|
pub fn hasSelection(self: *const Self) bool {
|
|
return self.selection_start != self.selection_end;
|
|
}
|
|
|
|
/// Limpia la selección (pero mantiene el cursor)
|
|
pub fn clearSelection(self: *Self) void {
|
|
self.selection_start = 0;
|
|
self.selection_end = 0;
|
|
}
|
|
|
|
/// 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;
|
|
// Limpiar selección al revertir
|
|
self.clearSelection();
|
|
}
|
|
|
|
/// Finaliza edición
|
|
pub fn stopEditing(self: *Self) void {
|
|
self.editing = false;
|
|
self.edit_len = 0;
|
|
self.edit_cursor = 0;
|
|
self.escape_count = 0;
|
|
self.clearSelection();
|
|
}
|
|
|
|
/// 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
|
|
pub const DoubleClickState = struct {
|
|
last_click_time: u64 = 0,
|
|
last_click_row: i64 = -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,
|
|
selection_start: usize,
|
|
selection_end: 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 "";
|
|
|
|
// Dibujar selección si existe (Excel-style highlight)
|
|
if (selection_start != selection_end) {
|
|
const sel_min = @min(selection_start, selection_end);
|
|
const sel_max = @max(selection_start, selection_end);
|
|
const sel_x = x + 4 + @as(i32, @intCast(sel_min * 8)); // 8px por caracter (monospace)
|
|
const sel_width = @as(u32, @intCast((sel_max - sel_min) * 8));
|
|
// Color azul semitransparente para selección
|
|
ctx.pushCommand(Command.rect(sel_x, text_y, sel_width, 16, colors.cell_selection_bg));
|
|
}
|
|
|
|
// Texto (encima de la selección)
|
|
ctx.pushCommand(Command.text(x + 4, text_y, text_to_show, colors.cell_editing_text));
|
|
|
|
// Cursor parpadeante (simplificado: siempre visible)
|
|
// Solo mostrar cursor si NO hay selección completa
|
|
if (selection_start == selection_end) {
|
|
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));
|
|
}
|
|
|
|
/// 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,
|
|
/// ID de fila con cambios pendientes (dirty tracking visual)
|
|
/// Si no es null y coincide con el row_id actual, se aplica blend naranja
|
|
dirty_row_id: ?i64 = null,
|
|
/// Buffer de edición de fila para priorizar valores pendientes en renderizado
|
|
/// Permite mostrar lo que el usuario ha tecleado antes de que se guarde en BD
|
|
edit_buffer: ?*const RowEditBuffer = null,
|
|
};
|
|
|
|
/// 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,
|
|
};
|
|
}
|
|
|
|
// Dirty tracking: si la fila tiene cambios pendientes sin guardar
|
|
if (config.dirty_row_id) |dirty_id| {
|
|
const row_id = datasource.getRowId(row_idx);
|
|
if (row_id == dirty_id) {
|
|
// Blend naranja 25% para indicar cambios pendientes
|
|
row_bg = blendColor(row_bg, config.colors.state_modified, 0.25);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// PRIORIDAD 1: Valor pendiente en RowEditBuffer (lo que el usuario tecleó)
|
|
// PRIORIDAD 2: Valor del DataSource (BD o memoria)
|
|
var cell_text: []const u8 = "";
|
|
const row_id = datasource.getRowId(row_idx);
|
|
if (config.edit_buffer) |eb| {
|
|
if (eb.row_id == row_id) {
|
|
if (eb.getPendingValue(col_idx)) |pending| {
|
|
cell_text = pending;
|
|
}
|
|
}
|
|
}
|
|
if (cell_text.len == 0) {
|
|
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
|
|
pub fn detectDoubleClick(
|
|
state: *DoubleClickState,
|
|
current_time: u64,
|
|
row: i64,
|
|
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
|
|
// =============================================================================
|
|
|
|
/// Dirección de navegación después de edición
|
|
pub const NavigateDirection = enum {
|
|
none,
|
|
next_cell, // Tab
|
|
prev_cell, // Shift+Tab
|
|
next_row, // Enter o ↓
|
|
prev_row, // ↑
|
|
};
|
|
|
|
/// Resultado de procesar teclado en modo edición
|
|
pub const EditKeyboardResult = struct {
|
|
/// Se confirmó la edición (Enter, Tab, flechas)
|
|
committed: bool = false,
|
|
/// Se canceló la edición (Escape 2x)
|
|
cancelled: bool = false,
|
|
/// Se revirtió al valor original (Escape 1x)
|
|
reverted: bool = false,
|
|
/// Dirección de navegación después de commit
|
|
navigate: NavigateDirection = .none,
|
|
/// El buffer de edición cambió
|
|
text_changed: bool = false,
|
|
/// Indica que se procesó un evento de teclado (para evitar doble procesamiento)
|
|
handled: bool = false,
|
|
};
|
|
|
|
/// Procesa teclado en modo edición
|
|
/// Modifica edit_buffer, edit_len, edit_cursor según las teclas
|
|
/// Soporta selección Excel-style: typing reemplaza selección
|
|
/// Retorna resultado con flags de navegación y si se procesó algún evento
|
|
pub fn handleEditingKeyboard(
|
|
ctx: *Context,
|
|
edit_buffer: []u8,
|
|
edit_len: *usize,
|
|
edit_cursor: *usize,
|
|
escape_count: *u8,
|
|
original_text: ?[]const u8,
|
|
selection_start: ?*usize,
|
|
selection_end: ?*usize,
|
|
) EditKeyboardResult {
|
|
var result = EditKeyboardResult{};
|
|
|
|
// Helper para eliminar texto seleccionado
|
|
const deleteSelection = struct {
|
|
fn f(buf: []u8, len: *usize, cursor: *usize, sel_start: *usize, sel_end: *usize) bool {
|
|
if (sel_start.* == sel_end.*) return false;
|
|
const min_pos = @min(sel_start.*, sel_end.*);
|
|
const max_pos = @min(@max(sel_start.*, sel_end.*), len.*);
|
|
if (max_pos <= min_pos) return false;
|
|
|
|
// Mover caracteres después de la selección hacia atrás
|
|
const chars_to_delete = max_pos - min_pos;
|
|
var i: usize = min_pos;
|
|
while (i + chars_to_delete < len.*) : (i += 1) {
|
|
buf[i] = buf[i + chars_to_delete];
|
|
}
|
|
len.* -= chars_to_delete;
|
|
cursor.* = min_pos;
|
|
sel_start.* = 0;
|
|
sel_end.* = 0;
|
|
return true;
|
|
}
|
|
}.f;
|
|
|
|
// Helper para limpiar selección
|
|
const clearSelection = struct {
|
|
fn f(sel_start: ?*usize, sel_end: ?*usize) void {
|
|
if (sel_start) |s| s.* = 0;
|
|
if (sel_end) |e| e.* = 0;
|
|
}
|
|
}.f;
|
|
|
|
// Procesar eventos de tecla
|
|
for (ctx.input.getKeyEvents()) |event| {
|
|
if (!event.pressed) continue;
|
|
|
|
switch (event.key) {
|
|
.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;
|
|
}
|
|
}
|
|
result.handled = true;
|
|
return result;
|
|
},
|
|
.enter => {
|
|
result.committed = true;
|
|
result.navigate = .next_row;
|
|
result.handled = true;
|
|
return result;
|
|
},
|
|
.tab => {
|
|
result.committed = true;
|
|
result.navigate = if (event.modifiers.shift) .prev_cell else .next_cell;
|
|
result.handled = true;
|
|
return result;
|
|
},
|
|
.up => {
|
|
result.committed = true;
|
|
result.navigate = .prev_row;
|
|
result.handled = true;
|
|
return result;
|
|
},
|
|
.down => {
|
|
result.committed = true;
|
|
result.navigate = .next_row;
|
|
result.handled = true;
|
|
return result;
|
|
},
|
|
.left => {
|
|
clearSelection(selection_start, selection_end);
|
|
if (edit_cursor.* > 0) edit_cursor.* -= 1;
|
|
result.handled = true;
|
|
escape_count.* = 0;
|
|
},
|
|
.right => {
|
|
clearSelection(selection_start, selection_end);
|
|
if (edit_cursor.* < edit_len.*) edit_cursor.* += 1;
|
|
result.handled = true;
|
|
escape_count.* = 0;
|
|
},
|
|
.home => {
|
|
clearSelection(selection_start, selection_end);
|
|
edit_cursor.* = 0;
|
|
result.handled = true;
|
|
escape_count.* = 0;
|
|
},
|
|
.end => {
|
|
clearSelection(selection_start, selection_end);
|
|
edit_cursor.* = edit_len.*;
|
|
result.handled = true;
|
|
escape_count.* = 0;
|
|
},
|
|
.backspace => {
|
|
// Si hay selección, borrar selección
|
|
if (selection_start != null and selection_end != null) {
|
|
if (deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?)) {
|
|
result.text_changed = true;
|
|
result.handled = true;
|
|
escape_count.* = 0;
|
|
continue;
|
|
}
|
|
}
|
|
// Sin selección: borrar caracter antes del cursor
|
|
if (edit_cursor.* > 0 and edit_len.* > 0) {
|
|
const pos = edit_cursor.* - 1;
|
|
var i: usize = pos;
|
|
while (i < edit_len.* - 1) : (i += 1) {
|
|
edit_buffer[i] = edit_buffer[i + 1];
|
|
}
|
|
edit_len.* -= 1;
|
|
edit_cursor.* -= 1;
|
|
result.text_changed = true;
|
|
}
|
|
result.handled = true;
|
|
escape_count.* = 0;
|
|
},
|
|
.delete => {
|
|
// Si hay selección, borrar selección
|
|
if (selection_start != null and selection_end != null) {
|
|
if (deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?)) {
|
|
result.text_changed = true;
|
|
result.handled = true;
|
|
escape_count.* = 0;
|
|
continue;
|
|
}
|
|
}
|
|
// Sin selección: borrar caracter después del cursor
|
|
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;
|
|
}
|
|
result.handled = true;
|
|
escape_count.* = 0;
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
// Procesar texto ingresado (caracteres imprimibles)
|
|
const text_input = ctx.input.getTextInput();
|
|
if (text_input.len > 0) {
|
|
// Si hay selección, borrarla primero (comportamiento Excel/Word)
|
|
if (selection_start != null and selection_end != null) {
|
|
if (selection_start.?.* != selection_end.?.*) {
|
|
_ = deleteSelection(edit_buffer, edit_len, edit_cursor, selection_start.?, selection_end.?);
|
|
}
|
|
}
|
|
|
|
for (text_input) |ch| {
|
|
if (edit_len.* < edit_buffer.len - 1) {
|
|
// Hacer espacio moviendo caracteres hacia la derecha
|
|
if (edit_cursor.* < edit_len.*) {
|
|
var i = 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;
|
|
result.handled = true;
|
|
}
|
|
}
|
|
escape_count.* = 0;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// BRAIN-IN-CORE: Procesamiento Unificado de Eventos de Tabla (FASE C)
|
|
// =============================================================================
|
|
//
|
|
// Arquitectura "Brain-in-Core" (diseñado por Gemini):
|
|
// - TODA la lógica de decisión vive aquí
|
|
// - Los widgets solo pasan eventos y reaccionan a los resultados
|
|
// - Cualquier nueva tabla (CloudTable, etc.) hereda esta potencia automáticamente
|
|
|
|
/// Resultado completo del procesamiento de eventos de tabla.
|
|
/// Contiene flags para TODAS las acciones posibles.
|
|
pub const TableEventResult = struct {
|
|
// =========================================================================
|
|
// Navegación básica (flechas, PageUp/Down)
|
|
// =========================================================================
|
|
move_up: bool = false,
|
|
move_down: bool = false,
|
|
move_left: bool = false, // Sin Ctrl: cambiar columna
|
|
move_right: bool = false, // Sin Ctrl: cambiar columna
|
|
page_up: bool = false,
|
|
page_down: bool = false,
|
|
|
|
// =========================================================================
|
|
// Navegación a extremos
|
|
// =========================================================================
|
|
go_to_first_col: bool = false, // Home sin Ctrl
|
|
go_to_last_col: bool = false, // End sin Ctrl
|
|
go_to_first_row: bool = false, // Ctrl+Home: primera fila de datos
|
|
go_to_last_row: bool = false, // Ctrl+End: última fila de datos
|
|
|
|
// =========================================================================
|
|
// Scroll horizontal (Ctrl+Left/Right)
|
|
// =========================================================================
|
|
scroll_left: bool = false,
|
|
scroll_right: bool = false,
|
|
|
|
// =========================================================================
|
|
// CRUD (Ctrl+N, Ctrl+B, Ctrl+Delete)
|
|
// =========================================================================
|
|
insert_row: bool = false, // Ctrl+N: insertar nueva fila
|
|
delete_row: bool = false, // Ctrl+Delete o Ctrl+B: eliminar fila
|
|
|
|
// =========================================================================
|
|
// Ordenación (Ctrl+Shift+1..9)
|
|
// =========================================================================
|
|
sort_by_column: ?usize = null, // Índice de columna (0-based)
|
|
|
|
// =========================================================================
|
|
// Edición (F2, Space, tecla alfanumérica)
|
|
// =========================================================================
|
|
start_editing: bool = false, // Iniciar edición de celda activa
|
|
initial_char: ?u8 = null, // Caracter inicial (si fue tecla alfa)
|
|
|
|
// =========================================================================
|
|
// Tab navigation
|
|
// =========================================================================
|
|
tab_out: bool = false, // Tab presionado (pasar focus a otro widget)
|
|
tab_shift: bool = false, // Fue Shift+Tab (dirección inversa)
|
|
|
|
// =========================================================================
|
|
// Flag general
|
|
// =========================================================================
|
|
handled: bool = false, // Se procesó algún evento
|
|
};
|
|
|
|
/// Procesa TODOS los eventos de teclado de una tabla.
|
|
/// Esta es la función maestra "Brain-in-Core" que centraliza toda la lógica.
|
|
///
|
|
/// Parámetros:
|
|
/// - ctx: Contexto de renderizado (acceso a input)
|
|
/// - is_editing: Si hay una celda en modo edición (ignora navegación)
|
|
///
|
|
/// El widget debe reaccionar a los flags retornados y actualizar su estado.
|
|
///
|
|
/// Ejemplo de uso en widget:
|
|
/// ```zig
|
|
/// const events = table_core.processTableEvents(ctx, list_state.isEditing());
|
|
/// if (events.move_up) list_state.moveUp();
|
|
/// if (events.move_down) list_state.moveDown(visible_rows);
|
|
/// if (events.go_to_first_row) list_state.goToStart();
|
|
/// if (events.insert_row) result.insert_row = true;
|
|
/// // ... etc
|
|
/// ```
|
|
pub fn processTableEvents(ctx: *Context, is_editing: bool) TableEventResult {
|
|
var result = TableEventResult{};
|
|
|
|
// Si hay edición activa, el CellEditor maneja las teclas
|
|
// Solo procesamos Tab para salir del widget
|
|
if (is_editing) {
|
|
for (ctx.input.getKeyEvents()) |event| {
|
|
if (!event.pressed) continue;
|
|
if (event.key == .tab) {
|
|
result.tab_out = true;
|
|
result.tab_shift = event.modifiers.shift;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 1. Navegación con navKeyPressed (soporta key repeat)
|
|
// =========================================================================
|
|
if (ctx.input.navKeyPressed()) |key| {
|
|
const ctrl = ctx.input.modifiers.ctrl;
|
|
|
|
switch (key) {
|
|
.up => {
|
|
result.move_up = true;
|
|
result.handled = true;
|
|
},
|
|
.down => {
|
|
result.move_down = true;
|
|
result.handled = true;
|
|
},
|
|
.left => {
|
|
if (ctrl) {
|
|
result.scroll_left = true;
|
|
} else {
|
|
result.move_left = true;
|
|
}
|
|
result.handled = true;
|
|
},
|
|
.right => {
|
|
if (ctrl) {
|
|
result.scroll_right = true;
|
|
} else {
|
|
result.move_right = true;
|
|
}
|
|
result.handled = true;
|
|
},
|
|
.page_up => {
|
|
result.page_up = true;
|
|
result.handled = true;
|
|
},
|
|
.page_down => {
|
|
result.page_down = true;
|
|
result.handled = true;
|
|
},
|
|
.home => {
|
|
if (ctrl) {
|
|
result.go_to_first_row = true;
|
|
result.go_to_first_col = true;
|
|
} else {
|
|
result.go_to_first_col = true;
|
|
}
|
|
result.handled = true;
|
|
},
|
|
.end => {
|
|
if (ctrl) {
|
|
result.go_to_last_row = true;
|
|
result.go_to_last_col = true;
|
|
} else {
|
|
result.go_to_last_col = true;
|
|
}
|
|
result.handled = true;
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 2. Atajos con Ctrl y teclas especiales (getKeyEvents)
|
|
// =========================================================================
|
|
for (ctx.input.getKeyEvents()) |event| {
|
|
if (!event.pressed) continue;
|
|
|
|
// F2 o Space: iniciar edición
|
|
if (event.key == .f2 or event.key == .space) {
|
|
result.start_editing = true;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
|
|
// Tab: pasar focus al siguiente widget
|
|
if (event.key == .tab) {
|
|
result.tab_out = true;
|
|
result.tab_shift = event.modifiers.shift;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
|
|
// Atajos con Ctrl
|
|
if (event.modifiers.ctrl) {
|
|
switch (event.key) {
|
|
.n => {
|
|
// Ctrl+N: insertar nueva fila
|
|
result.insert_row = true;
|
|
result.handled = true;
|
|
return result;
|
|
},
|
|
.b, .delete => {
|
|
// Ctrl+B o Ctrl+Delete: eliminar fila
|
|
result.delete_row = true;
|
|
result.handled = true;
|
|
return result;
|
|
},
|
|
// Ctrl+Shift+1..9: ordenar por columna
|
|
.@"1" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 0;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
.@"2" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 1;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
.@"3" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 2;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
.@"4" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 3;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
.@"5" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 4;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
.@"6" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 5;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
.@"7" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 6;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
.@"8" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 7;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
.@"9" => {
|
|
if (event.modifiers.shift) {
|
|
result.sort_by_column = 8;
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 3. Teclas alfanuméricas: iniciar edición con ese caracter
|
|
// =========================================================================
|
|
const char_input = ctx.input.getTextInput();
|
|
if (char_input.len > 0) {
|
|
result.start_editing = true;
|
|
result.initial_char = char_input[0];
|
|
result.handled = true;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Alias para compatibilidad (DEPRECADO - usar processTableEvents)
|
|
pub const TableKeyboardResult = TableEventResult;
|
|
pub const handleTableKeyboard = processTableEvents;
|
|
|
|
// =============================================================================
|
|
// Edición de fila completa (commit al abandonar fila, estilo Excel)
|
|
// =============================================================================
|
|
|
|
/// Máximo de columnas soportadas para cambios pendientes
|
|
pub const MAX_PENDING_COLUMNS: usize = 32;
|
|
|
|
/// Máximo tamaño de valor por celda
|
|
pub const MAX_CELL_VALUE_LEN: usize = 256;
|
|
|
|
/// ID especial para filas nuevas (ghost row)
|
|
pub const NEW_ROW_ID: i64 = -1;
|
|
|
|
/// Cambio pendiente en una columna
|
|
pub const PendingCellChange = struct {
|
|
/// Índice de columna
|
|
col: usize,
|
|
/// Valor nuevo (slice al buffer interno)
|
|
value: []const u8,
|
|
};
|
|
|
|
/// Buffer para acumular cambios de una fila antes de commit
|
|
/// Usado por los states de los widgets, procesado por funciones de table_core
|
|
pub const RowEditBuffer = struct {
|
|
/// ID de la fila siendo editada (NEW_ROW_ID si es ghost row o inyectada)
|
|
row_id: i64 = NEW_ROW_ID,
|
|
|
|
/// Índice de fila (para navegación)
|
|
row_index: usize = 0,
|
|
|
|
/// Es una fila nueva (ghost row que el usuario está rellenando)
|
|
is_new_row: bool = false,
|
|
|
|
/// Hay cambios pendientes
|
|
has_changes: bool = false,
|
|
|
|
/// True si es una fila inyectada (Ctrl+N entre líneas)
|
|
is_injected: bool = false,
|
|
|
|
/// Índice donde se insertó la fila inyectada (null si no es inyección)
|
|
injection_index: ?usize = null,
|
|
|
|
/// Buffers de valores por columna (almacenamiento fijo)
|
|
value_buffers: [MAX_PENDING_COLUMNS][MAX_CELL_VALUE_LEN]u8 = undefined,
|
|
|
|
/// Longitudes de cada valor
|
|
value_lens: [MAX_PENDING_COLUMNS]usize = [_]usize{0} ** MAX_PENDING_COLUMNS,
|
|
|
|
/// Flags: qué columnas tienen cambios
|
|
changed_cols: [MAX_PENDING_COLUMNS]bool = [_]bool{false} ** MAX_PENDING_COLUMNS,
|
|
|
|
/// Número de columnas con cambios
|
|
change_count: usize = 0,
|
|
|
|
/// Inicializa/resetea el buffer para una nueva fila
|
|
pub fn startEdit(self: *RowEditBuffer, row_id: i64, row_index: usize, is_new: bool) void {
|
|
self.row_id = row_id;
|
|
self.row_index = row_index;
|
|
self.is_new_row = is_new;
|
|
self.has_changes = false;
|
|
self.is_injected = false;
|
|
self.injection_index = null;
|
|
self.change_count = 0;
|
|
for (0..MAX_PENDING_COLUMNS) |i| {
|
|
self.changed_cols[i] = false;
|
|
self.value_lens[i] = 0;
|
|
}
|
|
}
|
|
|
|
/// Inicializa buffer para una fila inyectada (Ctrl+N entre líneas)
|
|
/// insertion_idx es el índice visual donde aparece la fila nueva
|
|
pub fn startInjectedEdit(self: *RowEditBuffer, insertion_idx: usize) void {
|
|
self.row_id = NEW_ROW_ID;
|
|
self.row_index = insertion_idx;
|
|
self.is_new_row = true;
|
|
self.has_changes = false;
|
|
self.is_injected = true;
|
|
self.injection_index = insertion_idx;
|
|
self.change_count = 0;
|
|
for (0..MAX_PENDING_COLUMNS) |i| {
|
|
self.changed_cols[i] = false;
|
|
self.value_lens[i] = 0;
|
|
}
|
|
}
|
|
|
|
/// Añade un cambio pendiente para una columna
|
|
pub fn addChange(self: *RowEditBuffer, col: usize, value: []const u8) void {
|
|
if (col >= MAX_PENDING_COLUMNS) return;
|
|
|
|
// Copiar valor al buffer interno
|
|
const len = @min(value.len, MAX_CELL_VALUE_LEN);
|
|
@memcpy(self.value_buffers[col][0..len], value[0..len]);
|
|
self.value_lens[col] = len;
|
|
|
|
// Marcar como cambiado
|
|
if (!self.changed_cols[col]) {
|
|
self.changed_cols[col] = true;
|
|
self.change_count += 1;
|
|
}
|
|
|
|
self.has_changes = true;
|
|
}
|
|
|
|
/// Obtiene el valor pendiente de una columna (si hay cambio)
|
|
pub fn getPendingValue(self: *const RowEditBuffer, col: usize) ?[]const u8 {
|
|
if (col >= MAX_PENDING_COLUMNS) return null;
|
|
if (!self.changed_cols[col]) return null;
|
|
return self.value_buffers[col][0..self.value_lens[col]];
|
|
}
|
|
|
|
/// Limpia el buffer (después de commit o discard)
|
|
pub fn clear(self: *RowEditBuffer) void {
|
|
self.row_id = NEW_ROW_ID;
|
|
self.row_index = 0;
|
|
self.is_new_row = false;
|
|
self.has_changes = false;
|
|
self.is_injected = false;
|
|
self.injection_index = null;
|
|
self.change_count = 0;
|
|
for (0..MAX_PENDING_COLUMNS) |i| {
|
|
self.changed_cols[i] = false;
|
|
self.value_lens[i] = 0;
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Información para hacer commit de los cambios de una fila
|
|
/// Retornada cuando el usuario abandona una fila editada
|
|
pub const RowCommitInfo = struct {
|
|
/// ID de la fila (NEW_ROW_ID si es INSERT)
|
|
row_id: i64,
|
|
|
|
/// Es INSERT (nueva fila) o UPDATE (fila existente)
|
|
is_insert: bool,
|
|
|
|
/// Lista de cambios (columna, valor)
|
|
changes: []const PendingCellChange,
|
|
|
|
/// Número de cambios
|
|
change_count: usize,
|
|
|
|
/// True si era una fila inyectada (Ctrl+N entre líneas)
|
|
is_injected: bool = false,
|
|
|
|
/// Índice visual donde fue inyectada (válido si is_injected = true)
|
|
injection_index: ?usize = null,
|
|
};
|
|
|
|
/// Construye la info de commit desde un RowEditBuffer
|
|
/// El caller debe proveer el array para almacenar los cambios
|
|
pub fn buildCommitInfo(
|
|
buffer: *const RowEditBuffer,
|
|
changes_out: []PendingCellChange,
|
|
) ?RowCommitInfo {
|
|
if (!buffer.has_changes) return null;
|
|
|
|
var count: usize = 0;
|
|
for (0..MAX_PENDING_COLUMNS) |col| {
|
|
if (buffer.changed_cols[col] and count < changes_out.len) {
|
|
changes_out[count] = .{
|
|
.col = col,
|
|
.value = buffer.value_buffers[col][0..buffer.value_lens[col]],
|
|
};
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
return RowCommitInfo{
|
|
.row_id = buffer.row_id,
|
|
.is_insert = buffer.is_new_row,
|
|
.changes = changes_out[0..count],
|
|
.change_count = count,
|
|
.is_injected = buffer.is_injected,
|
|
.injection_index = buffer.injection_index,
|
|
};
|
|
}
|
|
|
|
/// Verifica si hay que hacer commit antes de editar nueva celda.
|
|
/// Si la fila cambió y hay cambios pendientes, retorna commit info.
|
|
/// Siempre inicializa el buffer para la nueva fila.
|
|
///
|
|
/// Uso típico en widget:
|
|
/// ```
|
|
/// if (table_core.checkRowChangeAndCommit(&state.row_edit_buffer, new_id, new_idx, is_ghost, &changes)) |info| {
|
|
/// result.row_committed = true;
|
|
/// result.commit_info = info;
|
|
/// }
|
|
/// ```
|
|
pub fn checkRowChangeAndCommit(
|
|
buffer: *RowEditBuffer,
|
|
new_row_id: i64,
|
|
new_row_index: usize,
|
|
is_new_row: bool,
|
|
changes_out: []PendingCellChange,
|
|
) ?RowCommitInfo {
|
|
// Si es la misma fila, no hacer nada
|
|
if (buffer.row_id == new_row_id) return null;
|
|
|
|
// Si hay cambios pendientes en la fila anterior, construir commit
|
|
var commit_info: ?RowCommitInfo = null;
|
|
if (buffer.has_changes) {
|
|
commit_info = buildCommitInfo(buffer, changes_out);
|
|
}
|
|
|
|
// Iniciar edición de la nueva fila
|
|
buffer.startEdit(new_row_id, new_row_index, is_new_row);
|
|
|
|
return commit_info;
|
|
}
|
|
|
|
/// Verifica si un row_id corresponde a la ghost row (fila nueva)
|
|
pub fn isGhostRow(row_id: i64) bool {
|
|
return row_id == NEW_ROW_ID;
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Navegación Tab Excel-style (compartida por AdvancedTable y VirtualAdvancedTable)
|
|
// =============================================================================
|
|
|
|
/// Resultado de navegación Tab
|
|
pub const TabNavigateResult = enum {
|
|
/// Navegó a otra celda dentro del widget
|
|
navigated,
|
|
/// Salió del widget (Tab en última celda o Shift+Tab en primera)
|
|
tab_out,
|
|
};
|
|
|
|
/// Resultado del cálculo de nueva posición de celda
|
|
pub const CellPosition = struct {
|
|
row: usize,
|
|
col: usize,
|
|
result: TabNavigateResult,
|
|
};
|
|
|
|
/// Calcula la siguiente celda después de Tab
|
|
/// Parámetros genéricos para que funcione con ambos tipos de tabla.
|
|
pub fn calculateNextCell(
|
|
current_row: usize,
|
|
current_col: usize,
|
|
num_cols: usize,
|
|
num_rows: usize,
|
|
wrap_to_start: bool,
|
|
) CellPosition {
|
|
if (num_cols == 0 or num_rows == 0) {
|
|
return .{ .row = current_row, .col = current_col, .result = .tab_out };
|
|
}
|
|
|
|
var new_row = current_row;
|
|
var new_col = current_col;
|
|
|
|
if (current_col + 1 < num_cols) {
|
|
// Siguiente columna en misma fila
|
|
new_col = current_col + 1;
|
|
return .{ .row = new_row, .col = new_col, .result = .navigated };
|
|
}
|
|
|
|
// Última columna: ir a primera columna de siguiente fila
|
|
new_col = 0;
|
|
|
|
if (current_row + 1 < num_rows) {
|
|
// Hay siguiente fila
|
|
new_row = current_row + 1;
|
|
return .{ .row = new_row, .col = new_col, .result = .navigated };
|
|
}
|
|
|
|
// Última fila
|
|
if (wrap_to_start) {
|
|
new_row = 0;
|
|
return .{ .row = new_row, .col = new_col, .result = .navigated };
|
|
}
|
|
|
|
return .{ .row = current_row, .col = current_col, .result = .tab_out };
|
|
}
|
|
|
|
/// Calcula la celda anterior después de Shift+Tab
|
|
pub fn calculatePrevCell(
|
|
current_row: usize,
|
|
current_col: usize,
|
|
num_cols: usize,
|
|
num_rows: usize,
|
|
wrap_to_end: bool,
|
|
) CellPosition {
|
|
if (num_cols == 0 or num_rows == 0) {
|
|
return .{ .row = current_row, .col = current_col, .result = .tab_out };
|
|
}
|
|
|
|
var new_row = current_row;
|
|
var new_col = current_col;
|
|
|
|
if (current_col > 0) {
|
|
// Columna anterior en misma fila
|
|
new_col = current_col - 1;
|
|
return .{ .row = new_row, .col = new_col, .result = .navigated };
|
|
}
|
|
|
|
// Primera columna: ir a última columna de fila anterior
|
|
new_col = num_cols - 1;
|
|
|
|
if (current_row > 0) {
|
|
// Hay fila anterior
|
|
new_row = current_row - 1;
|
|
return .{ .row = new_row, .col = new_col, .result = .navigated };
|
|
}
|
|
|
|
// Primera fila
|
|
if (wrap_to_end) {
|
|
new_row = num_rows - 1;
|
|
return .{ .row = new_row, .col = new_col, .result = .navigated };
|
|
}
|
|
|
|
return .{ .row = current_row, .col = current_col, .result = .tab_out };
|
|
}
|
|
|
|
/// Acción a ejecutar después de navegación Tab
|
|
pub const TabAction = enum {
|
|
/// Navegar a nueva celda, sin commit
|
|
move,
|
|
/// Navegar a nueva celda, con commit de fila anterior
|
|
move_with_commit,
|
|
/// Salir del widget, sin commit
|
|
exit,
|
|
/// Salir del widget, con commit de fila actual
|
|
exit_with_commit,
|
|
};
|
|
|
|
/// Plan completo de navegación Tab (resultado de planTabNavigation)
|
|
pub const TabNavigationPlan = struct {
|
|
action: TabAction,
|
|
new_row: usize,
|
|
new_col: usize,
|
|
commit_info: ?RowCommitInfo,
|
|
};
|
|
|
|
/// Planifica navegación Tab con commit automático al cambiar de fila.
|
|
///
|
|
/// Esta es la función central DRY para navegación Excel-style.
|
|
/// El widget solo pasa parámetros y recibe el plan completo.
|
|
///
|
|
/// Parámetros:
|
|
/// - buffer: RowEditBuffer con cambios pendientes
|
|
/// - current_row/col: posición actual
|
|
/// - num_cols/rows: dimensiones de la tabla
|
|
/// - forward: true=Tab, false=Shift+Tab
|
|
/// - wrap: si hacer wrap al llegar al final
|
|
/// - row_id_getter: cualquier tipo con fn getRowId(usize) i64
|
|
/// - changes_out: buffer para almacenar cambios del commit
|
|
///
|
|
/// El widget ejecuta el plan:
|
|
/// - .move: actualizar posición
|
|
/// - .move_with_commit: guardar commit_info en BD, luego actualizar posición
|
|
/// - .exit: establecer tab_out=true
|
|
/// - .exit_with_commit: guardar commit_info, luego tab_out=true
|
|
pub fn planTabNavigation(
|
|
buffer: *RowEditBuffer,
|
|
current_row: usize,
|
|
current_col: usize,
|
|
num_cols: usize,
|
|
num_rows: usize,
|
|
forward: bool,
|
|
wrap: bool,
|
|
row_id_getter: anytype,
|
|
changes_out: []PendingCellChange,
|
|
) TabNavigationPlan {
|
|
// 1. Calcular nueva posición
|
|
const pos = if (forward)
|
|
calculateNextCell(current_row, current_col, num_cols, num_rows, wrap)
|
|
else
|
|
calculatePrevCell(current_row, current_col, num_cols, num_rows, wrap);
|
|
|
|
// 2. Si es tab_out, verificar si hay commit pendiente
|
|
if (pos.result == .tab_out) {
|
|
if (buffer.has_changes) {
|
|
const info = buildCommitInfo(buffer, changes_out);
|
|
buffer.clear();
|
|
return .{
|
|
.action = .exit_with_commit,
|
|
.new_row = pos.row,
|
|
.new_col = pos.col,
|
|
.commit_info = info,
|
|
};
|
|
}
|
|
return .{
|
|
.action = .exit,
|
|
.new_row = pos.row,
|
|
.new_col = pos.col,
|
|
.commit_info = null,
|
|
};
|
|
}
|
|
|
|
// 3. Navegación dentro del widget - verificar si cambió de fila
|
|
const current_row_id = buffer.row_id;
|
|
const new_row_id = row_id_getter.getRowId(pos.row);
|
|
|
|
if (current_row_id != new_row_id and buffer.has_changes) {
|
|
// Cambió de fila con cambios pendientes → commit
|
|
const info = buildCommitInfo(buffer, changes_out);
|
|
// Iniciar buffer para nueva fila
|
|
buffer.startEdit(new_row_id, pos.row, isGhostRow(new_row_id));
|
|
return .{
|
|
.action = .move_with_commit,
|
|
.new_row = pos.row,
|
|
.new_col = pos.col,
|
|
.commit_info = info,
|
|
};
|
|
}
|
|
|
|
// Sin cambio de fila o sin cambios pendientes
|
|
if (current_row_id != new_row_id) {
|
|
// Cambió de fila pero sin cambios → solo actualizar buffer
|
|
buffer.startEdit(new_row_id, pos.row, isGhostRow(new_row_id));
|
|
}
|
|
|
|
return .{
|
|
.action = .move,
|
|
.new_row = pos.row,
|
|
.new_col = pos.col,
|
|
.commit_info = null,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Ordenación (compartida)
|
|
// =============================================================================
|
|
|
|
/// Dirección de ordenación
|
|
pub const SortDirection = enum {
|
|
none,
|
|
ascending,
|
|
descending,
|
|
|
|
/// Alterna la dirección: none → asc → desc → none
|
|
pub fn toggle(self: SortDirection) SortDirection {
|
|
return switch (self) {
|
|
.none => .ascending,
|
|
.ascending => .descending,
|
|
.descending => .none,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Resultado de toggle de ordenación en columna
|
|
pub const SortToggleResult = struct {
|
|
/// Nueva columna de ordenación (null si se desactivó)
|
|
column: ?usize,
|
|
/// Nueva dirección
|
|
direction: SortDirection,
|
|
};
|
|
|
|
/// Calcula el nuevo estado de ordenación al hacer click en una columna
|
|
pub fn toggleSort(
|
|
current_column: ?usize,
|
|
current_direction: SortDirection,
|
|
clicked_column: usize,
|
|
) SortToggleResult {
|
|
if (current_column) |col| {
|
|
if (col == clicked_column) {
|
|
// Misma columna: ciclar dirección
|
|
const new_dir = current_direction.toggle();
|
|
return .{
|
|
.column = if (new_dir == .none) null else clicked_column,
|
|
.direction = new_dir,
|
|
};
|
|
}
|
|
}
|
|
// Columna diferente o sin ordenación: empezar ascendente
|
|
return .{
|
|
.column = clicked_column,
|
|
.direction = .ascending,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Renderizado de Scrollbars (FASE 6)
|
|
// =============================================================================
|
|
|
|
/// Parámetros para dibujar scrollbar vertical
|
|
pub const VerticalScrollbarParams = struct {
|
|
/// Posición X del track
|
|
track_x: i32,
|
|
/// Posición Y del track
|
|
track_y: i32,
|
|
/// Ancho del scrollbar
|
|
width: u32 = 12,
|
|
/// Altura del track
|
|
height: u32,
|
|
/// Número de elementos visibles
|
|
visible_count: usize,
|
|
/// Número total de elementos
|
|
total_count: usize,
|
|
/// Posición actual del scroll (0-based)
|
|
scroll_pos: usize,
|
|
/// Color del track (fondo)
|
|
track_color: Style.Color,
|
|
/// Color del thumb (control deslizante)
|
|
thumb_color: Style.Color,
|
|
};
|
|
|
|
/// Dibuja un scrollbar vertical.
|
|
/// Función genérica usada por AdvancedTable y VirtualAdvancedTable.
|
|
pub fn drawVerticalScrollbar(ctx: *Context, params: VerticalScrollbarParams) void {
|
|
if (params.total_count == 0 or params.visible_count >= params.total_count) return;
|
|
|
|
// Track (fondo)
|
|
ctx.pushCommand(Command.rect(
|
|
params.track_x,
|
|
params.track_y,
|
|
params.width,
|
|
params.height,
|
|
params.track_color,
|
|
));
|
|
|
|
// Calcular tamaño del thumb
|
|
const visible_ratio = @as(f32, @floatFromInt(params.visible_count)) /
|
|
@as(f32, @floatFromInt(params.total_count));
|
|
const thumb_h = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(params.height)))));
|
|
|
|
// Calcular posición del thumb
|
|
const max_scroll = params.total_count - params.visible_count;
|
|
const scroll_ratio = @as(f32, @floatFromInt(params.scroll_pos)) /
|
|
@as(f32, @floatFromInt(@max(1, max_scroll)));
|
|
const thumb_y_offset = @as(u32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(params.height - thumb_h))));
|
|
|
|
// Thumb (control deslizante)
|
|
ctx.pushCommand(Command.rect(
|
|
params.track_x + 2,
|
|
params.track_y + @as(i32, @intCast(thumb_y_offset)),
|
|
params.width - 4,
|
|
thumb_h,
|
|
params.thumb_color,
|
|
));
|
|
}
|
|
|
|
/// Parámetros para dibujar scrollbar horizontal
|
|
pub const HorizontalScrollbarParams = struct {
|
|
/// Posición X del track
|
|
track_x: i32,
|
|
/// Posición Y del track
|
|
track_y: i32,
|
|
/// Ancho del track
|
|
width: u32,
|
|
/// Altura del scrollbar
|
|
height: u32 = 12,
|
|
/// Ancho visible del contenido
|
|
visible_width: u32,
|
|
/// Ancho total del contenido
|
|
total_width: u32,
|
|
/// Posición actual del scroll horizontal (pixels)
|
|
scroll_x: i32,
|
|
/// Máximo scroll horizontal (pixels)
|
|
max_scroll_x: i32,
|
|
/// Color del track (fondo)
|
|
track_color: Style.Color,
|
|
/// Color del thumb (control deslizante)
|
|
thumb_color: Style.Color,
|
|
};
|
|
|
|
/// Dibuja un scrollbar horizontal.
|
|
/// Función genérica usada por VirtualAdvancedTable.
|
|
pub fn drawHorizontalScrollbar(ctx: *Context, params: HorizontalScrollbarParams) void {
|
|
if (params.max_scroll_x <= 0) return;
|
|
|
|
// Track (fondo)
|
|
ctx.pushCommand(Command.rect(
|
|
params.track_x,
|
|
params.track_y,
|
|
params.width,
|
|
params.height,
|
|
params.track_color,
|
|
));
|
|
|
|
// Calcular tamaño del thumb
|
|
const visible_ratio = @as(f32, @floatFromInt(params.visible_width)) /
|
|
@as(f32, @floatFromInt(params.total_width));
|
|
const thumb_w = @max(20, @as(u32, @intFromFloat(visible_ratio * @as(f32, @floatFromInt(params.width)))));
|
|
|
|
// Calcular posición del thumb
|
|
const scroll_ratio = @as(f32, @floatFromInt(params.scroll_x)) /
|
|
@as(f32, @floatFromInt(params.max_scroll_x));
|
|
const thumb_x_offset = @as(u32, @intFromFloat(scroll_ratio * @as(f32, @floatFromInt(params.width - thumb_w))));
|
|
|
|
// Thumb (control deslizante)
|
|
ctx.pushCommand(Command.rect(
|
|
params.track_x + @as(i32, @intCast(thumb_x_offset)),
|
|
params.track_y + 2,
|
|
thumb_w,
|
|
params.height - 4,
|
|
params.thumb_color,
|
|
));
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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);
|
|
}
|
|
|
|
test "calculateNextCell - basic navigation" {
|
|
// Tabla 3x4 (3 columnas, 4 filas)
|
|
// Celda (0,0) -> (0,1)
|
|
const r1 = calculateNextCell(0, 0, 3, 4, false);
|
|
try std.testing.expectEqual(@as(usize, 0), r1.row);
|
|
try std.testing.expectEqual(@as(usize, 1), r1.col);
|
|
try std.testing.expectEqual(TabNavigateResult.navigated, r1.result);
|
|
|
|
// Última columna -> primera columna de siguiente fila
|
|
const r2 = calculateNextCell(0, 2, 3, 4, false);
|
|
try std.testing.expectEqual(@as(usize, 1), r2.row);
|
|
try std.testing.expectEqual(@as(usize, 0), r2.col);
|
|
try std.testing.expectEqual(TabNavigateResult.navigated, r2.result);
|
|
|
|
// Última celda sin wrap -> tab_out
|
|
const r3 = calculateNextCell(3, 2, 3, 4, false);
|
|
try std.testing.expectEqual(TabNavigateResult.tab_out, r3.result);
|
|
|
|
// Última celda con wrap -> primera celda
|
|
const r4 = calculateNextCell(3, 2, 3, 4, true);
|
|
try std.testing.expectEqual(@as(usize, 0), r4.row);
|
|
try std.testing.expectEqual(@as(usize, 0), r4.col);
|
|
try std.testing.expectEqual(TabNavigateResult.navigated, r4.result);
|
|
}
|
|
|
|
test "calculatePrevCell - basic navigation" {
|
|
// Celda (0,2) -> (0,1)
|
|
const r1 = calculatePrevCell(0, 2, 3, 4, false);
|
|
try std.testing.expectEqual(@as(usize, 0), r1.row);
|
|
try std.testing.expectEqual(@as(usize, 1), r1.col);
|
|
try std.testing.expectEqual(TabNavigateResult.navigated, r1.result);
|
|
|
|
// Primera columna -> última columna de fila anterior
|
|
const r2 = calculatePrevCell(1, 0, 3, 4, false);
|
|
try std.testing.expectEqual(@as(usize, 0), r2.row);
|
|
try std.testing.expectEqual(@as(usize, 2), r2.col);
|
|
try std.testing.expectEqual(TabNavigateResult.navigated, r2.result);
|
|
|
|
// Primera celda sin wrap -> tab_out
|
|
const r3 = calculatePrevCell(0, 0, 3, 4, false);
|
|
try std.testing.expectEqual(TabNavigateResult.tab_out, r3.result);
|
|
|
|
// Primera celda con wrap -> última celda
|
|
const r4 = calculatePrevCell(0, 0, 3, 4, true);
|
|
try std.testing.expectEqual(@as(usize, 3), r4.row);
|
|
try std.testing.expectEqual(@as(usize, 2), r4.col);
|
|
try std.testing.expectEqual(TabNavigateResult.navigated, r4.result);
|
|
}
|
|
|
|
test "toggleSort" {
|
|
// Sin ordenación -> ascendente en columna 2
|
|
const r1 = toggleSort(null, .none, 2);
|
|
try std.testing.expectEqual(@as(?usize, 2), r1.column);
|
|
try std.testing.expectEqual(SortDirection.ascending, r1.direction);
|
|
|
|
// Ascendente en columna 2 -> descendente
|
|
const r2 = toggleSort(2, .ascending, 2);
|
|
try std.testing.expectEqual(@as(?usize, 2), r2.column);
|
|
try std.testing.expectEqual(SortDirection.descending, r2.direction);
|
|
|
|
// Descendente -> none (columna null)
|
|
const r3 = toggleSort(2, .descending, 2);
|
|
try std.testing.expectEqual(@as(?usize, null), r3.column);
|
|
try std.testing.expectEqual(SortDirection.none, r3.direction);
|
|
|
|
// Click en columna diferente -> ascendente en nueva columna
|
|
const r4 = toggleSort(2, .ascending, 5);
|
|
try std.testing.expectEqual(@as(?usize, 5), r4.column);
|
|
try std.testing.expectEqual(SortDirection.ascending, r4.direction);
|
|
}
|