zcatui/docs/PLAN_V2.2.md
reugenio 7abc87a4f5 feat: zcatui v2.2 - Complete feature set with 13 new modules
New modules (13):
- src/resize.zig: SIGWINCH terminal resize detection
- src/drag.zig: Mouse drag state and Splitter panels
- src/diagnostic.zig: Elm-style error messages with code snippets
- src/debug.zig: Debug overlay (FPS, timing, widget count)
- src/profile.zig: Performance profiling with scoped timers
- src/sixel.zig: Sixel graphics encoding for terminal images
- src/async_loop.zig: epoll-based async event loop with timers
- src/compose.zig: Widget composition utilities
- src/shortcuts.zig: Keyboard shortcut registry
- src/widgets/logo.zig: ASCII art logo widget

Enhanced modules:
- src/layout.zig: Added Constraint.ratio(num, denom)
- src/terminal.zig: Integrated resize handling
- src/root.zig: Re-exports all new modules

New examples (9):
- resize_demo, splitter_demo, dirtree_demo
- help_demo, markdown_demo, progress_demo
- spinner_demo, syntax_demo, viewport_demo

Package manager:
- build.zig.zon: Zig package manager support

Stats: 60+ source files, 186+ tests, 20 executables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 22:46:06 +01:00

75 KiB

Plan de Trabajo zcatui v2.2

Fecha: 2025-12-08 Objetivo: Implementar todas las mejoras identificadas Estimación: 13 fases de trabajo


Resumen de Tareas

Fase Descripción Archivos Nuevos Complejidad
1 Logo Widget 1 widget Baja
2 Examples v2.1 7 examples Media
3 Mouse Drag & Drop 2-3 módulos Alta
4 Keyboard Shortcuts Configurables 1 módulo Media
5 Async Event Loop (io_uring/epoll) 2-3 módulos Muy Alta
6 Widget Composition Ergonómico 1-2 módulos Media
7 Layout Ratio Constraints 1 módulo (edit) Baja
8 Terminal Resize Handler 1 módulo (edit) Media
9 Sixel Images 1 módulo Alta
10 Errores tipo Elm 1 módulo Media
11 Debug Mode 1-2 módulos Media
12 Performance Profiling 1-2 módulos Alta
13 Package Manager (build.zig.zon) 2 archivos Baja

Fase 1: Logo Widget

Objetivo

Widget para renderizar ASCII art / logos con soporte para colores y animaciones.

Archivo

  • src/widgets/logo.zig

Funcionalidades

const Logo = @import("widgets/logo.zig").Logo;

// ASCII art estático
const logo = Logo.init()
    .setText(
        \\  _______ _______ _______ _______
        \\ |___    |   _   |_     _|   |   |
        \\  ___|   |       | |   | |   |   |
        \\ |_______|___|___| |___| |_______|
    )
    .setStyle(Style{}.fg(Color.cyan).bold())
    .setAlignment(.center);

// Con gradiente de colores
const logo_gradient = Logo.init()
    .setText(ascii_art)
    .setGradient(&[_]Color{ .red, .yellow, .green, .cyan, .blue });

// Animación de aparición
const logo_animated = Logo.init()
    .setText(ascii_art)
    .setAnimation(.typewriter) // o .fade_in, .slide_down
    .setAnimationSpeed(50); // ms por caracter

Tipos de animación

  • .none - Estático
  • .typewriter - Aparece caracter por caracter
  • .fade_in - Fade usando caracteres ░▒▓█
  • .slide_down - Línea por línea desde arriba
  • .slide_up - Línea por línea desde abajo
  • .rainbow - Colores rotativos continuos
  • .pulse - Parpadeo suave

Tests

  • Renderizado básico
  • Alineación (left, center, right)
  • Gradientes
  • Animaciones

Fase 2: Examples para Widgets v2.1

Objetivo

Crear 7 demos ejecutables para los widgets nuevos.

Archivos

  1. examples/spinner_demo.zig
  2. examples/help_demo.zig
  3. examples/viewport_demo.zig
  4. examples/progress_demo.zig
  5. examples/markdown_demo.zig
  6. examples/dirtree_demo.zig
  7. examples/syntax_demo.zig

Detalle de cada demo

2.1 spinner_demo.zig

┌─ Spinner Styles ─────────────────────────┐
│                                          │
│  Dots:     ⠋ Loading...                  │
│  Line:     | Processing...               │
│  Arc:      ◜ Compiling...                │
│  Pulse:    ● Connecting...               │
│  Bounce:   ⠁ Syncing...                  │
│  Clock:    ◴ Waiting...                  │
│  Moon:     ◐ Downloading...              │
│  Box:      ▖ Building...                 │
│  Circle:   ◴ Testing...                  │
│  Custom:   ⣾ Custom spinner...           │
│                                          │
│  Press 1-0 to select style, q to quit    │
└──────────────────────────────────────────┘

2.2 help_demo.zig

┌─ Keyboard Shortcuts ─────────────────────┐
│                                          │
│  Navigation                              │
│  ──────────                              │
│  ↑/k     Move up                         │
│  ↓/j     Move down                       │
│  ←/h     Move left                       │
│  →/l     Move right                      │
│  Home    Go to start                     │
│  End     Go to end                       │
│                                          │
│  Actions                                 │
│  ───────                                 │
│  Enter   Select item                     │
│  Space   Toggle selection                │
│  /       Search                          │
│  ?       Show this help                  │
│                                          │
│  Press any key to close                  │
└──────────────────────────────────────────┘

2.3 viewport_demo.zig

┌─ Viewport Demo ──────────────────────────┐
│ Line 1: Lorem ipsum dolor sit amet...    │▲
│ Line 2: Consectetur adipiscing elit...   │█
│ Line 3: Sed do eiusmod tempor...         │█
│ Line 4: Incididunt ut labore et...       │█
│ Line 5: Dolore magna aliqua...           │░
│ Line 6: Ut enim ad minim veniam...       │░
│ Line 7: Quis nostrud exercitation...     │░
│ Line 8: Ullamco laboris nisi ut...       │░
│                                          │░
│ [Scroll: 1-100 of 500 lines]             │▼
└──────────────────────────────────────────┘
  ↑/↓: Scroll  PgUp/PgDn: Page  Home/End

2.4 progress_demo.zig

┌─ Multi-Step Progress ────────────────────┐
│                                          │
│  Installation Progress                   │
│                                          │
│  ✓ Download      ████████████████ 100%   │
│  ✓ Extract       ████████████████ 100%   │
│  → Compile       ████████░░░░░░░░  52%   │
│  ○ Test          ░░░░░░░░░░░░░░░░   0%   │
│  ○ Install       ░░░░░░░░░░░░░░░░   0%   │
│                                          │
│  Overall: ████████████░░░░░░░░  63%      │
│                                          │
│  Compiling: src/widgets/chart.zig        │
│  ETA: 2m 34s                             │
└──────────────────────────────────────────┘

2.5 markdown_demo.zig

┌─ Markdown Viewer ────────────────────────┐
│                                          │
│  # Welcome to zcatui                     │
│                                          │
│  A **TUI library** for Zig with:         │
│                                          │
│  - 34 widgets                            │
│  - Event handling                        │
│  - Animations                            │
│                                          │
│  ## Code Example                         │
│  ┌────────────────────────────────────┐  │
│  │ const zcatui = @import("zcatui"); │  │
│  │ const term = zcatui.Terminal;     │  │
│  └────────────────────────────────────┘  │
│                                          │
│  > This is a blockquote                  │
│                                          │
└──────────────────────────────────────────┘

2.6 dirtree_demo.zig

┌─ File Browser ───────────────────────────┐
│ 📁 /home/user/projects/zcatui            │
├──────────────────────────────────────────┤
│ 📁 src/                                  │
│   📁 widgets/                            │
│     📄 block.zig                    2.1K │
│     📄 list.zig                     4.3K │
│   > 📄 root.zig                     1.2K │
│   📄 buffer.zig                     3.5K │
│ 📁 examples/                             │
│ 📁 docs/                                 │
│ 📄 build.zig                        890B │
│ 📄 README.md                        2.4K │
├──────────────────────────────────────────┤
│ 12 files, 4 dirs, 45.2 KB total          │
└──────────────────────────────────────────┘
  Enter: Open  Backspace: Up  Tab: Preview

2.7 syntax_demo.zig

┌─ Syntax Highlighter ─────────────────────┐
│ Language: [Zig ▼]                        │
├──────────────────────────────────────────┤
│  1 │ const std = @import("std");         │
│  2 │                                     │
│  3 │ pub fn main() !void {               │
│  4 │     const stdout = std.io.getStd(); │
│  5 │     try stdout.print("Hello\n", .{});│
│  6 │ }                                   │
│  7 │                                     │
│  8 │ // This is a comment                │
│  9 │ const x: u32 = 42;                  │
│ 10 │ const str = "string literal";       │
├──────────────────────────────────────────┤
│ Zig | 10 lines | UTF-8                   │
└──────────────────────────────────────────┘
  1-5: Language  q: Quit

Actualizar build.zig

Añadir los 7 nuevos ejemplos al build system.


Fase 3: Mouse Drag & Drop + Resize Panels

Objetivo

Sistema completo de drag & drop para redimensionar paneles con el ratón.

