refactor(tables): Add table_core.zig with shared rendering functions
- New module: table_core.zig with common table rendering logic - drawCellActiveIndicator(): visual indicator for selected cell - detectDoubleClick(): timing-based double-click detection - handleEditingKeyboard(): common keyboard handling for editing - blendColor(), startsWithIgnoreCase(): utilities VirtualAdvancedTable now uses table_core: - Active cell indicator in drawRows (visible highlight on selected cell) - Double-click detection in handleMouseClick - Added state fields: last_click_time, last_click_row, last_click_col AdvancedTable changes: - Improved cell active indicator (alpha 0.35, double border) - Added double-click fields to state - Space starts editing with empty value - Alphanumeric keys start editing in editable cells
This commit is contained in:
parent
65f6782d24
commit
47fc5b28f7
7 changed files with 1063 additions and 63 deletions
|
|
@ -403,12 +403,17 @@ fn drawRow(
|
||||||
const is_selected_cell = is_selected_row and table_state.selected_col == @as(i32, @intCast(col_idx));
|
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);
|
const cell_clicked = cell_rect.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left);
|
||||||
|
|
||||||
// Cell indicator for selected cell (outline instead of solid fill)
|
// Cell indicator for selected cell - más visible que antes
|
||||||
if (is_selected_cell) {
|
if (is_selected_cell and has_focus) {
|
||||||
// Subtle background tint
|
// 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.15)));
|
ctx.pushCommand(Command.rect(col_x, bounds.y, col.width, config.row_height, blendColor(row_bg, colors.selected_cell, 0.35)));
|
||||||
// Border outline
|
// 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, 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
|
// Get cell value
|
||||||
|
|
@ -427,13 +432,40 @@ fn drawRow(
|
||||||
ctx.pushCommand(Command.text(col_x + 4, text_y, text, text_color));
|
ctx.pushCommand(Command.text(col_x + 4, text_y, text, text_color));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle cell click
|
// Handle cell click and double-click
|
||||||
if (cell_clicked) {
|
if (cell_clicked) {
|
||||||
if (!is_selected_cell) {
|
const current_time = ctx.current_time_ms;
|
||||||
table_state.selectCell(row_idx, col_idx);
|
const same_cell = table_state.last_click_row == @as(i32, @intCast(row_idx)) and
|
||||||
result.selection_changed = true;
|
table_state.last_click_col == @as(i32, @intCast(col_idx));
|
||||||
result.selected_row = row_idx;
|
const time_diff = current_time -| table_state.last_click_time;
|
||||||
result.selected_col = col_idx;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -727,19 +759,29 @@ fn handleKeyboard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start editing with F2 or Enter
|
// Start editing with F2, Enter, or Space (empty)
|
||||||
if (config.allow_edit and (ctx.input.keyPressed(.f2) or ctx.input.keyPressed(.enter))) {
|
if (config.allow_edit) {
|
||||||
if (table_state.selected_row >= 0 and table_state.selected_col >= 0) {
|
const start_edit_key = ctx.input.keyPressed(.f2) or ctx.input.keyPressed(.enter);
|
||||||
const col_idx: usize = @intCast(table_state.selected_col);
|
const start_empty = ctx.input.keyPressed(.space);
|
||||||
if (col_idx < table_schema.columns.len and table_schema.columns[col_idx].editable) {
|
|
||||||
// Get current value
|
if (start_edit_key or start_empty) {
|
||||||
if (table_state.getRow(@intCast(table_state.selected_row))) |row| {
|
if (table_state.selected_row >= 0 and table_state.selected_col >= 0) {
|
||||||
const value = row.get(table_schema.columns[col_idx].name);
|
const col_idx: usize = @intCast(table_state.selected_col);
|
||||||
var format_buf: [128]u8 = undefined;
|
if (col_idx < table_schema.columns.len and table_schema.columns[col_idx].editable) {
|
||||||
const text = value.format(&format_buf);
|
if (table_state.getRow(@intCast(table_state.selected_row))) |row| {
|
||||||
table_state.startEditing(text);
|
const value = row.get(table_schema.columns[col_idx].name);
|
||||||
table_state.original_value = value;
|
if (start_empty) {
|
||||||
result.edit_started = true;
|
// Espacio: empezar vacío
|
||||||
|
table_state.startEditing("");
|
||||||
|
} else {
|
||||||
|
// F2/Enter: empezar con valor actual
|
||||||
|
var format_buf: [128]u8 = undefined;
|
||||||
|
const text = value.format(&format_buf);
|
||||||
|
table_state.startEditing(text);
|
||||||
|
}
|
||||||
|
table_state.original_value = value;
|
||||||
|
result.edit_started = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -785,41 +827,51 @@ fn handleKeyboard(
|
||||||
result.selection_changed = true;
|
result.selection_changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incremental search (type-to-search)
|
// Type-to-edit or incremental search
|
||||||
// Only when not editing and no modifiers pressed
|
// Behavior: If current cell is editable → start editing with typed char
|
||||||
|
// If not editable → incremental search in first column
|
||||||
if (!ctx.input.modifiers.ctrl and !ctx.input.modifiers.alt) {
|
if (!ctx.input.modifiers.ctrl and !ctx.input.modifiers.alt) {
|
||||||
if (ctx.input.text_input_len > 0) {
|
if (ctx.input.text_input_len > 0) {
|
||||||
const text = ctx.input.text_input[0..ctx.input.text_input_len];
|
const text = ctx.input.text_input[0..ctx.input.text_input_len];
|
||||||
for (text) |char| {
|
|
||||||
if (char >= 32 and char < 127) { // Printable ASCII
|
|
||||||
const search_term = table_state.addSearchChar(char, ctx.current_time_ms);
|
|
||||||
|
|
||||||
// Search for matching row in first column
|
// Check if current cell is editable
|
||||||
if (search_term.len > 0 and table_schema.columns.len > 0) {
|
const current_cell_editable = blk: {
|
||||||
const first_col_name = table_schema.columns[0].name;
|
if (!config.allow_edit) break :blk false;
|
||||||
const start_row: usize = if (table_state.selected_row >= 0)
|
if (table_state.selected_row < 0 or table_state.selected_col < 0) break :blk false;
|
||||||
@intCast(table_state.selected_row)
|
const col_idx: usize = @intCast(table_state.selected_col);
|
||||||
else
|
if (col_idx >= table_schema.columns.len) break :blk false;
|
||||||
0;
|
break :blk table_schema.columns[col_idx].editable;
|
||||||
|
};
|
||||||
|
|
||||||
var found_row: ?usize = null;
|
if (current_cell_editable) {
|
||||||
|
// Start editing with first typed character
|
||||||
|
if (text.len > 0 and text[0] >= 32 and text[0] < 127) {
|
||||||
|
if (table_state.getRow(@intCast(table_state.selected_row))) |row| {
|
||||||
|
const col_idx: usize = @intCast(table_state.selected_col);
|
||||||
|
const value = row.get(table_schema.columns[col_idx].name);
|
||||||
|
// Start with the typed text (replaces content)
|
||||||
|
table_state.startEditing(text);
|
||||||
|
table_state.original_value = value;
|
||||||
|
result.edit_started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Incremental search (type-to-search) in first column
|
||||||
|
for (text) |char| {
|
||||||
|
if (char >= 32 and char < 127) {
|
||||||
|
const search_term = table_state.addSearchChar(char, ctx.current_time_ms);
|
||||||
|
|
||||||
// Search from current position to end
|
if (search_term.len > 0 and table_schema.columns.len > 0) {
|
||||||
for (start_row..row_count) |row| {
|
const first_col_name = table_schema.columns[0].name;
|
||||||
if (table_state.getRowConst(row)) |row_data| {
|
const start_row: usize = if (table_state.selected_row >= 0)
|
||||||
const cell_value = row_data.get(first_col_name);
|
@intCast(table_state.selected_row)
|
||||||
var format_buf: [128]u8 = undefined;
|
else
|
||||||
const cell_text = cell_value.format(&format_buf);
|
0;
|
||||||
if (startsWithIgnoreCase(cell_text, search_term)) {
|
|
||||||
found_row = row;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap to beginning if not found
|
var found_row: ?usize = null;
|
||||||
if (found_row == null and start_row > 0) {
|
|
||||||
for (0..start_row) |row| {
|
// Search from current position to end
|
||||||
|
for (start_row..row_count) |row| {
|
||||||
if (table_state.getRowConst(row)) |row_data| {
|
if (table_state.getRowConst(row)) |row_data| {
|
||||||
const cell_value = row_data.get(first_col_name);
|
const cell_value = row_data.get(first_col_name);
|
||||||
var format_buf: [128]u8 = undefined;
|
var format_buf: [128]u8 = undefined;
|
||||||
|
|
@ -830,12 +882,27 @@ fn handleKeyboard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Move selection if found
|
// Wrap to beginning if not found
|
||||||
if (found_row) |row_idx| {
|
if (found_row == null and start_row > 0) {
|
||||||
table_state.selectCell(row_idx, @intCast(@max(0, table_state.selected_col)));
|
for (0..start_row) |row| {
|
||||||
result.selection_changed = true;
|
if (table_state.getRowConst(row)) |row_data| {
|
||||||
|
const cell_value = row_data.get(first_col_name);
|
||||||
|
var format_buf: [128]u8 = undefined;
|
||||||
|
const cell_text = cell_value.format(&format_buf);
|
||||||
|
if (startsWithIgnoreCase(cell_text, search_term)) {
|
||||||
|
found_row = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move selection if found
|
||||||
|
if (found_row) |row_idx| {
|
||||||
|
table_state.selectCell(row_idx, @intCast(@max(0, table_state.selected_col)));
|
||||||
|
result.selection_changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,22 @@ pub const AdvancedTableState = struct {
|
||||||
/// Escape count (1 = revert, 2 = cancel)
|
/// Escape count (1 = revert, 2 = cancel)
|
||||||
escape_count: u8 = 0,
|
escape_count: u8 = 0,
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Double-click detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Time of last click (ms)
|
||||||
|
last_click_time: u64 = 0,
|
||||||
|
|
||||||
|
/// Row of last click
|
||||||
|
last_click_row: i32 = -1,
|
||||||
|
|
||||||
|
/// Column of last click
|
||||||
|
last_click_col: i32 = -1,
|
||||||
|
|
||||||
|
/// Double-click threshold in ms
|
||||||
|
double_click_threshold_ms: u64 = 400,
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Sorting
|
// Sorting
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
422
src/widgets/table_core (conflicted).zig
Normal file
422
src/widgets/table_core (conflicted).zig
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
//! 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);
|
||||||
|
}
|
||||||
422
src/widgets/table_core.zig
Normal file
422
src/widgets/table_core.zig
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
//! 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: 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,
|
||||||
|
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: 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
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,22 @@ pub const VirtualAdvancedTableState = struct {
|
||||||
/// Cuando el usuario navega con flechas o hace click, se actualiza
|
/// Cuando el usuario navega con flechas o hace click, se actualiza
|
||||||
active_col: usize = 0,
|
active_col: usize = 0,
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Double-click detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Time of last click (ms)
|
||||||
|
last_click_time: u64 = 0,
|
||||||
|
|
||||||
|
/// Row of last click (global index)
|
||||||
|
last_click_row: i64 = -1,
|
||||||
|
|
||||||
|
/// Column of last click
|
||||||
|
last_click_col: i32 = -1,
|
||||||
|
|
||||||
|
/// Double-click threshold in ms
|
||||||
|
double_click_threshold_ms: u64 = 400,
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Scroll y ventana
|
// Scroll y ventana
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const Layout = @import("../../core/layout.zig");
|
||||||
const Style = @import("../../core/style.zig");
|
const Style = @import("../../core/style.zig");
|
||||||
const Input = @import("../../core/input.zig");
|
const Input = @import("../../core/input.zig");
|
||||||
const text_input = @import("../text_input.zig");
|
const text_input = @import("../text_input.zig");
|
||||||
|
const table_core = @import("../table_core.zig");
|
||||||
|
|
||||||
// Re-exports públicos
|
// Re-exports públicos
|
||||||
pub const types = @import("types.zig");
|
pub const types = @import("types.zig");
|
||||||
|
|
@ -756,6 +757,29 @@ fn drawRows(
|
||||||
const col_end = x + @as(i32, @intCast(col.width));
|
const col_end = x + @as(i32, @intCast(col.width));
|
||||||
// Only draw if column is visible
|
// Only draw if column is visible
|
||||||
if (col_end > content_bounds.x and x < content_bounds.x + @as(i32, @intCast(content_bounds.w))) {
|
if (col_end > content_bounds.x and x < content_bounds.x + @as(i32, @intCast(content_bounds.w))) {
|
||||||
|
// Check if this is the active cell
|
||||||
|
const is_active_cell = is_selected and list_state.active_col == col_idx;
|
||||||
|
|
||||||
|
// Draw active cell indicator BEFORE text
|
||||||
|
if (is_active_cell) {
|
||||||
|
// Convertir colores del config a TableColors para table_core
|
||||||
|
const tc_colors = table_core.TableColors{
|
||||||
|
.selected_cell = colors.row_selected, // Usar color de selección pero más claro
|
||||||
|
.selected_cell_unfocus = colors.row_selected_unfocus,
|
||||||
|
.border = colors.border,
|
||||||
|
};
|
||||||
|
table_core.drawCellActiveIndicator(
|
||||||
|
ctx,
|
||||||
|
x,
|
||||||
|
row_y,
|
||||||
|
col.width,
|
||||||
|
row_h,
|
||||||
|
bg_color,
|
||||||
|
&tc_colors,
|
||||||
|
list_state.has_focus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (col_idx < row.values.len) {
|
if (col_idx < row.values.len) {
|
||||||
const text_color = if (is_selected and list_state.has_focus)
|
const text_color = if (is_selected and list_state.has_focus)
|
||||||
colors.text_selected
|
colors.text_selected
|
||||||
|
|
@ -1000,8 +1024,6 @@ fn handleMouseClick(
|
||||||
list_state: *VirtualAdvancedTableState,
|
list_state: *VirtualAdvancedTableState,
|
||||||
result: *VirtualAdvancedTableResult,
|
result: *VirtualAdvancedTableResult,
|
||||||
) void {
|
) void {
|
||||||
_ = result;
|
|
||||||
|
|
||||||
const mouse = ctx.input.mousePos();
|
const mouse = ctx.input.mousePos();
|
||||||
// Content starts after FilterBar + Header
|
// Content starts after FilterBar + Header
|
||||||
const content_y = bounds.y + @as(i32, @intCast(filter_bar_h)) + @as(i32, @intCast(header_h));
|
const content_y = bounds.y + @as(i32, @intCast(filter_bar_h)) + @as(i32, @intCast(header_h));
|
||||||
|
|
@ -1016,22 +1038,54 @@ fn handleMouseClick(
|
||||||
const data_idx = window_offset + screen_row;
|
const data_idx = window_offset + screen_row;
|
||||||
|
|
||||||
if (data_idx < list_state.current_window.len) {
|
if (data_idx < list_state.current_window.len) {
|
||||||
list_state.selectById(list_state.current_window[data_idx].id);
|
const global_row = list_state.scroll_offset + screen_row;
|
||||||
|
|
||||||
// Detect which column was clicked
|
// Detect which column was clicked
|
||||||
|
var clicked_col: usize = 0;
|
||||||
const relative_x = mouse.x - bounds.x + list_state.scroll_offset_x;
|
const relative_x = mouse.x - bounds.x + list_state.scroll_offset_x;
|
||||||
var col_start: i32 = 0;
|
var col_start: i32 = 0;
|
||||||
for (config.columns, 0..) |col, col_idx| {
|
for (config.columns, 0..) |col, col_idx| {
|
||||||
const col_end = col_start + @as(i32, @intCast(col.width));
|
const col_end = col_start + @as(i32, @intCast(col.width));
|
||||||
if (relative_x >= col_start and relative_x < col_end) {
|
if (relative_x >= col_start and relative_x < col_end) {
|
||||||
list_state.active_col = col_idx;
|
clicked_col = col_idx;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
col_start = col_end;
|
col_start = col_end;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: implement double click detection with timing
|
// Double-click detection using table_core
|
||||||
// For now, double click is not supported
|
var dc_state = table_core.DoubleClickState{
|
||||||
|
.last_click_time = list_state.last_click_time,
|
||||||
|
.last_click_row = list_state.last_click_row,
|
||||||
|
.last_click_col = list_state.last_click_col,
|
||||||
|
.threshold_ms = list_state.double_click_threshold_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
const is_double_click = table_core.detectDoubleClick(
|
||||||
|
&dc_state,
|
||||||
|
ctx.current_time_ms,
|
||||||
|
@intCast(global_row),
|
||||||
|
@intCast(clicked_col),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update state from detection
|
||||||
|
list_state.last_click_time = dc_state.last_click_time;
|
||||||
|
list_state.last_click_row = dc_state.last_click_row;
|
||||||
|
list_state.last_click_col = dc_state.last_click_col;
|
||||||
|
|
||||||
|
if (is_double_click and !list_state.isEditing()) {
|
||||||
|
// Double-click: start editing
|
||||||
|
const cell = types.CellId{ .row = global_row, .col = clicked_col };
|
||||||
|
// Signal to panel that editing was requested
|
||||||
|
// The panel should provide the current value via callback
|
||||||
|
result.edited_cell = cell;
|
||||||
|
result.double_clicked = true;
|
||||||
|
result.double_click_id = list_state.current_window[data_idx].id;
|
||||||
|
} else {
|
||||||
|
// Single click: select
|
||||||
|
list_state.selectById(list_state.current_window[data_idx].id);
|
||||||
|
list_state.active_col = clicked_col;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ pub const sheet = @import("sheet.zig");
|
||||||
pub const discloser = @import("discloser.zig");
|
pub const discloser = @import("discloser.zig");
|
||||||
pub const selectable = @import("selectable.zig");
|
pub const selectable = @import("selectable.zig");
|
||||||
|
|
||||||
|
// Core table utilities (shared between AdvancedTable and VirtualAdvancedTable)
|
||||||
|
pub const table_core = @import("table_core.zig");
|
||||||
|
|
||||||
// Advanced widgets
|
// Advanced widgets
|
||||||
pub const advanced_table = @import("advanced_table/advanced_table.zig");
|
pub const advanced_table = @import("advanced_table/advanced_table.zig");
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue