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>
2636 lines
75 KiB
Markdown
2636 lines
75 KiB
Markdown
# 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
|
|
```zig
|
|
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
|
|
|
|
```zig
|
|
// 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.
|
|
}
|
|
};
|
|
```
|
|
|
|
```zig
|
|
// 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
|
|
```zig
|
|
// 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
|
|
```zig
|
|
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
|
|
|
|
```zig
|
|
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
|
|
|
|
```zig
|
|
// 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;
|
|
}
|
|
};
|
|
```
|
|
|
|
```zig
|
|
// 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);
|
|
// ...
|
|
}
|
|
};
|
|
```
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
// 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)
|
|
|
|
```zig
|
|
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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
// 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
|
|
|
|
```zig
|
|
// 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)
|
|
|
|
```zig
|
|
/// 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
|
|
|
|
```zig
|
|
/// 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
|
|
|
|
```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
|
|
|
|
```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
|
|
|
|
```zig
|
|
// 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.)
|