Archivos

  • src/drag.zig - Sistema de drag & drop
  • src/resizable.zig - Wrapper para hacer widgets redimensionables
  • Modificar src/widgets/panel.zig - Integrar resize

Arquitectura

// drag.zig
pub const DragState = struct {
    active: bool = false,
    start_pos: Position,
    current_pos: Position,
    target: DragTarget,

    pub const DragTarget = union(enum) {
        panel_border: struct {
            panel_id: u32,
            edge: Edge, // .left, .right, .top, .bottom
        },
        splitter: struct {
            splitter_id: u32,
            orientation: Orientation,
        },
        window: u32,
        custom: *anyopaque,
    };
};

pub const DragManager = struct {
    state: ?DragState = null,
    on_drag_start: ?*const fn(DragState) void = null,
    on_drag_move: ?*const fn(DragState, Position) void = null,
    on_drag_end: ?*const fn(DragState, Position) void = null,

    /// Procesar evento de ratón
    pub fn handleMouseEvent(self: *DragManager, event: MouseEvent) DragResult {
        // Detectar inicio de drag (click en borde)
        // Actualizar posición durante drag
        // Finalizar en release
    }

    /// Obtener cursor apropiado para la posición
    pub fn getCursor(self: *const DragManager, pos: Position) Cursor {
        // Retorna .resize_ew, .resize_ns, etc.
    }
};
// resizable.zig
pub fn Resizable(comptime Widget: type) type {
    return struct {
        inner: Widget,
        min_size: Size = .{ .width = 10, .height = 3 },
        max_size: ?Size = null,
        resize_edges: EdgeSet = EdgeSet.all(),

        pub fn render(self: *@This(), area: Rect, buf: *Buffer) void {
            // Renderizar widget interno
            self.inner.render(area, buf);

            // Renderizar handles de resize en bordes
            if (self.resize_edges.contains(.right)) {
                self.renderResizeHandle(buf, area, .right);
            }
            // ...
        }

        pub fn handleDrag(self: *@This(), drag: DragState, delta: Position) void {
            // Ajustar tamaño según el drag
        }
    };
}

// Uso:
var resizable_panel = Resizable(Panel).init(my_panel)
    .setMinSize(.{ .width = 20, .height = 5 })
    .setResizeEdges(.{ .right = true, .bottom = true });

Splitter Widget

// Para dividir áreas con drag
pub const Splitter = struct {
    orientation: Orientation,
    position: f32, // 0.0 - 1.0 (porcentaje)
    min_first: u16 = 10,
    min_second: u16 = 10,

    pub fn render(self: Splitter, area: Rect, buf: *Buffer) void {
        // Renderizar línea divisoria
        // Con handle visual para drag
    }

    pub fn getAreas(self: Splitter, area: Rect) struct { first: Rect, second: Rect } {
        // Calcular las dos áreas resultantes
    }
};

Cursores de terminal

pub const Cursor = enum {
    default,
    pointer,
    text,
    resize_ew,   // ↔ horizontal
    resize_ns,   // ↕ vertical
    resize_nwse, // ↘ diagonal
    resize_nesw, // ↙ diagonal
    move,        // ✥ mover
    not_allowed, // 🚫

    /// Secuencia ANSI para cambiar cursor (si el terminal lo soporta)
    pub fn toAnsi(self: Cursor) []const u8 {
        return switch (self) {
            .default => "\x1b[0 q",
            .pointer => "\x1b[1 q",
            // etc.
        };
    }
};

Tests

  • Drag start/move/end lifecycle
  • Resize constraints (min/max)
  • Splitter position calculation
  • Edge detection

Fase 4: Keyboard Shortcuts Configurables

Objetivo

Sistema de shortcuts reconfigurables para evitar conflictos con la aplicación host.

Archivo

  • src/shortcuts.zig

Diseño

pub const Shortcut = struct {
    key: KeyCode,
    modifiers: Modifiers = .{},

    pub const Modifiers = packed struct {
        ctrl: bool = false,
        alt: bool = false,
        shift: bool = false,
        super: bool = false,
    };

    pub fn matches(self: Shortcut, event: KeyEvent) bool {
        return self.key == event.code and
               self.modifiers.ctrl == event.ctrl and
               self.modifiers.alt == event.alt and
               self.modifiers.shift == event.shift;
    }

    /// Parse from string: "Ctrl+Shift+S", "Alt+F4", "Enter"
    pub fn parse(str: []const u8) !Shortcut { ... }

    /// Format to string
    pub fn format(self: Shortcut, buf: []u8) []const u8 { ... }
};

pub const Action = enum {
    // Navigation
    move_up,
    move_down,
    move_left,
    move_right,
    page_up,
    page_down,
    go_start,
    go_end,

    // Selection
    select,
    toggle,
    select_all,
    select_none,

    // Edit
    delete,
    copy,
    paste,
    cut,
    undo,
    redo,

    // UI
    focus_next,
    focus_prev,
    close,
    cancel,
    confirm,
    help,
    search,

    // Resize
    resize_grow_h,
    resize_shrink_h,
    resize_grow_v,
    resize_shrink_v,

    // Custom
    custom_1,
    custom_2,
    // ... hasta custom_20
};

pub const ShortcutMap = struct {
    bindings: std.EnumMap(Action, Shortcut),

    /// Preset por defecto (vim-like)
    pub const vim_preset = ShortcutMap{
        .bindings = .{
            .move_up = .{ .key = .char, .char = 'k' },
            .move_down = .{ .key = .char, .char = 'j' },
            .move_left = .{ .key = .char, .char = 'h' },
            .move_right = .{ .key = .char, .char = 'l' },
            // ...
        },
    };

    /// Preset arrows (tradicional)
    pub const arrows_preset = ShortcutMap{
        .bindings = .{
            .move_up = .{ .key = .up },
            .move_down = .{ .key = .down },
            // ...
        },
    };

    /// Preset emacs
    pub const emacs_preset = ShortcutMap{
        .bindings = .{
            .move_up = .{ .key = .char, .char = 'p', .modifiers = .{ .ctrl = true } },
            .move_down = .{ .key = .char, .char = 'n', .modifiers = .{ .ctrl = true } },
            // ...
        },
    };

    /// Rebind una acción
    pub fn rebind(self: *ShortcutMap, action: Action, shortcut: Shortcut) void {
        self.bindings.put(action, shortcut);
    }

    /// Obtener acción para un evento
    pub fn getAction(self: *const ShortcutMap, event: KeyEvent) ?Action {
        var iter = self.bindings.iterator();
        while (iter.next()) |entry| {
            if (entry.value.matches(event)) {
                return entry.key;
            }
        }
        return null;
    }

    /// Cargar desde archivo de configuración
    pub fn loadFromFile(allocator: Allocator, path: []const u8) !ShortcutMap { ... }

    /// Guardar a archivo
    pub fn saveToFile(self: *const ShortcutMap, path: []const u8) !void { ... }

    /// Detectar conflictos
    pub fn findConflicts(self: *const ShortcutMap) []Conflict {
        // Retorna shortcuts que mapean al mismo key
    }
};

/// Contexto de shortcuts (para diferentes modos)
pub const ShortcutContext = struct {
    name: []const u8,
    map: ShortcutMap,
    parent: ?*const ShortcutContext = null, // Herencia

    pub fn getAction(self: *const ShortcutContext, event: KeyEvent) ?Action {
        // Buscar en este contexto, luego en parent
        if (self.map.getAction(event)) |action| {
            return action;
        }
        if (self.parent) |p| {
            return p.getAction(event);
        }
        return null;
    }
};

Formato de archivo de configuración

# ~/.config/zcatui/shortcuts.conf

# Navigation
move_up = k, Up
move_down = j, Down
move_left = h, Left
move_right = l, Right

# With modifiers
page_up = Ctrl+u, PageUp
page_down = Ctrl+d, PageDown
go_start = g g, Home
go_end = G, End

# Actions
select = Enter, Space
delete = d d, Delete
copy = y y, Ctrl+c
paste = p, Ctrl+v

# Resize (Ctrl+Arrow)
resize_grow_h = Ctrl+Right
resize_shrink_h = Ctrl+Left
resize_grow_v = Ctrl+Down
resize_shrink_v = Ctrl+Up

Tests

  • Parse shortcuts from strings
  • Match events
  • Conflict detection
  • File load/save
  • Context inheritance

Fase 5: Async Event Loop (io_uring/epoll)

Objetivo

Event loop asíncrono para alto rendimiento, con fallback a epoll para compatibilidad.

Archivos

  • src/async/loop.zig - Event loop principal
  • src/async/io_uring.zig - Backend io_uring (Linux 5.1+)
  • src/async/epoll.zig - Backend epoll (fallback)
  • src/async/poll.zig - Backend poll (máxima compatibilidad)

Arquitectura

