zcatgui/docs/KEYBOARD_REFACTORING_PLAN.md
reugenio 7073ccef9f docs: Plan de refactoring sistema de teclado
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>
2025-12-11 22:43:05 +01:00

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é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:

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 - 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:

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ú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:

// 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