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>
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
examples/spinner_demo.zigexamples/help_demo.zigexamples/viewport_demo.zigexamples/progress_demo.zigexamples/markdown_demo.zigexamples/dirtree_demo.zigexamples/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 & dropsrc/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 principalsrc/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.zigsrc/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.zigsrc/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
- Fase 7 (Ratio constraints) - Base para otros layouts
- Fase 1 (Logo widget) - Simple, prepara terreno
- Fase 4 (Shortcuts) - Necesario para fases 3 y 11
- Fase 6 (Widget composition) - Mejora DX para examples
- Fase 2 (Examples) - Demuestra funcionalidad existente
- Fase 8 (Resize handler) - Necesario para fase 3
- Fase 3 (Drag & drop) - Feature compleja
- Fase 10 (Errores Elm) - Mejora debugging
- Fase 11 (Debug mode) - Usa errores mejorados
- Fase 12 (Profiling) - Complementa debug
- Fase 9 (Sixel) - Feature independiente
- Fase 5 (Async) - Más compleja, puede ser opcional
- 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.)