// loop.zig
pub const EventLoop = struct {
    backend: Backend,
    callbacks: CallbackRegistry,
    timers: TimerQueue,
    running: bool = false,

    pub const Backend = union(enum) {
        io_uring: IoUring,
        epoll: Epoll,
        poll: Poll,
    };

    /// Detectar mejor backend disponible
    pub fn init(allocator: Allocator) !EventLoop {
        // Intentar io_uring primero
        if (IoUring.isSupported()) {
            return .{ .backend = .{ .io_uring = try IoUring.init() } };
        }
        // Fallback a epoll
        if (Epoll.isSupported()) {
            return .{ .backend = .{ .epoll = try Epoll.init() } };
        }
        // Último recurso: poll
        return .{ .backend = .{ .poll = try Poll.init() } };
    }

    /// Registrar file descriptor para lectura
    pub fn addReader(self: *EventLoop, fd: i32, callback: ReadCallback) !void {
        switch (self.backend) {
            .io_uring => |*io| try io.addReader(fd, callback),
            .epoll => |*ep| try ep.addReader(fd, callback),
            .poll => |*p| try p.addReader(fd, callback),
        }
    }

    /// Registrar timer
    pub fn setTimeout(self: *EventLoop, ms: u64, callback: TimerCallback) TimerId {
        return self.timers.add(ms, callback, .once);
    }

    pub fn setInterval(self: *EventLoop, ms: u64, callback: TimerCallback) TimerId {
        return self.timers.add(ms, callback, .repeating);
    }

    /// Ejecutar una iteración
    pub fn poll(self: *EventLoop, timeout_ms: ?u32) !void {
        // 1. Procesar timers vencidos
        self.timers.processExpired();

        // 2. Poll I/O events
        const events = switch (self.backend) {
            .io_uring => |*io| try io.poll(timeout_ms),
            .epoll => |*ep| try ep.poll(timeout_ms),
            .poll => |*p| try p.poll(timeout_ms),
        };

        // 3. Dispatch callbacks
        for (events) |event| {
            if (self.callbacks.get(event.fd)) |cb| {
                cb(event);
            }
        }
    }

    /// Run loop principal
    pub fn run(self: *EventLoop) !void {
        self.running = true;
        while (self.running) {
            try self.poll(null);
        }
    }

    /// Detener el loop
    pub fn stop(self: *EventLoop) void {
        self.running = false;
    }
};
// io_uring.zig
pub const IoUring = struct {
    ring: linux.io_uring,

    pub fn isSupported() bool {
        // Check kernel version >= 5.1
        var uname: linux.utsname = undefined;
        _ = linux.uname(&uname);
        // Parse version...
        return kernel_version >= .{ .major = 5, .minor = 1 };
    }

    pub fn init() !IoUring {
        var ring: linux.io_uring = undefined;
        const ret = linux.io_uring_setup(256, &ring);
        if (ret < 0) return error.IoUringSetupFailed;
        return .{ .ring = ring };
    }

    pub fn addReader(self: *IoUring, fd: i32, callback: ReadCallback) !void {
        const sqe = linux.io_uring_get_sqe(&self.ring);
        linux.io_uring_prep_read(sqe, fd, ...);
        // ...
    }

    pub fn poll(self: *IoUring, timeout: ?u32) ![]Event {
        // Submit and wait for completions
        _ = linux.io_uring_submit(&self.ring);
        // ...
    }
};
// epoll.zig
pub const Epoll = struct {
    epfd: i32,
    events: [64]linux.epoll_event,

    pub fn isSupported() bool {
        return @import("builtin").os.tag == .linux;
    }

    pub fn init() !Epoll {
        const epfd = linux.epoll_create1(0);
        if (epfd < 0) return error.EpollCreateFailed;
        return .{ .epfd = epfd, .events = undefined };
    }

    pub fn addReader(self: *Epoll, fd: i32, callback: ReadCallback) !void {
        var ev = linux.epoll_event{
            .events = linux.EPOLLIN,
            .data = .{ .fd = fd },
        };
        _ = linux.epoll_ctl(self.epfd, linux.EPOLL_CTL_ADD, fd, &ev);
    }

    pub fn poll(self: *Epoll, timeout: ?u32) ![]Event {
        const n = linux.epoll_wait(self.epfd, &self.events, 64, timeout orelse -1);
        // Convert to Events...
    }
};

Integración con Terminal

// En terminal.zig
pub const AsyncTerminal = struct {
    terminal: Terminal,
    loop: EventLoop,
    event_callback: ?*const fn(Event) void = null,

    pub fn init(allocator: Allocator) !AsyncTerminal {
        var term = try Terminal.init(allocator);
        var loop = try EventLoop.init(allocator);

        // Registrar stdin para eventos
        try loop.addReader(std.os.STDIN_FILENO, handleStdinReady);

        return .{ .terminal = term, .loop = loop };
    }

    /// Callback cuando stdin tiene datos
    fn handleStdinReady(self: *AsyncTerminal) void {
        if (self.terminal.pollEvent()) |event| {
            if (self.event_callback) |cb| {
                cb(event);
            }
        }
    }

    /// Render con rate limiting
    pub fn scheduleRender(self: *AsyncTerminal, delay_ms: u32) void {
        self.loop.setTimeout(delay_ms, renderFrame);
    }
};

Tests

  • io_uring availability detection
  • epoll fallback
  • Timer accuracy
  • Event dispatch
  • Terminal integration

Fase 6: Widget Composition Ergonómico

Objetivo

API más ergonómica para componer widgets complejos.

Archivo

  • src/compose.zig

Diseño actual vs propuesto

// ACTUAL (verboso)
fn render(area: Rect, buf: *Buffer) void {
    const layout = Layout.init(.vertical, &[_]Constraint{
        .{ .length = 3 },
        .{ .min = 1 },
        .{ .length = 1 },
    });
    const chunks = layout.split(area);

    const header = Block.init().title("Header").borders(.all);
    header.render(chunks[0], buf);

    const content = Paragraph.init().text("Content");
    content.render(chunks[1], buf);

    const footer = Paragraph.init().text("Footer");
    footer.render(chunks[2], buf);
}

// PROPUESTO (fluent/declarativo)
fn render(area: Rect, buf: *Buffer) void {
    compose(buf, area)
        .column(.{
            .header(3, Block.init().title("Header").borders(.all)),
            .flex(1, Paragraph.init().text("Content")),
            .footer(1, Paragraph.init().text("Footer")),
        });
}

// O estilo builder:
fn render(area: Rect, buf: *Buffer) void {
    VStack.init()
        .child(Block.init().title("Header"), .{ .height = 3 })
        .child(Paragraph.init().text("Content"), .{ .flex = 1 })
        .child(Paragraph.init().text("Footer"), .{ .height = 1 })
        .render(area, buf);
}

Implementación

// compose.zig

/// Stack vertical de widgets
pub fn VStack(comptime N: usize) type {
    return struct {
        children: [N]Child,
        spacing: u16 = 0,
        alignment: Alignment = .start,

        pub const Child = struct {
            widget: Widget,
            constraint: Constraint,
        };

        pub fn init() @This() {
            return .{ .children = undefined };
        }

        pub fn child(self: *@This(), widget: anytype, constraint: Constraint) *@This() {
            // Add child...
            return self;
        }

        pub fn spacing(self: *@This(), s: u16) *@This() {
            self.spacing = s;
            return self;
        }

        pub fn render(self: @This(), area: Rect, buf: *Buffer) void {
            // Calculate layout and render children
        }
    };
}

/// Stack horizontal
pub fn HStack(comptime N: usize) type { ... }

/// Z-Stack (overlay)
pub fn ZStack(comptime N: usize) type { ... }

/// Conditional rendering
pub fn when(condition: bool, widget: anytype) ?@TypeOf(widget) {
    return if (condition) widget else null;
}

/// Composición declarativa con comptime
pub fn compose(buf: *Buffer, area: Rect) Composer {
    return .{ .buf = buf, .area = area };
}

pub const Composer = struct {
    buf: *Buffer,
    area: Rect,

    pub fn column(self: Composer, children: anytype) void {
        const T = @TypeOf(children);
        const fields = @typeInfo(T).@"struct".fields;

        // Build constraints array
        var constraints: [fields.len]Constraint = undefined;
        inline for (fields, 0..) |field, i| {
            constraints[i] = @field(children, field.name).constraint;
        }

        // Split area
        const layout = Layout.init(.vertical, &constraints);
        const chunks = layout.split(self.area);

        // Render each child
        inline for (fields, 0..) |field, i| {
            @field(children, field.name).widget.render(chunks[i], self.buf);
        }
    }

    pub fn row(self: Composer, children: anytype) void { ... }

    pub fn overlay(self: Composer, children: anytype) void { ... }
};

/// Helper para crear child con constraint
pub fn sized(widget: anytype, height: u16) Child(@TypeOf(widget)) {
    return .{ .widget = widget, .constraint = .{ .length = height } };
}

pub fn flex(widget: anytype, factor: u16) Child(@TypeOf(widget)) {
    return .{ .widget = widget, .constraint = .{ .ratio = .{ factor, 1 } } };
}

pub fn fill(widget: anytype) Child(@TypeOf(widget)) {
    return .{ .widget = widget, .constraint = .{ .min = 0 } };
}

Ejemplo completo

const App = struct {
    state: AppState,

    pub fn render(self: *App, area: Rect, buf: *Buffer) void {
        compose(buf, area).column(.{
            // Header fijo
            sized(
                Block.init()
                    .title("My App")
                    .borders(.all)
                    .style(self.theme.header),
                3
            ),

            // Content area flexible
            flex(
                compose(buf, area).row(.{
                    // Sidebar
                    sized(self.renderSidebar(), 20),
                    // Main content
                    fill(self.renderContent()),
                }),
                1
            ),

            // Footer condicional
            when(self.state.show_footer,
                sized(StatusBar.init().text(self.status), 1)
            ),
        });
    }
};

