diff --git a/docs/KEYBOARD_REFACTORING_PLAN.md b/docs/KEYBOARD_REFACTORING_PLAN.md new file mode 100644 index 0000000..86d9a14 --- /dev/null +++ b/docs/KEYBOARD_REFACTORING_PLAN.md @@ -0,0 +1,259 @@ +# Plan de Refactoring: Sistema de Eventos de Teclado + +> **Fecha:** 2025-12-11 +> **Estado:** APROBADO - Pendiente implementación +> **Prioridad:** ALTA + +--- + +## Contexto + +Auditoría realizada comparando zcatgui con librerías de referencia (Gio, microui, DVUI) identificó 4 problemas de duplicación/inconsistencia en el manejo de eventos de teclado. + +## Problemas Identificados + +| # | Problema | Impacto | +|---|----------|---------| +| 1 | SDL_KEYDOWN y SDL_TEXTINPUT son eventos separados | `KeyEvent.char` siempre es `null`, texto llega en array aparte | +| 2 | Dos arrays: `key_events[]` + `text_input[]` | Widgets deben leer ambos para funcionar | +| 3 | Sin timing en Context | Imposible implementar cursor parpadeante | +| 4 | Shortcuts duplicados en cada widget | Ctrl+A, Ctrl+Z reimplementados en text_input, table, etc. | + +## Comparación con Referencias + +| Aspecto | zcatgui | Gio | microui | DVUI | +|---------|---------|-----|---------|------| +| Evento único para teclado | NO | SI | SI | SI | +| Carácter en el evento | NO | SI | SI | SI | +| Timing disponible | NO | SI | SI | SI | +| Shortcuts centralizados | NO | SI | Parcial | Parcial | +| Cursor parpadeante | NO | SI | SI | SI | + +## Plan de Implementación + +### Fase 1: Timing en Context (2-3 horas) + +**Objetivo:** Permitir animaciones y cursor parpadeante. + +**Cambios:** + +```zig +// context.zig +pub const Context = struct { + // ... campos existentes ... + + // NUEVO: Timing para animaciones + current_time_ms: u64 = 0, + frame_delta_ms: u32 = 0, + + pub fn setFrameTime(self: *Self, time_ms: u64) void { + self.frame_delta_ms = @intCast(time_ms - self.current_time_ms); + self.current_time_ms = time_ms; + } +}; +``` + +**Uso en aplicación (main.zig):** +```zig +while (running) { + ctx.beginFrame(); + ctx.setFrameTime(@intCast(std.time.milliTimestamp())); + // ... resto del loop +} +``` + +**Archivos a modificar:** +- `src/core/context.zig` - Agregar campos y método +- `zsimifactu/src/main.zig` - Llamar setFrameTime() + +--- + +### Fase 2: Sistema de Shortcuts (2-3 horas) + +**Objetivo:** Centralizar manejo de Ctrl+, Alt+, etc. + +**Nuevo archivo `src/core/shortcuts.zig`:** + +```zig +pub const Shortcut = struct { + name: []const u8, + key: Key, + modifiers: KeyModifiers, +}; + +pub const ShortcutManager = struct { + shortcuts: std.ArrayList(Shortcut), + + pub fn register(self: *Self, name: []const u8, key: Key, mods: KeyModifiers) void; + pub fn isActive(self: Self, name: []const u8, input: *const InputState) bool; + pub fn getActive(self: Self, input: *const InputState) ?[]const u8; +}; + +// Shortcuts predefinidos +pub const BUILTIN_SHORTCUTS = [_]Shortcut{ + .{ .name = "select_all", .key = .a, .modifiers = .{ .ctrl = true } }, + .{ .name = "copy", .key = .c, .modifiers = .{ .ctrl = true } }, + .{ .name = "paste", .key = .v, .modifiers = .{ .ctrl = true } }, + .{ .name = "cut", .key = .x, .modifiers = .{ .ctrl = true } }, + .{ .name = "undo", .key = .z, .modifiers = .{ .ctrl = true } }, + .{ .name = "redo", .key = .z, .modifiers = .{ .ctrl = true, .shift = true } }, + .{ .name = "save", .key = .s, .modifiers = .{ .ctrl = true } }, +}; +``` + +**Uso en widgets:** +```zig +// Antes (duplicado en cada widget): +if (key_event.key == .a and key_event.modifiers.ctrl) { + state.selectAll(); +} + +// Después (centralizado): +if (ctx.shortcuts.isActive("select_all", &ctx.input)) { + state.selectAll(); +} +``` + +**Archivos a modificar:** +- `src/core/shortcuts.zig` - NUEVO +- `src/core/context.zig` - Agregar ShortcutManager +- `src/widgets/text_input.zig` - Usar shortcuts +- `src/widgets/table.zig` - Usar shortcuts +- `src/zcatgui.zig` - Re-exportar + +--- + +### Fase 3: Búsqueda Incremental en Tabla (2-3 horas) + +**Objetivo:** Poder escribir letras para buscar en tabla/lista. + +**Cambios en TableState:** + +```zig +pub const TableState = struct { + // ... campos existentes ... + + // NUEVO: Búsqueda incremental + search_buffer: [64]u8 = [_]u8{0} ** 64, + search_len: usize = 0, + search_last_time: u64 = 0, + search_timeout_ms: u64 = 1000, // Reset después de 1s sin teclear + + pub fn handleSearchChar(self: *Self, char: u8, current_time: u64) void { + // Reset si pasó timeout + if (current_time - self.search_last_time > self.search_timeout_ms) { + self.search_len = 0; + } + + // Agregar carácter + if (self.search_len < self.search_buffer.len) { + self.search_buffer[self.search_len] = char; + self.search_len += 1; + } + + self.search_last_time = current_time; + } + + pub fn getSearchTerm(self: Self) []const u8 { + return self.search_buffer[0..self.search_len]; + } +}; +``` + +**En handleKeyboard():** +```zig +// Procesar caracteres imprimibles para búsqueda +const text_input = ctx.input.getTextInput(); +for (text_input) |char| { + if (char >= 32 and char < 127) { + state.handleSearchChar(char, ctx.current_time_ms); + // Buscar primera fila que coincida + // ... + } +} +``` + +**Archivos a modificar:** +- `src/widgets/table.zig` - Agregar búsqueda +- `src/widgets/list.zig` - Agregar búsqueda (opcional) +- `src/widgets/select.zig` - Agregar búsqueda (opcional) + +--- + +### Fase 4: Cursor Parpadeante (1-2 horas) + +**Objetivo:** UX profesional en campos de texto. + +**Cambios en text_input.zig:** + +```zig +// En drawCursor(): +fn drawCursor(ctx: *Context, state: *TextInputState, x: i32, y: i32, h: u32) void { + // Solo mostrar cursor si tiene focus + if (!state.focused) return; + + // Parpadeo cada 500ms + const blink_period_ms: u64 = 500; + const cursor_visible = (ctx.current_time_ms / blink_period_ms) % 2 == 0; + + if (cursor_visible) { + ctx.pushCommand(.{ .line = .{ + .x1 = x, + .y1 = y, + .x2 = x, + .y2 = y + @intCast(h), + .color = theme.cursor_color, + }}); + } +} +``` + +**Consideración CPU:** +- El parpadeo requiere redraw cada 500ms cuando hay focus +- Solución: En idle, solo despertar para parpadeo si TextInput tiene focus +- Alternativa: Cursor sólido (bloque invertido) sin parpadeo + +**Archivos a modificar:** +- `src/widgets/text_input.zig` - Cursor parpadea + +--- + +## Orden de Implementación + +1. **Fase 1 primero** - Es prerequisito para Fase 3 y 4 +2. **Fase 4 segundo** - Beneficio inmediato visible +3. **Fase 2 tercero** - Limpieza de código +4. **Fase 3 último** - Feature nuevo + +## Estimación Total + +| Fase | Horas | Complejidad | +|------|-------|-------------| +| 1 | 2-3 | Baja | +| 2 | 2-3 | Media | +| 3 | 2-3 | Media | +| 4 | 1-2 | Baja | +| **Total** | **7-11** | - | + +## Riesgos + +1. **Fase 1 (timing):** Puede afectar idle de CPU si no se maneja bien +2. **Fase 3 (búsqueda):** Requiere que aplicación provea función de matching +3. **Fase 4 (parpadeo):** Conflicto con modelo "redraw on demand" + +## Criterios de Éxito + +- [ ] Cursor parpadea en TextInput +- [ ] Ctrl+A/C/V/X/Z funcionan igual en todos los widgets +- [ ] Tabla permite buscar escribiendo letras +- [ ] CPU idle sigue siendo ~0% cuando no hay actividad +- [ ] Tests pasan + +--- + +## Referencias + +- Auditoría completa: Conversación Claude 2025-12-11 +- Gio input: https://gioui.org/doc/learn/events +- microui: https://github.com/rxi/microui +- DVUI: https://github.com/david-vanderson/dvui