From dc82340381e98d50de8b03f96af190d028b90afb Mon Sep 17 00:00:00 2001 From: reugenio Date: Sat, 27 Dec 2025 21:41:35 +0100 Subject: [PATCH] feat(table_core): Selection on focus Excel-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añade selection_start/selection_end a CellEditState - F2/Space selecciona todo el texto al entrar en edición - Typing con selección reemplaza texto seleccionado - Backspace/Delete borra selección si existe - Flechas/Home/End limpian selección - Dibuja highlight azul en ambas tablas (Advanced + Virtual) --- src/widgets/advanced_table/advanced_table.zig | 27 +++- src/widgets/advanced_table/types.zig | 1 + src/widgets/table_core.zig | 117 ++++++++++++++++-- .../virtual_advanced_table/cell_editor.zig | 58 ++++++--- 4 files changed, 178 insertions(+), 25 deletions(-) diff --git a/src/widgets/advanced_table/advanced_table.zig b/src/widgets/advanced_table/advanced_table.zig index 3b32885..b1463b6 100644 --- a/src/widgets/advanced_table/advanced_table.zig +++ b/src/widgets/advanced_table/advanced_table.zig @@ -561,11 +561,28 @@ fn drawEditingOverlay( // Draw edit text const edit_text = table_state.getEditText(); const text_y = cell_y + @as(i32, @intCast((cell_h - 8) / 2)); + + // Draw selection highlight if exists (Excel-style) + const sel_start = table_state.cell_edit.selection_start; + const sel_end = table_state.cell_edit.selection_end; + if (sel_start != sel_end and edit_text.len > 0) { + const sel_min = @min(sel_start, sel_end); + const sel_max = @min(@max(sel_start, sel_end), edit_text.len); + if (sel_max > sel_min) { + const sel_x = col_x + 4 + @as(i32, @intCast(sel_min * 8)); + const sel_width = @as(u32, @intCast((sel_max - sel_min) * 8)); + ctx.pushCommand(Command.rect(sel_x, text_y, sel_width, 8, colors.cell_selection_bg)); + } + } + + // Draw text (on top of selection) ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.text_selected)); - // Draw cursor - const cursor_x = col_x + 4 + @as(i32, @intCast(table_state.cell_edit.edit_cursor * 8)); - ctx.pushCommand(Command.rect(cursor_x, text_y, 1, 8, colors.text_selected)); + // Draw cursor only if no selection + if (sel_start == sel_end) { + const cursor_x = col_x + 4 + @as(i32, @intCast(table_state.cell_edit.edit_cursor * 8)); + ctx.pushCommand(Command.rect(cursor_x, text_y, 1, 8, colors.text_selected)); + } } // ============================================================================= @@ -881,7 +898,7 @@ fn handleEditingKeyboard( else null; - // Usar table_core para procesamiento de teclado (DRY) + // Usar table_core para procesamiento de teclado (DRY) con soporte selección const kb_result = table_core.handleEditingKeyboard( ctx, &table_state.cell_edit.edit_buffer, @@ -889,6 +906,8 @@ fn handleEditingKeyboard( &table_state.cell_edit.edit_cursor, &table_state.cell_edit.escape_count, original_text, + &table_state.cell_edit.selection_start, + &table_state.cell_edit.selection_end, ); // Si no se procesó ningún evento, salir diff --git a/src/widgets/advanced_table/types.zig b/src/widgets/advanced_table/types.zig index 095405b..c4e020f 100644 --- a/src/widgets/advanced_table/types.zig +++ b/src/widgets/advanced_table/types.zig @@ -293,6 +293,7 @@ pub const TableColors = struct { // Cell editing cell_editing_bg: Style.Color = Style.Color.rgb(60, 60, 80), cell_editing_border: Style.Color = Style.Color.primary, + cell_selection_bg: Style.Color = Style.Color.rgb(0, 120, 215), // Azul para selección // Borders border: Style.Color = Style.Color.rgb(60, 60, 60), diff --git a/src/widgets/table_core.zig b/src/widgets/table_core.zig index 6c3fe8b..95302ad 100644 --- a/src/widgets/table_core.zig +++ b/src/widgets/table_core.zig @@ -48,6 +48,7 @@ pub const TableColors = struct { 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), @@ -138,6 +139,11 @@ pub const CellEditState = struct { /// 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 @@ -155,15 +161,20 @@ pub const CellEditState = struct { // Inicializar buffer de edición if (initial_char) |c| { - // Tecla alfanumérica: empezar con ese caracter + // 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 + // 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; } } @@ -184,12 +195,25 @@ pub const CellEditState = struct { 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 @@ -198,6 +222,7 @@ pub const CellEditState = struct { self.edit_len = 0; self.edit_cursor = 0; self.escape_count = 0; + self.clearSelection(); } /// Resultado de handleEscape @@ -348,6 +373,8 @@ pub fn drawEditingOverlay( height: u32, edit_text: []const u8, cursor_pos: usize, + selection_start: usize, + selection_end: usize, colors: *const TableColors, ) void { // Fondo blanco @@ -359,12 +386,26 @@ pub fn drawEditingOverlay( // 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) - // 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)); + // 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 @@ -716,6 +757,7 @@ pub const EditKeyboardResult = struct { /// 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, @@ -724,9 +766,41 @@ pub fn handleEditingKeyboard( 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; @@ -774,29 +848,41 @@ pub fn handleEditingKeyboard( return result; }, .left => { + clearSelection(selection_start, selection_end); if (edit_cursor.* > 0) edit_cursor.* -= 1; result.handled = true; - // Reset escape count 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) { - // Shift characters left const pos = edit_cursor.* - 1; var i: usize = pos; while (i < edit_len.* - 1) : (i += 1) { @@ -810,6 +896,16 @@ pub fn handleEditingKeyboard( 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) { @@ -828,6 +924,13 @@ pub fn handleEditingKeyboard( // 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 diff --git a/src/widgets/virtual_advanced_table/cell_editor.zig b/src/widgets/virtual_advanced_table/cell_editor.zig index b91aed7..ef71303 100644 --- a/src/widgets/virtual_advanced_table/cell_editor.zig +++ b/src/widgets/virtual_advanced_table/cell_editor.zig @@ -81,6 +81,31 @@ pub fn drawCellEditor( // Texto actual const text = state.getEditText(); const text_y = geom.y + @as(i32, @intCast((geom.h -| 16) / 2)); // Centrado vertical + + // Dibujar selección si existe (Excel-style: todo seleccionado al entrar con F2) + const sel_start = state.cell_edit.selection_start; + const sel_end = state.cell_edit.selection_end; + if (sel_start != sel_end and text.len > 0) { + const sel_min = @min(sel_start, sel_end); + const sel_max = @min(@max(sel_start, sel_end), text.len); + if (sel_max > sel_min) { + // Calcular posición X del inicio de selección + const sel_x_offset = ctx.measureTextToCursor(text, sel_min); + const sel_end_offset = ctx.measureTextToCursor(text, sel_max); + const sel_width = sel_end_offset - sel_x_offset; + if (sel_width > 0) { + ctx.pushCommand(Command.rect( + geom.x + padding + @as(i32, @intCast(sel_x_offset)), + text_y, + @intCast(sel_width), + 16, + colors.selection, + )); + } + } + } + + // Texto (encima de la selección) ctx.pushCommand(Command.text( geom.x + padding, text_y, @@ -88,24 +113,27 @@ pub fn drawCellEditor( colors.text, )); - // Cursor: posición calculada con measureTextToCursor (TTF-aware) - const cursor_offset = ctx.measureTextToCursor(text, state.cell_edit.edit_cursor); - const cursor_x = geom.x + padding + @as(i32, @intCast(cursor_offset)); + // Cursor: solo si NO hay selección activa + const has_selection = sel_start != sel_end; + if (!has_selection) { + const cursor_offset = ctx.measureTextToCursor(text, state.cell_edit.edit_cursor); + const cursor_x = geom.x + padding + @as(i32, @intCast(cursor_offset)); - // Visibilidad del cursor usando función compartida de Context - const cursor_visible = ctx.isCursorVisible(state.last_edit_time_ms); + // Visibilidad del cursor usando función compartida de Context + const cursor_visible = ctx.isCursorVisible(state.last_edit_time_ms); - if (cursor_visible) { - ctx.pushCommand(Command.rect( - cursor_x, - text_y, - 2, // 2px de ancho (más visible) - 16, // Altura del texto - colors.cursor, - )); + if (cursor_visible) { + ctx.pushCommand(Command.rect( + cursor_x, + text_y, + 2, // 2px de ancho (más visible) + 16, // Altura del texto + colors.cursor, + )); + } } - // Procesar input de teclado usando table_core + // Procesar input de teclado usando table_core (con soporte selección) const original_text = state.getOriginalValue(); const kb_result = table_core.handleEditingKeyboard( ctx, @@ -114,6 +142,8 @@ pub fn drawCellEditor( &state.cell_edit.edit_cursor, &state.cell_edit.escape_count, if (original_text.len > 0) original_text else null, + &state.cell_edit.selection_start, + &state.cell_edit.selection_end, ); // Mapear resultado de table_core a CellEditorResult