Tests

  • VStack/HStack layout
  • Nested composition
  • Conditional rendering
  • Constraint propagation

Fase 7: Layout Ratio Constraints

Objetivo

Añadir constraint por ratio (fracción) además de length/min/max/percentage.

Archivo

  • Modificar src/layout.zig

Implementación

// En Constraint
pub const Constraint = union(enum) {
    /// Fixed size in cells
    length: u16,
    /// Minimum size
    min: u16,
    /// Maximum size
    max: u16,
    /// Percentage of available space (0-100)
    percentage: u16,
    /// Ratio of remaining space (numerator, denominator)
    ratio: struct { num: u16, den: u16 },

    /// Helper constructors
    pub fn fixed(size: u16) Constraint {
        return .{ .length = size };
    }

    pub fn percent(p: u16) Constraint {
        return .{ .percentage = @min(p, 100) };
    }

    pub fn ratio(num: u16, den: u16) Constraint {
        return .{ .ratio = .{ .num = num, .den = den } };
    }

    // Ejemplo: 1/3 del espacio
    pub fn third() Constraint {
        return ratio(1, 3);
    }

    // Ejemplo: 2/3 del espacio
    pub fn twoThirds() Constraint {
        return ratio(2, 3);
    }
};

Algoritmo de split actualizado

pub fn split(self: Layout, area: Rect) []Rect {
    const total = if (self.direction == .horizontal) area.width else area.height;
    var remaining = total;
    var results: [MAX_CHUNKS]Rect = undefined;

    // Fase 1: Calcular tamaños fijos
    for (self.constraints) |c| {
        switch (c) {
            .length => |len| remaining -= @min(len, remaining),
            else => {},
        }
    }

    // Fase 2: Calcular ratios
    var total_ratio_parts: u32 = 0;
    for (self.constraints) |c| {
        switch (c) {
            .ratio => |r| total_ratio_parts += r.num,
            else => {},
        }
    }

    // Fase 3: Asignar tamaños
    var pos: u16 = if (self.direction == .horizontal) area.x else area.y;

    for (self.constraints, 0..) |c, i| {
        const size: u16 = switch (c) {
            .length => |len| len,
            .min => |min| @max(min, remaining / (self.constraints.len - i)),
            .max => |max| @min(max, remaining / (self.constraints.len - i)),
            .percentage => |p| @as(u16, @intCast((@as(u32, total) * p) / 100)),
            .ratio => |r| blk: {
                const ratio_space = remaining; // Espacio para ratios
                break :blk @as(u16, @intCast((@as(u32, ratio_space) * r.num) / total_ratio_parts));
            },
        };

        results[i] = if (self.direction == .horizontal)
            Rect{ .x = pos, .y = area.y, .width = size, .height = area.height }
        else
            Rect{ .x = area.x, .y = pos, .width = area.width, .height = size };

        pos += size + self.spacing;
    }

    return results[0..self.constraints.len];
}

Ejemplos de uso

// Dividir en tercios
const layout = Layout.init(.horizontal, &[_]Constraint{
    .ratio(1, 3), // 1/3
    .ratio(1, 3), // 1/3
    .ratio(1, 3), // 1/3
});

// Golden ratio (aproximado)
const golden = Layout.init(.horizontal, &[_]Constraint{
    .ratio(382, 1000), // ~38.2%
    .ratio(618, 1000), // ~61.8%
});

// Sidebar + content
const sidebar_layout = Layout.init(.horizontal, &[_]Constraint{
    .length(20),      // Sidebar fijo
    .ratio(1, 1),     // Content ocupa el resto
});

// Three column con proporciones
const three_col = Layout.init(.horizontal, &[_]Constraint{
    .ratio(1, 4), // 25%
    .ratio(2, 4), // 50%
    .ratio(1, 4), // 25%
});

Tests

  • Ratio simple (1/2, 1/3)
  • Ratios múltiples
  • Combinación ratio + fixed
  • Edge cases (ratio > espacio disponible)

Fase 8: Terminal Resize Handler Mejorado

Objetivo

Manejo robusto de resize de terminal con debounce y callbacks.

Archivo

  • Modificar src/terminal.zig
  • Nuevo: src/resize.zig

Implementación

// resize.zig
pub const ResizeHandler = struct {
    current_size: Size,
    callbacks: std.ArrayListUnmanaged(ResizeCallback),
    debounce_ms: u32 = 100,
    last_resize: i64 = 0,
    pending_size: ?Size = null,
    allocator: std.mem.Allocator,

    pub const ResizeCallback = *const fn (old: Size, new: Size) void;

    pub fn init(allocator: std.mem.Allocator) ResizeHandler {
        return .{
            .current_size = getTerminalSize(),
            .callbacks = .{},
            .allocator = allocator,
        };
    }

    /// Registrar callback para resize
    pub fn onResize(self: *ResizeHandler, callback: ResizeCallback) !void {
        try self.callbacks.append(self.allocator, callback);
    }

    /// Procesar señal SIGWINCH
    pub fn handleSignal(self: *ResizeHandler) void {
        const new_size = getTerminalSize();
        if (!new_size.eql(self.current_size)) {
            self.pending_size = new_size;
            self.last_resize = std.time.milliTimestamp();
        }
    }

    /// Llamar en el event loop para procesar resize con debounce
    pub fn poll(self: *ResizeHandler) ?struct { old: Size, new: Size } {
        if (self.pending_size) |new_size| {
            const now = std.time.milliTimestamp();
            if (now - self.last_resize >= self.debounce_ms) {
                const old_size = self.current_size;
                self.current_size = new_size;
                self.pending_size = null;

                // Notificar callbacks
                for (self.callbacks.items) |cb| {
                    cb(old_size, new_size);
                }

                return .{ .old = old_size, .new = new_size };
            }
        }
        return null;
    }

    /// Obtener tamaño actual
    pub fn getSize(self: *const ResizeHandler) Size {
        return self.current_size;
    }
};

/// Obtener tamaño del terminal
pub fn getTerminalSize() Size {
    var ws: std.os.linux.winsize = undefined;
    const ret = std.os.linux.ioctl(
        std.os.STDOUT_FILENO,
        std.os.linux.T.IOCGWINSZ,
        @intFromPtr(&ws)
    );

    if (ret == 0) {
        return .{
            .width = ws.ws_col,
            .height = ws.ws_row,
        };
    }

    // Fallback: variables de entorno
    const cols = std.os.getenv("COLUMNS") orelse "80";
    const rows = std.os.getenv("LINES") orelse "24";

    return .{
        .width = std.fmt.parseInt(u16, cols, 10) catch 80,
        .height = std.fmt.parseInt(u16, rows, 10) catch 24,
    };
}

/// Instalar handler de SIGWINCH
pub fn installResizeSignalHandler(handler: *ResizeHandler) !void {
    const S = struct {
        var global_handler: ?*ResizeHandler = null;

        fn sigwinchHandler(_: c_int) callconv(.C) void {
            if (global_handler) |h| {
                h.handleSignal();
            }
        }
    };

    S.global_handler = handler;

    var sa = std.os.linux.Sigaction{
        .handler = .{ .handler = S.sigwinchHandler },
        .mask = std.os.linux.empty_sigset,
        .flags = 0,
    };

    _ = std.os.linux.sigaction(std.os.linux.SIG.WINCH, &sa, null);
}

Integración con Terminal

// En terminal.zig
pub const Terminal = struct {
    // ... campos existentes ...
    resize_handler: ResizeHandler,

    pub fn init(allocator: Allocator) !Terminal {
        var self = Terminal{
            // ...
            .resize_handler = ResizeHandler.init(allocator),
        };

        try installResizeSignalHandler(&self.resize_handler);

        return self;
    }

    pub fn pollEvent(self: *Terminal) ?Event {
        // Verificar resize primero
        if (self.resize_handler.poll()) |resize| {
            return Event{ .resize = resize };
        }

        // Luego verificar input...
    }

    pub fn onResize(self: *Terminal, callback: ResizeHandler.ResizeCallback) !void {
        try self.resize_handler.onResize(callback);
    }
};

Layout responsivo

pub const ResponsiveLayout = struct {
    breakpoints: struct {
        small: u16 = 60,   // < 60 cols
        medium: u16 = 100, // 60-100 cols
        large: u16 = 140,  // 100-140 cols
        // > 140 = xlarge
    },

    pub fn getBreakpoint(self: ResponsiveLayout, width: u16) Breakpoint {
        if (width < self.breakpoints.small) return .small;
        if (width < self.breakpoints.medium) return .medium;
        if (width < self.breakpoints.large) return .large;
        return .xlarge;
    }

    pub const Breakpoint = enum { small, medium, large, xlarge };
};

// Uso
fn render(app: *App, area: Rect, buf: *Buffer) void {
    const bp = ResponsiveLayout{}.getBreakpoint(area.width);

    switch (bp) {
        .small => app.renderMobile(area, buf),
        .medium => app.renderTablet(area, buf),
        .large, .xlarge => app.renderDesktop(area, buf),
    }
}

Tests

  • Size detection
  • Signal handling
  • Debounce
  • Callback dispatch
  • Responsive breakpoints

Fase 9: Sixel Images

Objetivo

Soporte para imágenes Sixel (alternativa a Kitty protocol, más compatible).

Archivo

  • src/sixel.zig

Documentación Sixel

