Auditoría comparando con Gio, microui y DVUI identificó 4 problemas: 1. SDL_KEYDOWN y SDL_TEXTINPUT separados 2. Dos arrays para eventos de teclado 3. Sin timing en Context para animaciones 4. Shortcuts duplicados en cada widget Plan de 4 fases aprobado (~7-11 horas total). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
7.4 KiB
7.4 KiB
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:
// 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):
while (running) {
ctx.beginFrame();
ctx.setFrameTime(@intCast(std.time.milliTimestamp()));
// ... resto del loop
}
Archivos a modificar:
src/core/context.zig- Agregar campos y métodozsimifactu/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:
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:
// 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- NUEVOsrc/core/context.zig- Agregar ShortcutManagersrc/widgets/text_input.zig- Usar shortcutssrc/widgets/table.zig- Usar shortcutssrc/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:
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():
// 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úsquedasrc/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:
// 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
- Fase 1 primero - Es prerequisito para Fase 3 y 4
- Fase 4 segundo - Beneficio inmediato visible
- Fase 2 tercero - Limpieza de código
- 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
- Fase 1 (timing): Puede afectar idle de CPU si no se maneja bien
- Fase 3 (búsqueda): Requiere que aplicación provea función de matching
- 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