Sixel es un formato de gráficos para terminales que data de los años 80 (DEC). Muchos terminales modernos lo soportan: xterm, mlterm, mintty, Windows Terminal, foot.

Formato Sixel

ESC P q <data> ESC \

Donde <data> es:
- " Pan ; Pad ; Ph ; Pv - Atributos raster
- # Pc ; Pu ; Px ; Py ; Pz - Definir color
- ! Pn <char> - Repetir caracter Pn veces
- $ - Carriage return (sin line feed)
- - - Line feed (nueva banda de 6 píxeles)
- ? a ~ - Datos (cada caracter = 6 píxeles verticales)

Implementación

// sixel.zig
pub const SixelEncoder = struct {
    output: std.ArrayListUnmanaged(u8),
    palette: [256]Color,
    palette_size: u8,
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) SixelEncoder {
        return .{
            .output = .{},
            .palette = undefined,
            .palette_size = 0,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *SixelEncoder) void {
        self.output.deinit(self.allocator);
    }

    /// Codificar imagen RGBA a Sixel
    pub fn encode(self: *SixelEncoder, pixels: []const u8, width: u32, height: u32) ![]const u8 {
        self.output.clearRetainingCapacity();

        // Inicio secuencia Sixel
        try self.output.appendSlice(self.allocator, "\x1bPq");

        // Cuantizar colores a paleta (máx 256)
        try self.buildPalette(pixels, width, height);

        // Escribir definiciones de paleta
        try self.writePalette();

        // Codificar píxeles en bandas de 6
        try self.encodePixels(pixels, width, height);

        // Fin secuencia
        try self.output.appendSlice(self.allocator, "\x1b\\");

        return self.output.items;
    }

    fn buildPalette(self: *SixelEncoder, pixels: []const u8, width: u32, height: u32) !void {
        // Algoritmo de cuantización (median cut o similar)
        var color_counts = std.AutoHashMap(u32, u32).init(self.allocator);
        defer color_counts.deinit();

        // Contar colores únicos
        var i: usize = 0;
        while (i < pixels.len) : (i += 4) {
            const r = pixels[i];
            const g = pixels[i + 1];
            const b = pixels[i + 2];
            const key = (@as(u32, r) << 16) | (@as(u32, g) << 8) | b;

            const entry = try color_counts.getOrPut(key);
            if (!entry.found_existing) {
                entry.value_ptr.* = 0;
            }
            entry.value_ptr.* += 1;
        }

        // Reducir a 256 colores si es necesario
        // (implementar median cut aquí)

        // Por ahora, tomar los primeros 256
        var iter = color_counts.iterator();
        self.palette_size = 0;
        while (iter.next()) |entry| {
            if (self.palette_size >= 256) break;

            const key = entry.key_ptr.*;
            self.palette[self.palette_size] = Color.rgb(
                @truncate(key >> 16),
                @truncate(key >> 8),
                @truncate(key),
            );
            self.palette_size += 1;
        }
    }

    fn writePalette(self: *SixelEncoder) !void {
        for (self.palette[0..self.palette_size], 0..) |color, i| {
            const rgb = switch (color) {
                .true_color => |c| c,
                else => continue,
            };

            // Formato: #Pc;2;R;G;B (donde R,G,B son 0-100)
            var buf: [32]u8 = undefined;
            const str = std.fmt.bufPrint(&buf, "#{d};2;{d};{d};{d}", .{
                i,
                @as(u32, rgb.r) * 100 / 255,
                @as(u32, rgb.g) * 100 / 255,
                @as(u32, rgb.b) * 100 / 255,
            }) catch continue;

            try self.output.appendSlice(self.allocator, str);
        }
    }

    fn encodePixels(self: *SixelEncoder, pixels: []const u8, width: u32, height: u32) !void {
        // Procesar en bandas de 6 píxeles de alto
        var y: u32 = 0;
        while (y < height) : (y += 6) {
            const band_height = @min(6, height - y);

            // Para cada color en la paleta
            for (0..self.palette_size) |color_idx| {
                var has_pixels = false;
                var run_start: ?u32 = null;
                var run_char: u8 = 0;

                for (0..width) |x| {
                    var sixel_bits: u8 = 0;

                    // Construir los 6 bits verticales
                    for (0..band_height) |dy| {
                        const py = y + dy;
                        const idx = (py * width + x) * 4;

                        if (self.getColorIndex(pixels[idx..][0..3]) == color_idx) {
                            sixel_bits |= @as(u8, 1) << @intCast(dy);
                            has_pixels = true;
                        }
                    }

                    const char = sixel_bits + 63; // '?' = 0, '~' = 63

                    // Run-length encoding
                    if (run_start == null) {
                        run_start = @intCast(x);
                        run_char = char;
                    } else if (char != run_char) {
                        try self.writeRun(run_char, x - run_start.?);
                        run_start = @intCast(x);
                        run_char = char;
                    }
                }

                // Escribir último run
                if (run_start) |start| {
                    try self.writeRun(run_char, width - start);
                }

                // Carriage return (volver al inicio de la banda)
                if (has_pixels) {
                    try self.output.append(self.allocator, '$');
                }
            }

            // Line feed (siguiente banda de 6 píxeles)
            try self.output.append(self.allocator, '-');
        }
    }

    fn writeRun(self: *SixelEncoder, char: u8, count: u32) !void {
        if (count == 1) {
            try self.output.append(self.allocator, char);
        } else if (count > 1) {
            var buf: [16]u8 = undefined;
            const str = std.fmt.bufPrint(&buf, "!{d}", .{count}) catch return;
            try self.output.appendSlice(self.allocator, str);
            try self.output.append(self.allocator, char);
        }
    }

    fn getColorIndex(self: *SixelEncoder, rgb: *const [3]u8) usize {
        // Encontrar color más cercano en la paleta
        var best_idx: usize = 0;
        var best_dist: u32 = std.math.maxInt(u32);

        for (self.palette[0..self.palette_size], 0..) |color, i| {
            const c = switch (color) {
                .true_color => |c| c,
                else => continue,
            };

            const dr = @as(i32, rgb[0]) - @as(i32, c.r);
            const dg = @as(i32, rgb[1]) - @as(i32, c.g);
            const db = @as(i32, rgb[2]) - @as(i32, c.b);
            const dist: u32 = @intCast(dr * dr + dg * dg + db * db);

            if (dist < best_dist) {
                best_dist = dist;
                best_idx = i;
            }
        }

        return best_idx;
    }
};

/// Detectar soporte Sixel en el terminal
pub fn sixelSupported() bool {
    // Enviar DA1 query y parsear respuesta
    // Si respuesta contiene ";4;" o ";4c", soporta sixel

    // Por ahora, detectar por $TERM
    const term = std.os.getenv("TERM") orelse return false;

    return std.mem.indexOf(u8, term, "xterm") != null or
           std.mem.indexOf(u8, term, "mlterm") != null or
           std.mem.indexOf(u8, term, "mintty") != null or
           std.mem.indexOf(u8, term, "foot") != null;
}

/// Widget para mostrar imagen Sixel
pub const SixelImage = struct {
    data: []const u8,
    width: u32,
    height: u32,
    cached_sixel: ?[]const u8 = null,

    pub fn fromRgba(data: []const u8, width: u32, height: u32) SixelImage {
        return .{
            .data = data,
            .width = width,
            .height = height,
        };
    }

    pub fn render(self: *SixelImage, area: Rect, buf: *Buffer, allocator: std.mem.Allocator) void {
        if (!sixelSupported()) {
            // Fallback a bloques Unicode
            self.renderFallback(area, buf);
            return;
        }

        // Generar sixel data si no está cacheado
        if (self.cached_sixel == null) {
            var encoder = SixelEncoder.init(allocator);
            defer encoder.deinit();

            self.cached_sixel = encoder.encode(self.data, self.width, self.height) catch null;
        }

        if (self.cached_sixel) |sixel| {
            // Escribir secuencia sixel al buffer
            // (requiere soporte especial en Buffer para datos raw)
            buf.setRawSequence(area.x, area.y, sixel);
        }
    }

    fn renderFallback(self: *SixelImage, area: Rect, buf: *Buffer) void {
        // Renderizar usando half-block characters (▀▄█ )
        // 2 píxeles por caracter vertical

        for (0..@min(area.height * 2, self.height)) |y| {
            for (0..@min(area.width, self.width)) |x| {
                const top_idx = (y * 2 * self.width + x) * 4;
                const bot_idx = ((y * 2 + 1) * self.width + x) * 4;

                // Simplificado: usar ▀ con colores
                const top_color = Color.rgb(
                    self.data[top_idx],
                    self.data[top_idx + 1],
                    self.data[top_idx + 2],
                );

                buf.setCell(
                    area.x + @as(u16, @intCast(x)),
                    area.y + @as(u16, @intCast(y)),
                    Cell{
                        .symbol = .{ .short = .{ '▀', 0, 0, 0 } },
                        .style = Style{}.fg(top_color),
                    },
                );
            }
        }
    }
};

Tests

  • Palette generation
  • Sixel encoding
  • Run-length compression
  • Fallback rendering
  • Terminal detection

Fase 10: Errores tipo Elm

Objetivo

Mensajes de error hermosos y útiles, inspirados en Elm y Rust.

Archivo

  • src/errors.zig

Diseño de errores Elm-style

── CONSTRAINT ERROR ─────────────────────────── src/layout.zig:45

I found a layout constraint that doesn't make sense:

    45 │     .ratio = .{ .num = 3, .den = 0 },
                                       ^^^

The denominator of a ratio cannot be zero. This would cause
a division by zero when calculating the layout.

Hint: Did you mean to use a fixed length instead?

    .length = 3

── STYLE ERROR ──────────────────────────────── src/widgets/block.zig:12

I found conflicting style settings:

    12 │     .fg = Color.red,
    13 │     .foreground = Color.blue,
               ^^^^^^^^^^

You're setting the foreground color twice with different values.
Use either `fg` or `foreground`, but not both.

── TYPE ERROR ───────────────────────────────── examples/demo.zig:23

I was expecting a `Rect` but found a `Size`:

    23 │     widget.render(my_size, buffer);
                           ^^^^^^^

The `render` function needs the position AND size of the area,
but `Size` only has width and height.

Try converting it to a Rect:

    widget.render(Rect.fromSize(0, 0, my_size), buffer);

Implementación

// errors.zig
pub const DiagnosticLevel = enum {
    @"error",
    warning,
    hint,
    note,
};

pub const SourceLocation = struct {
    file: []const u8,
    line: u32,
    column: u32,
    source_line: ?[]const u8 = null,
};

pub const Diagnostic = struct {
    level: DiagnosticLevel,
    title: []const u8,
    message: []const u8,
    location: ?SourceLocation = null,
    highlights: []const Highlight = &.{},
    hints: []const []const u8 = &.{},
    notes: []const []const u8 = &.{},

    pub const Highlight = struct {
        start: u32,
        end: u32,
        label: ?[]const u8 = null,
    };
};

pub const DiagnosticFormatter = struct {
    allocator: std.mem.Allocator,
    colors: bool = true,
    max_width: u16 = 80,

    pub fn format(self: DiagnosticFormatter, diag: Diagnostic) ![]u8 {
        var out = std.ArrayListUnmanaged(u8){};
        errdefer out.deinit(self.allocator);

        const writer = out.writer(self.allocator);

        // Header con color
        try self.writeHeader(writer, diag);

        // Mensaje principal
        try writer.writeAll("\n");
        try self.writeWrapped(writer, diag.message, 0);
        try writer.writeAll("\n\n");

        // Código fuente con highlight
        if (diag.location) |loc| {
            try self.writeSourceSnippet(writer, loc, diag.highlights);
            try writer.writeAll("\n");
        }

        // Hints
        for (diag.hints) |hint| {
            try self.writeHint(writer, hint);
        }

        // Notes
        for (diag.notes) |note| {
            try self.writeNote(writer, note);
        }

        return out.toOwnedSlice(self.allocator);
    }

    fn writeHeader(self: DiagnosticFormatter, writer: anytype, diag: Diagnostic) !void {
        // ── ERROR ──────────────────────────── file.zig:12
        const color = if (self.colors) switch (diag.level) {
            .@"error" => "\x1b[31m",   // Red
            .warning => "\x1b[33m",    // Yellow
            .hint => "\x1b[36m",       // Cyan
            .note => "\x1b[34m",       // Blue
        } else "";
        const reset = if (self.colors) "\x1b[0m" else "";

        try writer.print("{s}── {s} ", .{ color, @tagName(diag.level) });

        // Línea decorativa
        var remaining = self.max_width - 20;
        if (diag.location) |loc| {
            remaining -= @min(remaining, loc.file.len + 10);
        }
        for (0..remaining) |_| try writer.writeAll("─");

        // Ubicación
        if (diag.location) |loc| {
            try writer.print(" {s}:{d}{s}", .{ loc.file, loc.line, reset });
        } else {
            try writer.print("{s}", .{reset});
        }
    }

    fn writeSourceSnippet(self: DiagnosticFormatter, writer: anytype, loc: SourceLocation, highlights: []const Diagnostic.Highlight) !void {
        const gutter_width = 5;
        const line_num_str = try std.fmt.allocPrint(self.allocator, "{d}", .{loc.line});
        defer self.allocator.free(line_num_str);

        // Padding para número de línea
        const padding = gutter_width - line_num_str.len - 1;
        for (0..padding) |_| try writer.writeAll(" ");

        // Número de línea
        if (self.colors) try writer.writeAll("\x1b[90m");
        try writer.print("{s} │ ", .{line_num_str});
        if (self.colors) try writer.writeAll("\x1b[0m");

        // Código fuente
        if (loc.source_line) |src| {
            try writer.writeAll(src);
        }
        try writer.writeAll("\n");

        // Underline highlights
        for (0..gutter_width) |_| try writer.writeAll(" ");
        try writer.writeAll("│ ");

        if (self.colors) try writer.writeAll("\x1b[31m");
        for (highlights) |h| {
            for (0..h.start) |_| try writer.writeAll(" ");
            for (h.start..h.end) |_| try writer.writeAll("^");
        }
        if (self.colors) try writer.writeAll("\x1b[0m");
    }

    fn writeHint(self: DiagnosticFormatter, writer: anytype, hint: []const u8) !void {
        if (self.colors) try writer.writeAll("\x1b[36m");
        try writer.writeAll("Hint: ");
        if (self.colors) try writer.writeAll("\x1b[0m");
        try self.writeWrapped(writer, hint, 6);
        try writer.writeAll("\n\n");
    }

    fn writeNote(self: DiagnosticFormatter, writer: anytype, note: []const u8) !void {
        if (self.colors) try writer.writeAll("\x1b[34m");
        try writer.writeAll("Note: ");
        if (self.colors) try writer.writeAll("\x1b[0m");
        try self.writeWrapped(writer, note, 6);
        try writer.writeAll("\n");
    }

    fn writeWrapped(self: DiagnosticFormatter, writer: anytype, text: []const u8, indent: u16) !void {
        var col: u16 = indent;
        var words = std.mem.tokenizeAny(u8, text, " \t\n");

        while (words.next()) |word| {
            if (col + word.len + 1 > self.max_width) {
                try writer.writeAll("\n");
                for (0..indent) |_| try writer.writeAll(" ");
                col = indent;
            }
            if (col > indent) {
                try writer.writeAll(" ");
                col += 1;
            }
            try writer.writeAll(word);
            col += @intCast(word.len);
        }
    }
};

// Errores predefinidos para zcatui
pub const ZcatuiError = union(enum) {
    constraint_zero_denominator: struct { line: u32, col: u32, file: []const u8 },
    invalid_color_format: struct { value: []const u8 },
    buffer_out_of_bounds: struct { x: u16, y: u16, width: u16, height: u16 },
    widget_not_rendered: struct { widget_type: []const u8 },
    // ... más errores

    pub fn toDiagnostic(self: ZcatuiError, allocator: std.mem.Allocator) !Diagnostic {
        return switch (self) {
            .constraint_zero_denominator => |e| .{
                .level = .@"error",
                .title = "CONSTRAINT ERROR",
                .message = "The denominator of a ratio constraint cannot be zero. This would cause a division by zero when calculating the layout.",
                .location = .{
                    .file = e.file,
                    .line = e.line,
                    .column = e.col,
                },
                .hints = &.{
                    "Did you mean to use a fixed length instead?",
                },
            },
            // ... más casos
        };
    }
};

Macro de error (comptime)

pub fn err(comptime fmt: []const u8, args: anytype) void {
    const diag = Diagnostic{
        .level = .@"error",
        .message = std.fmt.comptimePrint(fmt, args),
    };

    const formatter = DiagnosticFormatter{ .allocator = std.heap.page_allocator };
    const output = formatter.format(diag) catch return;
    defer std.heap.page_allocator.free(output);

    std.debug.print("{s}\n", .{output});
}

// Uso
if (denominator == 0) {
    errors.err("Ratio denominator cannot be zero in constraint at {s}:{d}", .{ @src().file, @src().line });
    return error.InvalidConstraint;
}

Tests

  • Formatting con colores
  • Wrapping de texto largo
  • Source snippet display
  • Multiple highlights

Fase 11: Debug Mode

Objetivo

Modo de depuración visual para inspeccionar buffers, eventos, layouts.

Archivos

  • src/debug.zig
  • src/widgets/debug_overlay.zig

Funcionalidades

// debug.zig
pub const DebugMode = struct {
    enabled: bool = false,
    show_layout_bounds: bool = true,
    show_focus_ring: bool = true,
    show_event_log: bool = true,
    show_render_stats: bool = true,
    show_buffer_grid: bool = false,
    event_log: std.BoundedArray(DebugEvent, 100) = .{},
    render_stats: RenderStats = .{},

    pub const DebugEvent = struct {
        timestamp: i64,
        event_type: []const u8,
        details: []const u8,
    };

    pub const RenderStats = struct {
        frame_count: u64 = 0,
        last_frame_time_ns: u64 = 0,
        cells_rendered: u64 = 0,
        cells_changed: u64 = 0,
        avg_frame_time_ns: u64 = 0,
    };

    /// Toggle debug mode
    pub fn toggle(self: *DebugMode) void {
        self.enabled = !self.enabled;
    }

    /// Log un evento
    pub fn logEvent(self: *DebugMode, event_type: []const u8, details: []const u8) void {
        if (!self.enabled) return;

        self.event_log.append(.{
            .timestamp = std.time.milliTimestamp(),
            .event_type = event_type,
            .details = details,
        }) catch {
            // Si está lleno, remover el más viejo
            _ = self.event_log.orderedRemove(0);
            self.event_log.append(.{
                .timestamp = std.time.milliTimestamp(),
                .event_type = event_type,
                .details = details,
            }) catch {};
        };
    }

    /// Marcar inicio de frame
    pub fn frameStart(self: *DebugMode) void {
        self.render_stats.frame_start = std.time.nanoTimestamp();
    }

    /// Marcar fin de frame
    pub fn frameEnd(self: *DebugMode, cells_rendered: u64, cells_changed: u64) void {
        const elapsed = std.time.nanoTimestamp() - self.render_stats.frame_start;
        self.render_stats.frame_count += 1;
        self.render_stats.last_frame_time_ns = @intCast(elapsed);
        self.render_stats.cells_rendered = cells_rendered;
        self.render_stats.cells_changed = cells_changed;

        // Running average
        self.render_stats.avg_frame_time_ns =
            (self.render_stats.avg_frame_time_ns * (self.render_stats.frame_count - 1) +
             self.render_stats.last_frame_time_ns) / self.render_stats.frame_count;
    }
};

/// Widget overlay de debug
pub const DebugOverlay = struct {
    debug: *DebugMode,
    position: Position = .top_right,

    pub const Position = enum { top_left, top_right, bottom_left, bottom_right };

    pub fn render(self: DebugOverlay, area: Rect, buf: *Buffer) void {
        if (!self.debug.enabled) return;

        // Calcular posición del overlay
        const overlay_width: u16 = 40;
        const overlay_height: u16 = @min(20, area.height);

        const overlay_area = switch (self.position) {
            .top_right => Rect{
                .x = area.x + area.width - overlay_width,
                .y = area.y,
                .width = overlay_width,
                .height = overlay_height,
            },
            // ... otros casos
        };

        // Fondo semi-transparente (usando color oscuro)
        for (overlay_area.y..overlay_area.y + overlay_area.height) |y| {
            for (overlay_area.x..overlay_area.x + overlay_area.width) |x| {
                buf.setCell(@intCast(x), @intCast(y), Cell{
                    .symbol = .{ .short = .{ ' ', 0, 0, 0 } },
                    .style = Style{}.bg(Color.rgb(20, 20, 30)),
                });
            }
        }

        // Título
        buf.setString(
            overlay_area.x + 1,
            overlay_area.y,
            "DEBUG",
            Style{}.fg(Color.yellow).bold(),
        );

        // Render stats
        if (self.debug.show_render_stats) {
            self.renderStats(buf, overlay_area);
        }

        // Event log
        if (self.debug.show_event_log) {
            self.renderEventLog(buf, overlay_area);
        }
    }

    fn renderStats(self: DebugOverlay, buf: *Buffer, area: Rect) void {
        const stats = self.debug.render_stats;
        var y = area.y + 2;

        // FPS
        const fps = if (stats.avg_frame_time_ns > 0)
            1_000_000_000 / stats.avg_frame_time_ns
        else
            0;

        var line_buf: [64]u8 = undefined;

        const fps_str = std.fmt.bufPrint(&line_buf, "FPS: {d}", .{fps}) catch "FPS: ?";
        buf.setString(area.x + 1, y, fps_str, Style{}.fg(Color.green));
        y += 1;

        const frame_str = std.fmt.bufPrint(&line_buf, "Frame: {d}", .{stats.frame_count}) catch "Frame: ?";
        buf.setString(area.x + 1, y, frame_str, Style{}.fg(Color.white));
        y += 1;

        const cells_str = std.fmt.bufPrint(&line_buf, "Cells: {d}/{d}", .{
            stats.cells_changed,
            stats.cells_rendered,
        }) catch "Cells: ?";
        buf.setString(area.x + 1, y, cells_str, Style{}.fg(Color.white));
    }

    fn renderEventLog(self: DebugOverlay, buf: *Buffer, area: Rect) void {
        var y = area.y + 6;
        const max_events = @min(self.debug.event_log.len, area.height - 8);

        buf.setString(area.x + 1, y, "Events:", Style{}.fg(Color.cyan));
        y += 1;

        // Mostrar eventos más recientes
        const start = if (self.debug.event_log.len > max_events)
            self.debug.event_log.len - max_events
        else
            0;

        for (self.debug.event_log.slice()[start..]) |event| {
            var line_buf: [64]u8 = undefined;
            const line = std.fmt.bufPrint(&line_buf, "{s}: {s}", .{
                event.event_type,
                event.details,
            }) catch continue;

            buf.setString(area.x + 2, y, line[0..@min(line.len, area.width - 3)], Style{}.fg(Color.white));
            y += 1;
        }
    }
};

/// Visualizador de layout bounds
pub fn renderLayoutBounds(area: Rect, buf: *Buffer, label: []const u8) void {
    // Dibujar borde punteado
    const style = Style{}.fg(Color.magenta);

    // Top
    for (area.x..area.x + area.width) |x| {
        if (x % 2 == 0) {
            buf.setCell(@intCast(x), area.y, Cell{
                .symbol = .{ .short = .{ '·', 0, 0, 0 } },
                .style = style,
            });
        }
    }

    // Bottom
    for (area.x..area.x + area.width) |x| {
        if (x % 2 == 0) {
            buf.setCell(@intCast(x), area.y + area.height - 1, Cell{
                .symbol = .{ .short = .{ '·', 0, 0, 0 } },
                .style = style,
            });
        }
    }

    // Label
    if (label.len > 0) {
        buf.setString(area.x, area.y, label, style.bold());
    }
}

Shortcut de activación

// F12 o Ctrl+Shift+D para toggle debug
if (event.key == .f12 or
    (event.key == .char and event.char == 'd' and event.ctrl and event.shift)) {
    debug_mode.toggle();
}

Tests

  • Event logging
  • Render stats calculation
  • Overlay positioning
  • Layout bounds visualization

Fase 12: Performance Profiling

Objetivo

Sistema de profiling para identificar cuellos de botella en el rendering.

Archivos

  • src/profiler.zig
  • src/widgets/profiler_view.zig

Implementación

// profiler.zig
pub const Profiler = struct {
    spans: std.ArrayListUnmanaged(Span),
    stack: std.BoundedArray(SpanId, 32),
    allocator: std.mem.Allocator,
    enabled: bool = false,

    pub const SpanId = u32;

    pub const Span = struct {
        name: []const u8,
        parent: ?SpanId,
        start_ns: i128,
        end_ns: i128 = 0,
        metadata: ?[]const u8 = null,

        pub fn durationNs(self: Span) u64 {
            return @intCast(self.end_ns - self.start_ns);
        }

        pub fn durationMs(self: Span) f64 {
            return @as(f64, @floatFromInt(self.durationNs())) / 1_000_000.0;
        }
    };

    pub fn init(allocator: std.mem.Allocator) Profiler {
        return .{
            .spans = .{},
            .stack = .{},
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *Profiler) void {
        self.spans.deinit(self.allocator);
    }

    /// Comenzar un span
    pub fn begin(self: *Profiler, name: []const u8) SpanId {
        if (!self.enabled) return 0;

        const parent = if (self.stack.len > 0)
            self.stack.get(self.stack.len - 1)
        else
            null;

        const id: SpanId = @intCast(self.spans.items.len);

        self.spans.append(self.allocator, .{
            .name = name,
            .parent = parent,
            .start_ns = std.time.nanoTimestamp(),
        }) catch return 0;

        self.stack.append(id) catch {};

        return id;
    }

    /// Terminar un span
    pub fn end(self: *Profiler, id: SpanId) void {
        if (!self.enabled) return;
        if (id >= self.spans.items.len) return;

        self.spans.items[id].end_ns = std.time.nanoTimestamp();
        _ = self.stack.pop();
    }

    /// Helper para scope automático
    pub fn scope(self: *Profiler, name: []const u8) ScopeGuard {
        return .{
            .profiler = self,
            .id = self.begin(name),
        };
    }

    pub const ScopeGuard = struct {
        profiler: *Profiler,
        id: SpanId,

        pub fn deinit(self: ScopeGuard) void {
            self.profiler.end(self.id);
        }
    };

    /// Comenzar nuevo frame (clear spans)
    pub fn newFrame(self: *Profiler) void {
        self.spans.clearRetainingCapacity();
        self.stack.len = 0;
    }

    /// Generar reporte
    pub fn generateReport(self: *const Profiler, allocator: std.mem.Allocator) !Report {
        var report = Report{
            .total_time_ns = 0,
            .span_stats = std.StringHashMap(SpanStats).init(allocator),
        };

        // Calcular estadísticas por nombre de span
        for (self.spans.items) |span| {
            const duration = span.durationNs();
            report.total_time_ns += duration;

            const entry = try report.span_stats.getOrPut(span.name);
            if (!entry.found_existing) {
                entry.value_ptr.* = .{};
            }

            entry.value_ptr.count += 1;
            entry.value_ptr.total_ns += duration;
            entry.value_ptr.min_ns = @min(entry.value_ptr.min_ns, duration);
            entry.value_ptr.max_ns = @max(entry.value_ptr.max_ns, duration);
        }

        return report;
    }

    pub const SpanStats = struct {
        count: u64 = 0,
        total_ns: u64 = 0,
        min_ns: u64 = std.math.maxInt(u64),
        max_ns: u64 = 0,

        pub fn avgNs(self: SpanStats) u64 {
            return if (self.count > 0) self.total_ns / self.count else 0;
        }

        pub fn avgMs(self: SpanStats) f64 {
            return @as(f64, @floatFromInt(self.avgNs())) / 1_000_000.0;
        }
    };

    pub const Report = struct {
        total_time_ns: u64,
        span_stats: std.StringHashMap(SpanStats),

        pub fn deinit(self: *Report) void {
            self.span_stats.deinit();
        }

        /// Formatear como texto
        pub fn format(self: *const Report, allocator: std.mem.Allocator) ![]u8 {
            var out = std.ArrayListUnmanaged(u8){};
            const writer = out.writer(allocator);

            try writer.writeAll("=== PROFILE REPORT ===\n\n");
            try writer.print("Total frame time: {d:.2} ms\n\n", .{
                @as(f64, @floatFromInt(self.total_time_ns)) / 1_000_000.0,
            });

            try writer.writeAll("Span                     Count   Total     Avg       Min       Max\n");
            try writer.writeAll("─────────────────────────────────────────────────────────────────\n");

            var iter = self.span_stats.iterator();
            while (iter.next()) |entry| {
                const stats = entry.value_ptr.*;
                try writer.print("{s:<24} {d:>5}   {d:>6.2}ms  {d:>6.2}ms  {d:>6.2}ms  {d:>6.2}ms\n", .{
                    entry.key_ptr.*,
                    stats.count,
                    @as(f64, @floatFromInt(stats.total_ns)) / 1_000_000.0,
                    stats.avgMs(),
                    @as(f64, @floatFromInt(stats.min_ns)) / 1_000_000.0,
                    @as(f64, @floatFromInt(stats.max_ns)) / 1_000_000.0,
                });
            }

            return out.toOwnedSlice(allocator);
        }
    };
};

/// Uso con defer
pub fn profiledRender(profiler: *Profiler, widget: anytype, area: Rect, buf: *Buffer) void {
    const guard = profiler.scope(@typeName(@TypeOf(widget)));
    defer guard.deinit();

    widget.render(area, buf);
}

Instrumentación automática (comptime)

/// Wrapper que añade profiling a cualquier widget
pub fn Profiled(comptime Widget: type) type {
    return struct {
        inner: Widget,
        profiler: *Profiler,

        pub fn render(self: @This(), area: Rect, buf: *Buffer) void {
            const guard = self.profiler.scope(@typeName(Widget));
            defer guard.deinit();

            self.inner.render(area, buf);
        }

        // Delegar otros métodos...
        pub usingnamespace if (@hasDecl(Widget, "handleEvent"))
            struct {
                pub fn handleEvent(self: *@This(), event: Event) bool {
                    return self.inner.handleEvent(event);
                }
            }
        else
            struct {};
    };
}

// Uso
var profiled_list = Profiled(List([]const u8)){
    .inner = my_list,
    .profiler = &profiler,
};
profiled_list.render(area, buf);

Flame graph output

/// Exportar a formato flame graph (para visualizar con flamegraph.pl)
pub fn exportFlameGraph(self: *const Profiler, allocator: std.mem.Allocator) ![]u8 {
    var out = std.ArrayListUnmanaged(u8){};
    const writer = out.writer(allocator);

    for (self.spans.items) |span| {
        // Construir stack path
        var path = std.ArrayListUnmanaged([]const u8){};
        defer path.deinit(allocator);

        var current: ?SpanId = @intCast(&span - self.spans.items.ptr);
        while (current) |id| {
            const s = self.spans.items[id];
            try path.insert(allocator, 0, s.name);
            current = s.parent;
        }

        // Formato: stack;path;names count
        for (path.items, 0..) |name, i| {
            if (i > 0) try writer.writeAll(";");
            try writer.writeAll(name);
        }
        try writer.print(" {d}\n", .{span.durationNs() / 1000}); // microseconds
    }

    return out.toOwnedSlice(allocator);
}

Tests

  • Span begin/end
  • Nested spans
  • Report generation
  • Flame graph export

Fase 13: Package Manager (build.zig.zon)

Objetivo

Hacer zcatui instalable via Zig package manager.

Archivos

  • build.zig.zon (nuevo)
  • Modificar build.zig

build.zig.zon

.{
    .name = "zcatui",
    .version = "2.2.0",
    .minimum_zig_version = "0.15.0",

    .dependencies = .{
        // Sin dependencias externas
    },

    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
        "LICENSE",
        "README.md",
    },
}

Modificaciones a build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // ============================================
    // MÓDULO PRINCIPAL (para uso como dependencia)
    // ============================================

    const zcatui_mod = b.addModule("zcatui", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });

    // ============================================
    // LIBRERÍA ESTÁTICA (opcional)
    // ============================================

    const lib = b.addStaticLibrary(.{
        .name = "zcatui",
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(lib);

    // ============================================
    // TESTS
    // ============================================

    const tests = b.addTest(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });

    const run_tests = b.addRunArtifact(tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_tests.step);

    // ============================================
    // EXAMPLES
    // ============================================

    const examples = [_][]const u8{
        "hello",
        "events_demo",
        "list_demo",
        "table_demo",
        "dashboard",
        "input_demo",
        "animation_demo",
        "clipboard_demo",
        "menu_demo",
        "form_demo",
        "panel_demo",
        // Nuevos v2.1
        "spinner_demo",
        "help_demo",
        "viewport_demo",
        "progress_demo",
        "markdown_demo",
        "dirtree_demo",
        "syntax_demo",
    };

    for (examples) |name| {
        const exe = b.addExecutable(.{
            .name = name,
            .root_source_file = b.path(b.fmt("examples/{s}.zig", .{name})),
            .target = target,
            .optimize = optimize,
        });
        exe.root_module.addImport("zcatui", zcatui_mod);

        const run_cmd = b.addRunArtifact(exe);
        const run_step = b.step(name, b.fmt("Run {s} example", .{name}));
        run_step.dependOn(&run_cmd.step);

        b.installArtifact(exe);
    }

    // ============================================
    // DOCUMENTACIÓN
    // ============================================

    const docs = b.addStaticLibrary(.{
        .name = "zcatui-docs",
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = .Debug,
    });

    const install_docs = b.addInstallDirectory(.{
        .source_dir = docs.getEmittedDocs(),
        .install_dir = .prefix,
        .install_subdir = "docs",
    });

    const docs_step = b.step("docs", "Generate documentation");
    docs_step.dependOn(&install_docs.step);
}

Uso desde otro proyecto

// En el proyecto del usuario: build.zig.zon
.{
    .name = "my-tui-app",
    .version = "0.1.0",
    .dependencies = .{
        .zcatui = .{
            .url = "https://git.reugenio.com/reugenio/zcatui/archive/v2.2.0.tar.gz",
            .hash = "1234567890abcdef...", // Se calcula automáticamente
        },
    },
}

// En build.zig
const zcatui_dep = b.dependency("zcatui", .{
    .target = target,
    .optimize = optimize,
});

exe.root_module.addImport("zcatui", zcatui_dep.module("zcatui"));

// En código
const zcatui = @import("zcatui");

pub fn main() !void {
    var term = try zcatui.Terminal.init(allocator);
    defer term.deinit();
    // ...
}

Tests

  • Build como módulo
  • Build como librería estática
  • Import desde proyecto externo

Resumen de Entregables

Fase Archivos Nuevos Modificados Tests
1 logo.zig - 4
2 7 examples build.zig -
3 drag.zig, resizable.zig panel.zig 8
4 shortcuts.zig - 6
5 async/loop.zig, io_uring.zig, epoll.zig terminal.zig 10
6 compose.zig - 5
7 - layout.zig 4
8 resize.zig terminal.zig 5
9 sixel.zig - 5
10 errors.zig - 4
11 debug.zig, debug_overlay.zig - 4
12 profiler.zig, profiler_view.zig - 4
13 build.zig.zon build.zig -

Total estimado:

  • ~20 archivos nuevos
  • ~10 archivos modificados
  • ~60 tests nuevos
  • ~5000-8000 líneas de código

Orden de Ejecución Recomendado

  1. Fase 7 (Ratio constraints) - Base para otros layouts
  2. Fase 1 (Logo widget) - Simple, prepara terreno
  3. Fase 4 (Shortcuts) - Necesario para fases 3 y 11
  4. Fase 6 (Widget composition) - Mejora DX para examples
  5. Fase 2 (Examples) - Demuestra funcionalidad existente
  6. Fase 8 (Resize handler) - Necesario para fase 3
  7. Fase 3 (Drag & drop) - Feature compleja
  8. Fase 10 (Errores Elm) - Mejora debugging
  9. Fase 11 (Debug mode) - Usa errores mejorados
  10. Fase 12 (Profiling) - Complementa debug
  11. Fase 9 (Sixel) - Feature independiente
  12. Fase 5 (Async) - Más compleja, puede ser opcional
  13. Fase 13 (Package) - Final, cuando todo esté estable

Notas Finales

  • Cada fase es independiente y puede commitearse por separado
  • Tests deben pasar antes de avanzar a siguiente fase
  • Documentar inline con /// doc comments
  • Mantener compatibilidad con API existente
  • Seguir patrones establecidos (builder, render, etc.)