From 7abc87a4f5873ad52287cd608b6e4169351b76a7 Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 22:46:06 +0100 Subject: [PATCH] feat: zcatui v2.2 - Complete feature set with 13 new modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- build.zig | 171 +++ build.zig.zon | 16 + docs/PLAN_V2.2.md | 2636 ++++++++++++++++++++++++++++++++++++ examples/dirtree_demo.zig | 126 ++ examples/help_demo.zig | 134 ++ examples/markdown_demo.zig | 140 ++ examples/progress_demo.zig | 178 +++ examples/resize_demo.zig | 159 +++ examples/spinner_demo.zig | 151 +++ examples/splitter_demo.zig | 200 +++ examples/syntax_demo.zig | 176 +++ examples/viewport_demo.zig | 176 +++ src/async_loop.zig | 347 +++++ src/compose.zig | 611 +++++++++ src/debug.zig | 338 +++++ src/diagnostic.zig | 401 ++++++ src/drag.zig | 342 +++++ src/layout.zig | 160 ++- src/profile.zig | 311 +++++ src/resize.zig | 217 +++ src/root.zig | 72 + src/shortcuts.zig | 783 +++++++++++ src/sixel.zig | 316 +++++ src/terminal.zig | 96 +- src/widgets/dirtree.zig | 21 +- src/widgets/logo.zig | 678 ++++++++++ 26 files changed, 8943 insertions(+), 13 deletions(-) create mode 100644 build.zig.zon create mode 100644 docs/PLAN_V2.2.md create mode 100644 examples/dirtree_demo.zig create mode 100644 examples/help_demo.zig create mode 100644 examples/markdown_demo.zig create mode 100644 examples/progress_demo.zig create mode 100644 examples/resize_demo.zig create mode 100644 examples/spinner_demo.zig create mode 100644 examples/splitter_demo.zig create mode 100644 examples/syntax_demo.zig create mode 100644 examples/viewport_demo.zig create mode 100644 src/async_loop.zig create mode 100644 src/compose.zig create mode 100644 src/debug.zig create mode 100644 src/diagnostic.zig create mode 100644 src/drag.zig create mode 100644 src/profile.zig create mode 100644 src/resize.zig create mode 100644 src/shortcuts.zig create mode 100644 src/sixel.zig create mode 100644 src/widgets/logo.zig diff --git a/build.zig b/build.zig index 9766e07..a6645ad 100644 --- a/build.zig +++ b/build.zig @@ -232,4 +232,175 @@ pub fn build(b: *std.Build) void { run_panel_demo.step.dependOn(b.getInstallStep()); const panel_demo_step = b.step("panel-demo", "Run panel demo"); panel_demo_step.dependOn(&run_panel_demo.step); + + // Ejemplo: spinner_demo + const spinner_demo_exe = b.addExecutable(.{ + .name = "spinner-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/spinner_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(spinner_demo_exe); + + const run_spinner_demo = b.addRunArtifact(spinner_demo_exe); + run_spinner_demo.step.dependOn(b.getInstallStep()); + const spinner_demo_step = b.step("spinner-demo", "Run spinner demo"); + spinner_demo_step.dependOn(&run_spinner_demo.step); + + // Ejemplo: syntax_demo + const syntax_demo_exe = b.addExecutable(.{ + .name = "syntax-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/syntax_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(syntax_demo_exe); + + const run_syntax_demo = b.addRunArtifact(syntax_demo_exe); + run_syntax_demo.step.dependOn(b.getInstallStep()); + const syntax_demo_step = b.step("syntax-demo", "Run syntax highlighting demo"); + syntax_demo_step.dependOn(&run_syntax_demo.step); + + // Ejemplo: markdown_demo + const markdown_demo_exe = b.addExecutable(.{ + .name = "markdown-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/markdown_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(markdown_demo_exe); + + const run_markdown_demo = b.addRunArtifact(markdown_demo_exe); + run_markdown_demo.step.dependOn(b.getInstallStep()); + const markdown_demo_step = b.step("markdown-demo", "Run markdown viewer demo"); + markdown_demo_step.dependOn(&run_markdown_demo.step); + + // Ejemplo: help_demo + const help_demo_exe = b.addExecutable(.{ + .name = "help-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/help_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(help_demo_exe); + + const run_help_demo = b.addRunArtifact(help_demo_exe); + run_help_demo.step.dependOn(b.getInstallStep()); + const help_demo_step = b.step("help-demo", "Run help keybindings demo"); + help_demo_step.dependOn(&run_help_demo.step); + + // Ejemplo: viewport_demo + const viewport_demo_exe = b.addExecutable(.{ + .name = "viewport-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/viewport_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(viewport_demo_exe); + + const run_viewport_demo = b.addRunArtifact(viewport_demo_exe); + run_viewport_demo.step.dependOn(b.getInstallStep()); + const viewport_demo_step = b.step("viewport-demo", "Run scrollable viewport demo"); + viewport_demo_step.dependOn(&run_viewport_demo.step); + + // Ejemplo: progress_demo + const progress_demo_exe = b.addExecutable(.{ + .name = "progress-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/progress_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(progress_demo_exe); + + const run_progress_demo = b.addRunArtifact(progress_demo_exe); + run_progress_demo.step.dependOn(b.getInstallStep()); + const progress_demo_step = b.step("progress-demo", "Run progress bars demo"); + progress_demo_step.dependOn(&run_progress_demo.step); + + // Ejemplo: dirtree_demo + const dirtree_demo_exe = b.addExecutable(.{ + .name = "dirtree-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/dirtree_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(dirtree_demo_exe); + + const run_dirtree_demo = b.addRunArtifact(dirtree_demo_exe); + run_dirtree_demo.step.dependOn(b.getInstallStep()); + const dirtree_demo_step = b.step("dirtree-demo", "Run directory tree demo"); + dirtree_demo_step.dependOn(&run_dirtree_demo.step); + + // Ejemplo: resize_demo + const resize_demo_exe = b.addExecutable(.{ + .name = "resize-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/resize_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(resize_demo_exe); + + const run_resize_demo = b.addRunArtifact(resize_demo_exe); + run_resize_demo.step.dependOn(b.getInstallStep()); + const resize_demo_step = b.step("resize-demo", "Run resize handling demo"); + resize_demo_step.dependOn(&run_resize_demo.step); + + // Ejemplo: splitter_demo + const splitter_demo_exe = b.addExecutable(.{ + .name = "splitter-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/splitter_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(splitter_demo_exe); + + const run_splitter_demo = b.addRunArtifact(splitter_demo_exe); + run_splitter_demo.step.dependOn(b.getInstallStep()); + const splitter_demo_step = b.step("splitter-demo", "Run resizable splitter demo"); + splitter_demo_step.dependOn(&run_splitter_demo.step); } diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..2a99b86 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .zcatui, + .fingerprint = 0x73ad863c554280f3, + .version = "2.2.0", + .minimum_zig_version = "0.14.0", + + .dependencies = .{}, + + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "README.md", + "LICENSE", + }, +} diff --git a/docs/PLAN_V2.2.md b/docs/PLAN_V2.2.md new file mode 100644 index 0000000..448d285 --- /dev/null +++ b/docs/PLAN_V2.2.md @@ -0,0 +1,2636 @@ +# 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 ESC \ + +Donde es: +- " Pan ; Pad ; Ph ; Pv - Atributos raster +- # Pc ; Pu ; Px ; Py ; Pz - Definir color +- ! Pn - 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.) diff --git a/examples/dirtree_demo.zig b/examples/dirtree_demo.zig new file mode 100644 index 0000000..cf046dc --- /dev/null +++ b/examples/dirtree_demo.zig @@ -0,0 +1,126 @@ +//! Directory Tree Demo - File browser widget +//! +//! Run with: zig build dirtree-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const DirectoryTree = zcatui.widgets.DirectoryTree; +// TreeSymbols is in dirtree_mod, not exported directly +const DirTreeSymbols = zcatui.widgets.dirtree_mod.TreeSymbols; + +/// State for the demo +const State = struct { + tree: *DirectoryTree, + use_ascii: bool = false, + running: bool = true, + path: []const u8, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + // Get current directory + const start_path = std.fs.cwd().realpathAlloc(allocator, ".") catch "/tmp"; + defer allocator.free(start_path); + + // Create directory tree + var tree = DirectoryTree.init(allocator, start_path) catch |err| { + std.debug.print("Failed to open directory: {}\n", .{err}); + return; + }; + defer tree.deinit(); + + var state = State{ + .tree = &tree, + .path = start_path, + }; + + while (state.running) { + try term.drawWithContext(&state, render); + + if (try term.pollEvent(100)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + if (c == 'j') state.tree.moveDown(); + if (c == 'k') state.tree.moveUp(); + if (c == 'l' or c == 'o') state.tree.toggleExpand() catch {}; + if (c == 'h') state.tree.collapse() catch {}; + if (c == '.') state.tree.toggleHidden() catch {}; + if (c == 'i') state.use_ascii = !state.use_ascii; + if (c == 'g') state.tree.goToTop(); + if (c == 'G') state.tree.goToBottom(); + }, + .up => state.tree.moveUp(), + .down => state.tree.moveDown(), + .left => state.tree.collapse() catch {}, + .right => state.tree.toggleExpand() catch {}, + .enter => state.tree.toggleExpand() catch {}, + .home => state.tree.goToTop(), + .end => state.tree.goToBottom(), + .esc => state.running = false, + else => {}, + } + }, + else => {}, + } + } + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Main border + const block = Block.init() + .title(" Directory Tree ") + .setBorders(Borders.all) + .borderStyle((Style{}).fg(Color.cyan)); + block.render(area, buf); + + // Path display + var path_buf: [256]u8 = undefined; + const current_path = state.tree.getSelectedPath() orelse state.path; + const path_display = std.fmt.bufPrint(&path_buf, " {s} ", .{current_path}) catch "..."; + _ = buf.setString(2, 1, path_display, (Style{}).fg(Color.yellow)); + + // Tree view + const tree_area = Rect.init(1, 2, area.width -| 2, area.height -| 4); + + // Configure symbols based on preference + if (state.use_ascii) { + var configured = state.tree.setSymbols(DirTreeSymbols.ascii); + configured.render(tree_area, buf); + } else { + state.tree.render(tree_area, buf); + } + + // File info panel + if (state.tree.getSelected()) |node| { + var info_buf: [128]u8 = undefined; + const info = std.fmt.bufPrint(&info_buf, "Selected: {s}", .{node.name}) catch "..."; + _ = buf.setString(2, area.height -| 2, info, (Style{}).fg(Color.white)); + } + + // Footer + const footer = "j/k nav | l expand | h collapse | . hidden | i ascii | g/G top/bottom | q quit"; + _ = buf.setString( + 2, + area.height -| 1, + footer, + (Style{}).fg(Color.rgb(100, 100, 100)), + ); +} diff --git a/examples/help_demo.zig b/examples/help_demo.zig new file mode 100644 index 0000000..628628b --- /dev/null +++ b/examples/help_demo.zig @@ -0,0 +1,134 @@ +//! Help Widget Demo - Shows keybinding help in different modes +//! +//! Run with: zig build help-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const Help = zcatui.widgets.Help; +const KeyBinding = zcatui.widgets.KeyBinding; +const HelpMode = zcatui.widgets.HelpMode; + +const bindings = [_]KeyBinding{ + .{ .key = "q", .description = "Quit", .group = "General" }, + .{ .key = "?", .description = "Toggle help", .group = "General" }, + .{ .key = "up/k", .description = "Move up", .group = "Navigation" }, + .{ .key = "down/j", .description = "Move down", .group = "Navigation" }, + .{ .key = "left/h", .description = "Move left", .group = "Navigation" }, + .{ .key = "right/l", .description = "Move right", .group = "Navigation" }, + .{ .key = "PgUp", .description = "Page up", .group = "Navigation" }, + .{ .key = "PgDn", .description = "Page down", .group = "Navigation" }, + .{ .key = "Home", .description = "Go to start", .group = "Navigation" }, + .{ .key = "End", .description = "Go to end", .group = "Navigation" }, + .{ .key = "Enter", .description = "Select item", .group = "Actions" }, + .{ .key = "Space", .description = "Toggle selection", .group = "Actions" }, + .{ .key = "Tab", .description = "Next panel", .group = "Actions" }, + .{ .key = "Ctrl+C", .description = "Copy", .group = "Edit" }, + .{ .key = "Ctrl+V", .description = "Paste", .group = "Edit" }, + .{ .key = "Ctrl+Z", .description = "Undo", .group = "Edit" }, +}; + +const modes = [_]HelpMode{ .single_line, .compact, .multi_line, .full }; +const mode_names = [_][]const u8{ "Single Line", "Compact", "Multi Line", "Full" }; + +/// State for the demo +const State = struct { + current_mode: usize = 0, + running: bool = true, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + var state = State{}; + + while (state.running) { + try term.drawWithContext(&state, render); + + if (try term.pollEvent(100)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + if (c >= '1' and c <= '4') { + state.current_mode = c - '1'; + } + }, + .left => { + if (state.current_mode > 0) state.current_mode -= 1; + }, + .right => { + if (state.current_mode < modes.len - 1) state.current_mode += 1; + }, + .esc => state.running = false, + else => {}, + } + }, + else => {}, + } + } + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Main border + const block = Block.init() + .title(" Help Widget Demo ") + .setBorders(Borders.all) + .borderStyle((Style{}).fg(Color.cyan)); + block.render(area, buf); + + // Mode tabs + var x: u16 = 2; + for (mode_names, 0..) |name, i| { + const style = if (i == state.current_mode) + (Style{}).fg(Color.black).bg(Color.cyan).bold() + else + (Style{}).fg(Color.white); + + _ = buf.setString(x, 1, " ", style); + _ = buf.setString(x + 1, 1, name, style); + _ = buf.setString(x + 1 + @as(u16, @intCast(name.len)), 1, " ", style); + x += @as(u16, @intCast(name.len)) + 3; + } + + // Help content area + const content_area = Rect.init(1, 3, area.width -| 2, area.height -| 5); + + const help_block = Block.init() + .setBorders(Borders.all) + .title(" Keybindings ") + .borderStyle((Style{}).fg(Color.rgb(80, 80, 80))); + help_block.render(content_area, buf); + + const inner = Rect.init(2, 4, area.width -| 4, area.height -| 7); + + // Help widget + const key_style = (Style{}).fg(Color.yellow).bold(); + const help = Help.init(&bindings) + .setMode(modes[state.current_mode]) + .setKeyStyle(key_style); + + help.render(inner, buf); + + // Footer + _ = buf.setString( + 2, + area.height -| 1, + "Press 1-4 or left/right to change mode, q to quit", + (Style{}).fg(Color.rgb(100, 100, 100)), + ); +} diff --git a/examples/markdown_demo.zig b/examples/markdown_demo.zig new file mode 100644 index 0000000..bf68214 --- /dev/null +++ b/examples/markdown_demo.zig @@ -0,0 +1,140 @@ +//! Markdown Viewer Demo +//! +//! Run with: zig build markdown-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const Markdown = zcatui.widgets.Markdown; + +const sample_markdown = + \\# Welcome to zcatui + \\ + \\A **TUI library** for Zig, inspired by _ratatui_. + \\ + \\## Features + \\ + \\- 35+ widgets + \\- Event handling (keyboard, mouse) + \\- Animations with easing + \\- Clipboard support (OSC 52) + \\- Syntax highlighting + \\ + \\## Code Example + \\ + \\```zig + \\const zcatui = @import("zcatui"); + \\ + \\pub fn main() !void { + \\ var term = try zcatui.Terminal.init(allocator); + \\ defer term.deinit(); + \\ // ... + \\} + \\``` + \\ + \\## Installation + \\ + \\Add to your `build.zig.zon`: + \\ + \\```zon + \\.dependencies = .{ + \\ .zcatui = .{ .url = "..." }, + \\}, + \\``` + \\ + \\> **Note**: This is a blockquote with important information + \\> that spans multiple lines. + \\ + \\### Links + \\ + \\Check out the [documentation](https://git.reugenio.com/reugenio/zcatui). + \\ + \\--- + \\ + \\*Thank you for using zcatui!* +; + +/// State for the demo +const State = struct { + scroll_offset: u16 = 0, + running: bool = true, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + var state = State{}; + + while (state.running) { + try term.drawWithContext(&state, render); + + if (try term.pollEvent(100)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + if (c == 'j') state.scroll_offset +|= 1; + if (c == 'k' and state.scroll_offset > 0) state.scroll_offset -= 1; + }, + .up => { + if (state.scroll_offset > 0) state.scroll_offset -= 1; + }, + .down => { + state.scroll_offset +|= 1; + }, + .page_up => { + state.scroll_offset -|= 10; + }, + .page_down => { + state.scroll_offset +|= 10; + }, + .home => state.scroll_offset = 0, + .esc => state.running = false, + else => {}, + } + }, + else => {}, + } + } + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Border + const block = Block.init() + .title(" Markdown Viewer ") + .setBorders(Borders.all) + .borderStyle((Style{}).fg(Color.cyan)); + block.render(area, buf); + + // Markdown content + const inner = Rect.init(2, 2, area.width -| 4, area.height -| 5); + + const md = Markdown.init(sample_markdown) + .setScroll(state.scroll_offset); + + md.render(inner, buf); + + // Footer with scroll info + var footer_buf: [64]u8 = undefined; + const footer = std.fmt.bufPrint(&footer_buf, "Line {d} | j/k or up/down to scroll, PgUp/PgDn, q to quit", .{state.scroll_offset}) catch "..."; + _ = buf.setString( + 2, + area.height -| 2, + footer, + (Style{}).fg(Color.rgb(100, 100, 100)), + ); +} diff --git a/examples/progress_demo.zig b/examples/progress_demo.zig new file mode 100644 index 0000000..5195048 --- /dev/null +++ b/examples/progress_demo.zig @@ -0,0 +1,178 @@ +//! Progress Bar Demo - Shows progress with ETA +//! +//! Run with: zig build progress-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; + +const Task = struct { + name: []const u8, + current: u64, + total: u64, + speed: f64, // Items per frame + color: Color, +}; + +/// State for the demo +const State = struct { + tasks: [4]Task = .{ + .{ .name = "Downloading...", .current = 0, .total = 100, .speed = 0.8, .color = Color.blue }, + .{ .name = "Compiling...", .current = 0, .total = 50, .speed = 0.3, .color = Color.green }, + .{ .name = "Installing...", .current = 0, .total = 200, .speed = 1.2, .color = Color.yellow }, + .{ .name = "Verifying...", .current = 0, .total = 80, .speed = 0.5, .color = Color.magenta }, + }, + frame: u64 = 0, + paused: bool = false, + running: bool = true, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + var state = State{}; + + while (state.running) { + try term.drawWithContext(&state, render); + + if (try term.pollEvent(50)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + if (c == 'r') { + // Reset all tasks + for (&state.tasks) |*task| { + task.current = 0; + } + state.frame = 0; + } + if (c == 'p' or c == ' ') state.paused = !state.paused; + }, + .esc => state.running = false, + else => {}, + } + }, + else => {}, + } + } + + // Update progress (simulate work) + if (!state.paused) { + for (&state.tasks) |*task| { + if (task.current < task.total) { + // Add randomness to speed + const variation = @as(f64, @floatFromInt(state.frame % 10)) / 20.0; + const effective_speed = task.speed * (0.8 + variation); + const increment: u64 = @intFromFloat(effective_speed); + task.current = @min(task.current + @max(increment, 1), task.total); + } + } + } + + state.frame +%= 1; + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Main border + const status_text = if (state.paused) " Progress Demo [PAUSED] " else " Progress Demo "; + const block = Block.init() + .title(status_text) + .setBorders(Borders.all) + .borderStyle((Style{}).fg(Color.cyan)); + block.render(area, buf); + + // Calculate total progress + var total_current: u64 = 0; + var total_total: u64 = 0; + for (state.tasks) |task| { + total_current += task.current; + total_total += task.total; + } + + // Overall progress at top + const overall_y: u16 = 2; + _ = buf.setString(2, overall_y, "Overall Progress:", (Style{}).fg(Color.white).bold()); + + const overall_pct = if (total_total > 0) total_current * 100 / total_total else 0; + const overall_bar_width = area.width -| 6; + const overall_filled = @as(u16, @intCast(overall_bar_width * overall_pct / 100)); + + // Draw overall progress bar + var i: u16 = 0; + while (i < overall_bar_width) : (i += 1) { + const char: []const u8 = if (i < overall_filled) "=" else "-"; + const col = if (i < overall_filled) Color.cyan else Color.rgb(60, 60, 60); + _ = buf.setString(2 + i, overall_y + 1, char, (Style{}).fg(col)); + } + + // Overall percentage + var pct_buf: [16]u8 = undefined; + const pct_str = std.fmt.bufPrint(&pct_buf, " {d}%", .{overall_pct}) catch "?%"; + _ = buf.setString(2 + overall_bar_width -| 5, overall_y + 1, pct_str, (Style{}).fg(Color.white).bold()); + + // Individual task progress + var y: u16 = overall_y + 4; + for (state.tasks) |task| { + if (y >= area.height -| 3) break; + + // Task name + _ = buf.setString(2, y, task.name, (Style{}).fg(task.color)); + + // Progress bar + const bar_width = area.width -| 30; + const pct = if (task.total > 0) task.current * 100 / task.total else 0; + const filled = @as(u16, @intCast(bar_width * pct / 100)); + + var j: u16 = 0; + while (j < bar_width) : (j += 1) { + const char: []const u8 = if (j < filled) "=" else "-"; + const col = if (j < filled) task.color else Color.rgb(60, 60, 60); + _ = buf.setString(18 + j, y, char, (Style{}).fg(col)); + } + + // Stats + var stats_buf: [32]u8 = undefined; + const stats = std.fmt.bufPrint(&stats_buf, "{d}/{d} ({d}%)", .{ + task.current, + task.total, + pct, + }) catch "..."; + _ = buf.setString(area.width -| 18, y, stats, (Style{}).fg(Color.white)); + + y += 2; + } + + // Completion message + if (total_current >= total_total) { + const msg = "All tasks completed!"; + const msg_x = (area.width -| @as(u16, @intCast(msg.len))) / 2; + _ = buf.setString(msg_x, y + 1, msg, (Style{}).fg(Color.green).bold()); + } + + // Footer + const help = if (state.paused) + "SPACE/p resume | r restart | q quit" + else + "SPACE/p pause | r restart | q quit"; + _ = buf.setString( + 2, + area.height -| 1, + help, + (Style{}).fg(Color.rgb(100, 100, 100)), + ); +} diff --git a/examples/resize_demo.zig b/examples/resize_demo.zig new file mode 100644 index 0000000..aa8e8f8 --- /dev/null +++ b/examples/resize_demo.zig @@ -0,0 +1,159 @@ +//! Terminal Resize Demo +//! +//! Demonstrates automatic terminal resize handling. +//! Try resizing your terminal window to see the app adapt. +//! +//! Run with: zig build resize-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; + +/// State for the demo +const State = struct { + resize_count: u32 = 0, + last_width: u16 = 0, + last_height: u16 = 0, + running: bool = true, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + // Enable automatic resize detection + term.enableAutoResize(); + + // Get initial size + const initial_size = term.getSize(); + var state = State{ + .last_width = initial_size.width, + .last_height = initial_size.height, + }; + + while (state.running) { + // Check if size changed + const current_area = term.area(); + if (current_area.width != state.last_width or current_area.height != state.last_height) { + state.resize_count += 1; + state.last_width = current_area.width; + state.last_height = current_area.height; + } + + try term.drawWithContext(&state, render); + + if (try term.pollEvent(100)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + }, + .esc => state.running = false, + else => {}, + } + }, + else => {}, + } + } + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Main border + const block = Block.init() + .title(" Resize Demo ") + .setBorders(Borders.all) + .borderStyle((Style{}).fg(Color.cyan)); + block.render(area, buf); + + // Content + const content_start_y: u16 = 3; + const content_x: u16 = 4; + + // Title + _ = buf.setString( + content_x, + content_start_y, + "Resize your terminal to see this demo adapt!", + (Style{}).fg(Color.yellow).bold(), + ); + + // Current size + var size_buf: [64]u8 = undefined; + const size_str = std.fmt.bufPrint(&size_buf, "Current size: {d} x {d}", .{ + area.width, + area.height, + }) catch "?"; + _ = buf.setString(content_x, content_start_y + 2, size_str, (Style{}).fg(Color.white)); + + // Resize count + var count_buf: [64]u8 = undefined; + const count_str = std.fmt.bufPrint(&count_buf, "Resize events: {d}", .{state.resize_count}) catch "?"; + _ = buf.setString(content_x, content_start_y + 3, count_str, (Style{}).fg(Color.white)); + + // Draw a visual indicator based on size + const indicator_y = content_start_y + 6; + _ = buf.setString(content_x, indicator_y, "Size indicator:", (Style{}).fg(Color.magenta)); + + // Draw a bar that scales with width + const bar_width = @min(area.width -| 10, 50); + var i: u16 = 0; + while (i < bar_width) : (i += 1) { + const progress = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(bar_width)); + const color = if (progress < 0.33) + Color.red + else if (progress < 0.66) + Color.yellow + else + Color.green; + _ = buf.setString(content_x + i, indicator_y + 1, "=", (Style{}).fg(color)); + } + + // Draw a visual box that scales with size + if (area.height > 15 and area.width > 30) { + const box_y = indicator_y + 4; + const box_width = @min(area.width -| 10, 40); + const box_height = @min(area.height -| box_y -| 3, 10); + + // Draw box + var y: u16 = 0; + while (y < box_height) : (y += 1) { + var x: u16 = 0; + while (x < box_width) : (x += 1) { + const is_border = y == 0 or y == box_height - 1 or x == 0 or x == box_width - 1; + const char: []const u8 = if (is_border) "#" else " "; + const style = if (is_border) + (Style{}).fg(Color.blue) + else + Style{}; + _ = buf.setString(content_x + x, box_y + y, char, style); + } + } + + // Label inside box + var box_info: [32]u8 = undefined; + const box_str = std.fmt.bufPrint(&box_info, "{d}x{d}", .{ box_width, box_height }) catch "?"; + const label_x = content_x + (box_width / 2) - @as(u16, @intCast(box_str.len / 2)); + _ = buf.setString(label_x, box_y + box_height / 2, box_str, (Style{}).fg(Color.cyan)); + } + + // Footer + _ = buf.setString( + 2, + area.height -| 1, + "Press q to quit | Resize terminal to test", + (Style{}).fg(Color.rgb(100, 100, 100)), + ); +} diff --git a/examples/spinner_demo.zig b/examples/spinner_demo.zig new file mode 100644 index 0000000..6a2e616 --- /dev/null +++ b/examples/spinner_demo.zig @@ -0,0 +1,151 @@ +//! Spinner Demo - Shows all spinner styles +//! +//! Run with: zig build spinner-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const Spinner = zcatui.widgets.Spinner; +const SpinnerStyle = zcatui.widgets.SpinnerStyle; + +const styles = [_]SpinnerStyle{ + .dots, + .dots_braille, + .line, + .arrows, + .box_corners, + .circle, + .blocks, +}; + +const style_names = [_][]const u8{ + "Dots", + "Braille", + "Line", + "Arrows", + "Box", + "Circle", + "Blocks", +}; + +const messages = [_][]const u8{ + "Loading...", + "Processing...", + "Compiling...", + "Connecting...", + "Syncing...", + "Waiting...", + "Downloading...", +}; + +/// State for the demo +const State = struct { + frame: u64 = 0, + selected_style: usize = 0, + running: bool = true, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + var state = State{}; + + // Main loop + while (state.running) { + try term.drawWithContext(&state, render); + + // Poll events (non-blocking) + if (try term.pollEvent(50)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + // Number keys 1-7 to select style + if (c >= '1' and c <= '7') { + state.selected_style = c - '1'; + } + }, + .up => { + if (state.selected_style > 0) state.selected_style -= 1; + }, + .down => { + if (state.selected_style < styles.len - 1) state.selected_style += 1; + }, + .esc => state.running = false, + else => {}, + } + }, + else => {}, + } + } + + state.frame +%= 1; + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Draw border + const block = Block.init() + .title(" Spinner Styles ") + .setBorders(Borders.all) + .borderStyle((Style{}).fg(Color.cyan)); + block.render(area, buf); + + const inner = Rect.init(2, 2, area.width -| 4, area.height -| 4); + + // Draw all spinners + var y: u16 = 0; + for (styles, 0..) |style, i| { + if (y >= inner.height -| 2) break; + + const is_selected = i == state.selected_style; + const row_style = if (is_selected) + (Style{}).fg(Color.yellow).bold() + else + Style{}; + + // Style name + var name_buf: [32]u8 = undefined; + const name = std.fmt.bufPrint(&name_buf, "{s:<10}", .{style_names[i]}) catch "???"; + _ = buf.setString(inner.x, inner.y + y, name, row_style); + + // Spinner + var spinner = Spinner.init(style) + .setLabel(messages[i]); + + // Tick based on frame + var tick_count: u64 = 0; + while (tick_count < state.frame) : (tick_count += 1) { + spinner.tick(); + } + + spinner.render( + Rect.init(inner.x + 12, inner.y + y, inner.width -| 12, 1), + buf, + ); + + y += 2; + } + + // Instructions at bottom + const help_y = area.height -| 2; + _ = buf.setString( + 2, + help_y, + "Press 1-7 to select style, up/down to navigate, q to quit", + (Style{}).fg(Color.rgb(100, 100, 100)), + ); +} diff --git a/examples/splitter_demo.zig b/examples/splitter_demo.zig new file mode 100644 index 0000000..b5c53fa --- /dev/null +++ b/examples/splitter_demo.zig @@ -0,0 +1,200 @@ +//! Splitter Demo - Resizable panels with mouse drag +//! +//! Demonstrates mouse drag to resize panels. +//! Run with: zig build splitter-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const DragState = zcatui.DragState; +const DragType = zcatui.DragType; +const Splitter = zcatui.Splitter; + +/// State for the demo +const State = struct { + // Horizontal splitter (splits left/right) + h_splitter: Splitter = Splitter.horizontal(30).setMinSizes(10, 20), + // Vertical splitter for right side (splits top/bottom) + v_splitter: Splitter = Splitter.vertical(50).setMinSizes(5, 5), + // Drag state + drag_state: DragState = .{}, + // Which splitter is being dragged + active_splitter: ActiveSplitter = .none, + // Current area for hit testing + current_area: Rect = Rect.init(0, 0, 80, 24), + running: bool = true, +}; + +const ActiveSplitter = enum { none, horizontal, vertical }; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + // Enable mouse capture for drag support + try term.enableMouseCapture(); + term.enableAutoResize(); + + var state = State{}; + + while (state.running) { + try term.drawWithContext(&state, render); + + if (try term.pollEvent(50)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + // Keyboard shortcuts to adjust splitters + if (c == 'h' or c == 'H') { + const delta: i32 = if (c == 'H') -5 else 5; + state.h_splitter.adjustPosition(state.current_area, delta); + } + if (c == 'v' or c == 'V') { + const parts = state.h_splitter.split(state.current_area); + const delta: i32 = if (c == 'V') -5 else 5; + state.v_splitter.adjustPosition(parts.second, delta); + } + }, + .esc => state.running = false, + else => {}, + } + }, + .mouse => |mouse| { + handleMouse(&state, mouse); + }, + else => {}, + } + } + } +} + +fn handleMouse(state: *State, mouse: zcatui.event.MouseEvent) void { + switch (mouse.kind) { + .down => { + if (mouse.button == .left) { + // Check if on horizontal splitter + if (state.h_splitter.isOnHandle(state.current_area, mouse.column, mouse.row)) { + state.drag_state.start(.horizontal_resize, mouse.column, mouse.row); + state.active_splitter = .horizontal; + } else { + // Check if on vertical splitter (in second panel) + const parts = state.h_splitter.split(state.current_area); + if (state.v_splitter.isOnHandle(parts.second, mouse.column, mouse.row)) { + state.drag_state.start(.vertical_resize, mouse.column, mouse.row); + state.active_splitter = .vertical; + } + } + } + }, + .drag => { + if (state.drag_state.isDragging()) { + const old_x = state.drag_state.current_x; + const old_y = state.drag_state.current_y; + state.drag_state.update(mouse.column, mouse.row); + + // Apply the delta to the active splitter + switch (state.active_splitter) { + .horizontal => { + const delta = @as(i32, mouse.column) - @as(i32, old_x); + if (delta != 0) { + state.h_splitter.adjustPosition(state.current_area, delta); + } + }, + .vertical => { + const parts = state.h_splitter.split(state.current_area); + const delta = @as(i32, mouse.row) - @as(i32, old_y); + if (delta != 0) { + state.v_splitter.adjustPosition(parts.second, delta); + } + }, + .none => {}, + } + } + }, + .up => { + state.drag_state.end(); + state.active_splitter = .none; + }, + else => {}, + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Store area for mouse hit testing + state.current_area = area; + + // Get the split areas + const h_parts = state.h_splitter.split(area); + const v_parts = state.v_splitter.split(h_parts.second); + + // Draw left panel + drawPanel(buf, h_parts.first, " Left Panel ", Color.blue, "This is the left panel.\n\nDrag the vertical bar to resize.\n\nOr press h/H to adjust."); + + // Draw top-right panel + drawPanel(buf, v_parts.first, " Top Right ", Color.green, "This is the top-right panel.\n\nDrag the horizontal bar to resize.\n\nOr press v/V to adjust."); + + // Draw bottom-right panel + drawPanel(buf, v_parts.second, " Bottom Right ", Color.yellow, "This is the bottom-right panel.\n\nTry dragging both splitters!"); + + // Draw splitter handles + drawSplitter(buf, h_parts.handle, true, state.active_splitter == .horizontal); + drawSplitter(buf, v_parts.handle, false, state.active_splitter == .vertical); + + // Draw status bar + var status_buf: [128]u8 = undefined; + const status = std.fmt.bufPrint(&status_buf, "H-split: {d}% | V-split: {d}% | {s}", .{ + state.h_splitter.position, + state.v_splitter.position, + if (state.drag_state.isDragging()) "Dragging..." else "Drag splitters or press h/H v/V | q to quit", + }) catch "..."; + _ = buf.setString(0, area.height -| 1, status, (Style{}).fg(Color.white).bg(Color.rgb(40, 40, 40))); +} + +fn drawPanel(buf: *Buffer, area: Rect, title: []const u8, color: Color, content: []const u8) void { + const block = Block.init() + .title(title) + .setBorders(Borders.all) + .borderStyle((Style{}).fg(color)); + block.render(area, buf); + + // Draw content + const inner = Rect.init(area.x + 1, area.y + 1, area.width -| 2, area.height -| 2); + var y: u16 = 0; + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |line| { + if (y >= inner.height) break; + const max_len = @min(line.len, inner.width); + _ = buf.setString(inner.x, inner.y + y, line[0..max_len], Style{}); + y += 1; + } +} + +fn drawSplitter(buf: *Buffer, handle: Rect, is_vertical: bool, is_active: bool) void { + const color = if (is_active) Color.cyan else Color.rgb(100, 100, 100); + const char: []const u8 = if (is_vertical) "|" else "-"; + + if (is_vertical) { + var y: u16 = 0; + while (y < handle.height) : (y += 1) { + _ = buf.setString(handle.x, handle.y + y, char, (Style{}).fg(color)); + } + } else { + var x: u16 = 0; + while (x < handle.width) : (x += 1) { + _ = buf.setString(handle.x + x, handle.y, char, (Style{}).fg(color)); + } + } +} diff --git a/examples/syntax_demo.zig b/examples/syntax_demo.zig new file mode 100644 index 0000000..e42b615 --- /dev/null +++ b/examples/syntax_demo.zig @@ -0,0 +1,176 @@ +//! Syntax Highlighting Demo +//! +//! Run with: zig build syntax-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const SyntaxHighlighter = zcatui.widgets.SyntaxHighlighter; +const SyntaxLanguage = zcatui.widgets.SyntaxLanguage; + +const zig_code = + \\const std = @import("std"); + \\ + \\pub fn main() !void { + \\ const allocator = std.heap.page_allocator; + \\ var list = std.ArrayList(u32).init(allocator); + \\ defer list.deinit(); + \\ + \\ try list.append(42); + \\ try list.append(100); + \\ + \\ for (list.items) |item| { + \\ std.debug.print("{d}\n", .{item}); + \\ } + \\} +; + +const rust_code = + \\use std::collections::HashMap; + \\ + \\fn main() { + \\ let mut map = HashMap::new(); + \\ map.insert("key", "value"); + \\ + \\ if let Some(val) = map.get("key") { + \\ println!("Found: {}", val); + \\ } + \\ + \\ // Iterate over map + \\ for (k, v) in &map { + \\ println!("{}: {}", k, v); + \\ } + \\} +; + +const python_code = + \\import json + \\from typing import List, Dict + \\ + \\def process_data(items: List[str]) -> Dict: + \\ """Process a list of items.""" + \\ result = {} + \\ for i, item in enumerate(items): + \\ result[f"item_{i}"] = item.upper() + \\ return result + \\ + \\if __name__ == "__main__": + \\ data = ["hello", "world"] + \\ print(json.dumps(process_data(data))) +; + +const js_code = + \\const express = require('express'); + \\const app = express(); + \\ + \\// Middleware + \\app.use(express.json()); + \\ + \\app.get('/api/users', async (req, res) => { + \\ try { + \\ const users = await fetchUsers(); + \\ res.json({ success: true, data: users }); + \\ } catch (err) { + \\ res.status(500).json({ error: err.message }); + \\ } + \\}); + \\ + \\app.listen(3000, () => console.log('Server running')); +; + +const languages = [_]SyntaxLanguage{ .zig, .rust, .python, .javascript }; +const lang_names = [_][]const u8{ "Zig", "Rust", "Python", "JavaScript" }; +const codes = [_][]const u8{ zig_code, rust_code, python_code, js_code }; + +/// State for the demo +const State = struct { + selected: usize = 0, + running: bool = true, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + var state = State{}; + + while (state.running) { + try term.drawWithContext(&state, render); + + if (try term.pollEvent(100)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + if (c >= '1' and c <= '4') { + state.selected = c - '1'; + } + }, + .left => { + if (state.selected > 0) state.selected -= 1; + }, + .right => { + if (state.selected < languages.len - 1) state.selected += 1; + }, + .esc => state.running = false, + else => {}, + } + }, + else => {}, + } + } + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Header with language tabs + var x: u16 = 2; + for (lang_names, 0..) |name, i| { + const style = if (i == state.selected) + (Style{}).fg(Color.black).bg(Color.cyan).bold() + else + (Style{}).fg(Color.white); + + _ = buf.setString(x, 0, " ", style); + _ = buf.setString(x + 1, 0, name, style); + _ = buf.setString(x + 1 + @as(u16, @intCast(name.len)), 0, " ", style); + x += @as(u16, @intCast(name.len)) + 3; + } + + // Code area + const code_area = Rect.init(0, 2, area.width, area.height -| 4); + + const block = Block.init() + .title(" Code ") + .setBorders(Borders.all) + .borderStyle((Style{}).fg(Color.rgb(80, 80, 80))); + block.render(code_area, buf); + + const inner = Rect.init(1, 3, area.width -| 2, area.height -| 6); + + // Syntax highlighter + const highlighter = SyntaxHighlighter.init(languages[state.selected]) + .setLineNumbers(true); + + highlighter.render(codes[state.selected], 0, inner, buf); + + // Footer + _ = buf.setString( + 2, + area.height -| 1, + "Press 1-4 or left/right to change language, q to quit", + (Style{}).fg(Color.rgb(100, 100, 100)), + ); +} diff --git a/examples/viewport_demo.zig b/examples/viewport_demo.zig new file mode 100644 index 0000000..d90cfcf --- /dev/null +++ b/examples/viewport_demo.zig @@ -0,0 +1,176 @@ +//! Viewport Demo - Shows scrollable content +//! +//! Run with: zig build viewport-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Rect = zcatui.Rect; +const Buffer = zcatui.Buffer; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; + +// Sample content - pre-generated long text +const sample_content = + \\ 1 | === Welcome to the Viewport Demo === + \\ 2 | This demonstrates scrollable content. + \\ 3 | Use j/k or arrows to scroll. + \\ 4 | + \\ 5 | Lorem ipsum dolor sit amet, line 5. + \\ 6 | Lorem ipsum dolor sit amet, line 6. + \\ 7 | Lorem ipsum dolor sit amet, line 7. + \\ 8 | Lorem ipsum dolor sit amet, line 8. + \\ 9 | Lorem ipsum dolor sit amet, line 9. + \\ 10 | === Section 1 === + \\ 11 | Lorem ipsum dolor sit amet, line 11. + \\ 12 | Lorem ipsum dolor sit amet, line 12. + \\ 13 | Lorem ipsum dolor sit amet, line 13. + \\ 14 | Lorem ipsum dolor sit amet, line 14. + \\ 15 | --- subsection --- + \\ 16 | Lorem ipsum dolor sit amet, line 16. + \\ 17 | Lorem ipsum dolor sit amet, line 17. + \\ 18 | Lorem ipsum dolor sit amet, line 18. + \\ 19 | Lorem ipsum dolor sit amet, line 19. + \\ 20 | === Section 2 === + \\ 21 | Lorem ipsum dolor sit amet, line 21. + \\ 22 | Lorem ipsum dolor sit amet, line 22. + \\ 23 | Lorem ipsum dolor sit amet, line 23. + \\ 24 | Lorem ipsum dolor sit amet, line 24. + \\ 25 | --- subsection --- + \\ 26 | Lorem ipsum dolor sit amet, line 26. + \\ 27 | Lorem ipsum dolor sit amet, line 27. + \\ 28 | Lorem ipsum dolor sit amet, line 28. + \\ 29 | Lorem ipsum dolor sit amet, line 29. + \\ 30 | === Section 3 === + \\ 31 | Lorem ipsum dolor sit amet, line 31. + \\ 32 | Lorem ipsum dolor sit amet, line 32. + \\ 33 | Lorem ipsum dolor sit amet, line 33. + \\ 34 | Lorem ipsum dolor sit amet, line 34. + \\ 35 | --- subsection --- + \\ 36 | Lorem ipsum dolor sit amet, line 36. + \\ 37 | Lorem ipsum dolor sit amet, line 37. + \\ 38 | Lorem ipsum dolor sit amet, line 38. + \\ 39 | Lorem ipsum dolor sit amet, line 39. + \\ 40 | === Section 4 === + \\ 41 | Lorem ipsum dolor sit amet, line 41. + \\ 42 | Lorem ipsum dolor sit amet, line 42. + \\ 43 | Lorem ipsum dolor sit amet, line 43. + \\ 44 | Lorem ipsum dolor sit amet, line 44. + \\ 45 | --- subsection --- + \\ 46 | Lorem ipsum dolor sit amet, line 46. + \\ 47 | Lorem ipsum dolor sit amet, line 47. + \\ 48 | Lorem ipsum dolor sit amet, line 48. + \\ 49 | Lorem ipsum dolor sit amet, line 49. + \\ 50 | === End of content === +; + +/// State for the demo +const State = struct { + offset_y: u16 = 0, + content_height: u16 = 50, + running: bool = true, + view_height: u16 = 20, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + // Enable mouse for scroll wheel + try term.enableMouseCapture(); + + var state = State{}; + + while (state.running) { + state.view_height = term.area().height -| 4; + try term.drawWithContext(&state, render); + + if (try term.pollEvent(100)) |event| { + switch (event) { + .key => |key| { + switch (key.code) { + .char => |c| { + if (c == 'q') state.running = false; + if (c == 'j') state.offset_y +|= 1; + if (c == 'k' and state.offset_y > 0) state.offset_y -= 1; + if (c == 'g') state.offset_y = 0; + if (c == 'G') state.offset_y = state.content_height -| state.view_height; + }, + .up => if (state.offset_y > 0) { + state.offset_y -= 1; + }, + .down => state.offset_y +|= 1, + .page_up => state.offset_y -|= state.view_height, + .page_down => state.offset_y +|= state.view_height, + .home => state.offset_y = 0, + .end => state.offset_y = state.content_height -| state.view_height, + .esc => state.running = false, + else => {}, + } + }, + .mouse => |mouse| { + switch (mouse.kind) { + .scroll_up => if (state.offset_y >= 3) { + state.offset_y -= 3; + }, + .scroll_down => state.offset_y +|= 3, + else => {}, + } + }, + else => {}, + } + } + } +} + +fn render(state: *State, area: Rect, buf: *Buffer) void { + // Main border + const block = Block.init() + .title(" Viewport Demo ") + .setBorders(Borders.all) + .borderStyle((Style{}).fg(Color.cyan)); + block.render(area, buf); + + // Content area + const content_area = Rect.init(1, 1, area.width -| 2, area.height -| 3); + + // Render visible content manually + var lines = std.mem.splitScalar(u8, sample_content, '\n'); + var line_num: u16 = 0; + var y: u16 = 0; + while (lines.next()) |line| { + if (line_num >= state.offset_y and y < content_area.height) { + _ = buf.setString(content_area.x, content_area.y + y, line, Style{}); + y += 1; + } + line_num += 1; + } + + // Scrollbar indicator + const scroll_pct: u16 = if (state.content_height > state.view_height) + @min(state.offset_y * 100 / (state.content_height -| state.view_height), 100) + else + 0; + + // Footer with scroll info + var footer_buf: [128]u8 = undefined; + const footer = std.fmt.bufPrint(&footer_buf, "Line {d}/{d} ({d}%) | j/k up/down scroll, g/G top/bottom, PgUp/PgDn, q quit", .{ + state.offset_y + 1, + state.content_height, + scroll_pct, + }) catch "..."; + + _ = buf.setString( + 2, + area.height -| 1, + footer, + (Style{}).fg(Color.rgb(100, 100, 100)), + ); +} diff --git a/src/async_loop.zig b/src/async_loop.zig new file mode 100644 index 0000000..aa3cb11 --- /dev/null +++ b/src/async_loop.zig @@ -0,0 +1,347 @@ +//! Async event loop for zcatui. +//! +//! Provides efficient async I/O using epoll on Linux. +//! This allows handling multiple input sources (stdin, timers, signals) +//! with a single event loop. +//! +//! ## Features +//! +//! - Epoll-based event multiplexing +//! - Timer support for animations +//! - Signal handling integration +//! - Non-blocking I/O +//! +//! ## Example +//! +//! ```zig +//! var loop = try AsyncLoop.init(); +//! defer loop.deinit(); +//! +//! // Add stdin for terminal input +//! try loop.addStdin(); +//! +//! // Add a periodic timer +//! const timer_id = try loop.addTimer(100); // 100ms +//! +//! while (running) { +//! const events = try loop.wait(1000); // 1 second timeout +//! for (events) |event| { +//! switch (event.source) { +//! .stdin => handleInput(), +//! .timer => |id| if (id == timer_id) animate(), +//! .signal => |sig| handleSignal(sig), +//! } +//! } +//! } +//! ``` + +const std = @import("std"); + +/// Maximum number of events to process per wait. +const MAX_EVENTS = 32; + +/// Event source types. +pub const EventSource = union(enum) { + /// Standard input (terminal). + stdin, + /// Timer with ID. + timer: u32, + /// Signal received. + signal: u8, + /// Custom file descriptor. + fd: std.posix.fd_t, +}; + +/// An async event. +pub const AsyncEvent = struct { + source: EventSource, + /// Whether the source is readable. + readable: bool = false, + /// Whether the source is writable. + writable: bool = false, + /// Whether an error occurred. + err: bool = false, + /// Whether hangup occurred. + hup: bool = false, +}; + +/// Timer entry. +const TimerEntry = struct { + id: u32, + fd: std.posix.fd_t, + interval_ms: u64, + repeating: bool, +}; + +/// Async event loop using epoll. +pub const AsyncLoop = struct { + epoll_fd: std.posix.fd_t, + events: [MAX_EVENTS]std.os.linux.epoll_event = undefined, + timers: std.ArrayList(TimerEntry), + next_timer_id: u32 = 1, + stdin_added: bool = false, + allocator: std.mem.Allocator, + + /// Creates a new async event loop. + pub fn init(allocator: std.mem.Allocator) !AsyncLoop { + const epoll_fd = try std.posix.epoll_create1(.{ .CLOEXEC = true }); + return .{ + .epoll_fd = epoll_fd, + .timers = std.ArrayList(TimerEntry).init(allocator), + .allocator = allocator, + }; + } + + /// Cleans up the event loop. + pub fn deinit(self: *AsyncLoop) void { + // Close timer fds + for (self.timers.items) |timer| { + std.posix.close(timer.fd); + } + self.timers.deinit(); + std.posix.close(self.epoll_fd); + } + + /// Adds stdin to the event loop. + pub fn addStdin(self: *AsyncLoop) !void { + if (self.stdin_added) return; + + var ev = std.os.linux.epoll_event{ + .events = std.os.linux.EPOLL.IN, + .data = .{ .fd = std.posix.STDIN_FILENO }, + }; + + try std.posix.epoll_ctl( + self.epoll_fd, + .ADD, + std.posix.STDIN_FILENO, + &ev, + ); + + self.stdin_added = true; + } + + /// Adds a timer to the event loop. + pub fn addTimer(self: *AsyncLoop, interval_ms: u64) !u32 { + return self.addTimerEx(interval_ms, true); + } + + /// Adds a one-shot or repeating timer. + pub fn addTimerEx(self: *AsyncLoop, interval_ms: u64, repeating: bool) !u32 { + // Create timerfd + const timer_fd = std.posix.timerfd_create(.MONOTONIC, .{ + .CLOEXEC = true, + .NONBLOCK = true, + }) catch |err| { + // Fallback if timerfd not available + _ = err; + return error.TimerUnavailable; + }; + + errdefer std.posix.close(timer_fd); + + // Set timer interval + const interval_ns = interval_ms * 1_000_000; + const interval_sec = interval_ns / 1_000_000_000; + const interval_nsec = interval_ns % 1_000_000_000; + + var spec = std.os.linux.itimerspec{ + .it_interval = .{ + .sec = if (repeating) @intCast(interval_sec) else 0, + .nsec = if (repeating) @intCast(interval_nsec) else 0, + }, + .it_value = .{ + .sec = @intCast(interval_sec), + .nsec = @intCast(interval_nsec), + }, + }; + + _ = std.os.linux.timerfd_settime(timer_fd, .{}, &spec, null); + + // Add to epoll + const timer_id = self.next_timer_id; + self.next_timer_id += 1; + + var ev = std.os.linux.epoll_event{ + .events = std.os.linux.EPOLL.IN, + .data = .{ .fd = timer_fd }, + }; + + try std.posix.epoll_ctl(self.epoll_fd, .ADD, timer_fd, &ev); + + try self.timers.append(.{ + .id = timer_id, + .fd = timer_fd, + .interval_ms = interval_ms, + .repeating = repeating, + }); + + return timer_id; + } + + /// Removes a timer. + pub fn removeTimer(self: *AsyncLoop, timer_id: u32) void { + for (self.timers.items, 0..) |timer, i| { + if (timer.id == timer_id) { + std.posix.epoll_ctl(self.epoll_fd, .DEL, timer.fd, null) catch {}; + std.posix.close(timer.fd); + _ = self.timers.swapRemove(i); + return; + } + } + } + + /// Adds a custom file descriptor. + pub fn addFd(self: *AsyncLoop, fd: std.posix.fd_t, readable: bool, writable: bool) !void { + var events: u32 = 0; + if (readable) events |= std.os.linux.EPOLL.IN; + if (writable) events |= std.os.linux.EPOLL.OUT; + + var ev = std.os.linux.epoll_event{ + .events = events, + .data = .{ .fd = fd }, + }; + + try std.posix.epoll_ctl(self.epoll_fd, .ADD, fd, &ev); + } + + /// Removes a file descriptor. + pub fn removeFd(self: *AsyncLoop, fd: std.posix.fd_t) void { + std.posix.epoll_ctl(self.epoll_fd, .DEL, fd, null) catch {}; + } + + /// Waits for events. + pub fn wait(self: *AsyncLoop, timeout_ms: ?u32) ![]AsyncEvent { + const timeout: i32 = if (timeout_ms) |t| @intCast(t) else -1; + + const n = std.posix.epoll_wait( + self.epoll_fd, + &self.events, + timeout, + ); + + // Convert to AsyncEvents + var result: [MAX_EVENTS]AsyncEvent = undefined; + var count: usize = 0; + + for (self.events[0..n]) |ev| { + const fd = ev.data.fd; + + // Determine source + const source: EventSource = blk: { + if (fd == std.posix.STDIN_FILENO) { + break :blk .stdin; + } + + // Check timers + for (self.timers.items) |timer| { + if (timer.fd == fd) { + // Read to clear the timer + var buf: [8]u8 = undefined; + _ = std.posix.read(fd, &buf) catch {}; + break :blk .{ .timer = timer.id }; + } + } + + break :blk .{ .fd = fd }; + }; + + result[count] = .{ + .source = source, + .readable = (ev.events & std.os.linux.EPOLL.IN) != 0, + .writable = (ev.events & std.os.linux.EPOLL.OUT) != 0, + .err = (ev.events & std.os.linux.EPOLL.ERR) != 0, + .hup = (ev.events & std.os.linux.EPOLL.HUP) != 0, + }; + count += 1; + } + + return result[0..count]; + } + + /// Waits for a single event with a callback approach. + pub fn poll(self: *AsyncLoop, timeout_ms: ?u32) !?AsyncEvent { + const events = try self.wait(timeout_ms); + if (events.len > 0) { + return events[0]; + } + return null; + } +}; + +/// Simple ticker that fires at regular intervals. +pub const Ticker = struct { + loop: *AsyncLoop, + timer_id: u32, + interval_ms: u64, + + pub fn init(loop: *AsyncLoop, interval_ms: u64) !Ticker { + const timer_id = try loop.addTimer(interval_ms); + return .{ + .loop = loop, + .timer_id = timer_id, + .interval_ms = interval_ms, + }; + } + + pub fn deinit(self: *Ticker) void { + self.loop.removeTimer(self.timer_id); + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "AsyncLoop creation" { + const allocator = std.testing.allocator; + var loop = try AsyncLoop.init(allocator); + defer loop.deinit(); + + try std.testing.expect(loop.epoll_fd >= 0); +} + +test "AsyncLoop addStdin" { + const allocator = std.testing.allocator; + var loop = try AsyncLoop.init(allocator); + defer loop.deinit(); + + try loop.addStdin(); + try std.testing.expect(loop.stdin_added); + + // Adding again should be no-op + try loop.addStdin(); +} + +test "AsyncLoop timer" { + const allocator = std.testing.allocator; + var loop = try AsyncLoop.init(allocator); + defer loop.deinit(); + + const timer_id = loop.addTimer(100) catch |err| { + // Skip if timerfd not available + if (err == error.TimerUnavailable) return; + return err; + }; + + try std.testing.expect(timer_id > 0); + try std.testing.expectEqual(@as(usize, 1), loop.timers.items.len); + + loop.removeTimer(timer_id); + try std.testing.expectEqual(@as(usize, 0), loop.timers.items.len); +} + +test "EventSource union" { + const stdin: EventSource = .stdin; + const timer: EventSource = .{ .timer = 42 }; + + switch (stdin) { + .stdin => {}, + else => unreachable, + } + + switch (timer) { + .timer => |id| try std.testing.expectEqual(@as(u32, 42), id), + else => unreachable, + } +} diff --git a/src/compose.zig b/src/compose.zig new file mode 100644 index 0000000..88676dd --- /dev/null +++ b/src/compose.zig @@ -0,0 +1,611 @@ +//! Ergonomic Widget Composition +//! +//! Provides a declarative, fluent API for composing complex layouts +//! from simpler widgets. Similar to SwiftUI/Flutter patterns. +//! +//! ## Example +//! +//! ```zig +//! const compose = @import("compose.zig"); +//! +//! // Simple vertical stack +//! compose.vstack(buf, area, .{ +//! compose.sized(header_widget, 3), +//! compose.flex(content_widget, 1), +//! compose.sized(footer_widget, 1), +//! }); +//! +//! // Horizontal with spacing +//! compose.hstack(buf, area, .{ +//! .spacing = 1, +//! .children = .{ +//! compose.sized(sidebar, 20), +//! compose.flex(main_content, 1), +//! }, +//! }); +//! +//! // Nested composition +//! compose.vstack(buf, area, .{ +//! compose.sized(header, 3), +//! compose.hstack_flex(.{ +//! compose.sized(nav, 15), +//! compose.flex(content, 1), +//! }), +//! }); +//! ``` + +const std = @import("std"); +const Buffer = @import("buffer.zig").Buffer; +const Rect = @import("buffer.zig").Rect; +const Style = @import("style.zig").Style; +const Color = @import("style.zig").Color; +const Layout = @import("layout.zig").Layout; +const Constraint = @import("layout.zig").Constraint; +const Flex = @import("layout.zig").Flex; + +// ============================================================================ +// Renderable Interface +// ============================================================================ + +/// A renderable widget or composition +pub const Renderable = struct { + ptr: *const anyopaque, + renderFn: *const fn (*const anyopaque, Rect, *Buffer) void, + + pub fn render(self: Renderable, area: Rect, buf: *Buffer) void { + self.renderFn(self.ptr, area, buf); + } + + /// Create from any type with a render method + pub fn from(widget: anytype) Renderable { + const T = @TypeOf(widget); + const ptr_info = @typeInfo(T); + + if (ptr_info == .pointer) { + const ChildType = ptr_info.pointer.child; + return .{ + .ptr = @ptrCast(widget), + .renderFn = struct { + fn render(p: *const anyopaque, area: Rect, buf: *Buffer) void { + const w: *const ChildType = @ptrCast(@alignCast(p)); + w.render(area, buf); + } + }.render, + }; + } else { + // For value types, we'd need to store a copy + // This is a limitation - prefer pointers + @compileError("Renderable.from requires a pointer type"); + } + } +}; + +// ============================================================================ +// Child Wrapper +// ============================================================================ + +/// A child widget with its sizing constraint +pub const Child = struct { + /// The widget to render + widget: Renderable, + /// Size constraint + constraint: Constraint, + + /// Render this child in the given area + pub fn render(self: Child, area: Rect, buf: *Buffer) void { + self.widget.render(area, buf); + } +}; + +/// Create a child with fixed size +pub fn sized(widget: anytype, size: u16) Child { + return .{ + .widget = makeRenderable(widget), + .constraint = Constraint.length(size), + }; +} + +/// Create a child that flexes to fill space +pub fn flex(widget: anytype, factor: u16) Child { + return .{ + .widget = makeRenderable(widget), + .constraint = Constraint.ratio(factor, 1), + }; +} + +/// Create a child that fills all remaining space +pub fn fill(widget: anytype) Child { + return .{ + .widget = makeRenderable(widget), + .constraint = Constraint.fill(), + }; +} + +/// Create a child with minimum size +pub fn minSize(widget: anytype, min: u16) Child { + return .{ + .widget = makeRenderable(widget), + .constraint = Constraint.min(min), + }; +} + +/// Create a child with percentage size +pub fn percent(widget: anytype, pct: u16) Child { + return .{ + .widget = makeRenderable(widget), + .constraint = Constraint.percentage(pct), + }; +} + +/// Create a child with ratio size +pub fn ratio(widget: anytype, num: u32, den: u32) Child { + return .{ + .widget = makeRenderable(widget), + .constraint = Constraint.ratio(num, den), + }; +} + +// ============================================================================ +// Stack Layouts +// ============================================================================ + +/// Vertical stack options +pub const VStackOptions = struct { + spacing: u16 = 0, + margin: u16 = 0, + alignment: Alignment = .stretch, + + pub const Alignment = enum { start, center, end, stretch }; +}; + +/// Horizontal stack options +pub const HStackOptions = struct { + spacing: u16 = 0, + margin: u16 = 0, + alignment: Alignment = .stretch, + + pub const Alignment = enum { start, center, end, stretch }; +}; + +/// Render a vertical stack of widgets +pub fn vstack(buf: *Buffer, area: Rect, children: anytype) void { + vstackOpts(buf, area, .{}, children); +} + +/// Render a vertical stack with options +pub fn vstackOpts(buf: *Buffer, area: Rect, opts: VStackOptions, children: anytype) void { + const T = @TypeOf(children); + const fields = @typeInfo(T).@"struct".fields; + + if (fields.len == 0) return; + + // Apply margin + const inner = if (opts.margin > 0) + area.inner(.{ + .top = opts.margin, + .right = opts.margin, + .bottom = opts.margin, + .left = opts.margin, + }) + else + area; + + if (inner.isEmpty()) return; + + // Build constraints + var constraints: [fields.len]Constraint = undefined; + inline for (fields, 0..) |field, i| { + const child = @field(children, field.name); + constraints[i] = child.constraint; + } + + // Split area + const layout = Layout.vertical(&constraints).withMargin(0); + const result = layout.split(inner); + + // Render each child with spacing adjustment + var y_offset: u16 = 0; + inline for (fields, 0..) |field, i| { + if (i >= result.count) break; + + var child_area = result.rects[i]; + + // Apply spacing (except for first) + if (i > 0) { + y_offset += opts.spacing; + child_area.y += y_offset; + if (child_area.height > opts.spacing) { + child_area.height -= opts.spacing; + } + } + + // Apply alignment + if (opts.alignment != .stretch) { + // For non-stretch, we'd need to know the widget's preferred width + // For now, stretch is the default + } + + const child = @field(children, field.name); + child.widget.render(child_area, buf); + } +} + +/// Render a horizontal stack of widgets +pub fn hstack(buf: *Buffer, area: Rect, children: anytype) void { + hstackOpts(buf, area, .{}, children); +} + +/// Render a horizontal stack with options +pub fn hstackOpts(buf: *Buffer, area: Rect, opts: HStackOptions, children: anytype) void { + const T = @TypeOf(children); + const fields = @typeInfo(T).@"struct".fields; + + if (fields.len == 0) return; + + // Apply margin + const inner = if (opts.margin > 0) + area.inner(.{ + .top = opts.margin, + .right = opts.margin, + .bottom = opts.margin, + .left = opts.margin, + }) + else + area; + + if (inner.isEmpty()) return; + + // Build constraints + var constraints: [fields.len]Constraint = undefined; + inline for (fields, 0..) |field, i| { + const child = @field(children, field.name); + constraints[i] = child.constraint; + } + + // Split area + const layout = Layout.horizontal(&constraints).withMargin(0); + const result = layout.split(inner); + + // Render each child with spacing adjustment + var x_offset: u16 = 0; + inline for (fields, 0..) |field, i| { + if (i >= result.count) break; + + var child_area = result.rects[i]; + + // Apply spacing (except for first) + if (i > 0) { + x_offset += opts.spacing; + child_area.x += x_offset; + if (child_area.width > opts.spacing) { + child_area.width -= opts.spacing; + } + } + + const child = @field(children, field.name); + child.widget.render(child_area, buf); + } +} + +// ============================================================================ +// Z-Stack (Overlay) +// ============================================================================ + +/// Render widgets stacked on top of each other (z-order) +pub fn zstack(buf: *Buffer, area: Rect, children: anytype) void { + const T = @TypeOf(children); + const fields = @typeInfo(T).@"struct".fields; + + // Render all children in same area (later = on top) + inline for (fields) |field| { + const child = @field(children, field.name); + child.widget.render(area, buf); + } +} + +// ============================================================================ +// Conditional Rendering +// ============================================================================ + +/// Conditionally include a widget +pub fn when(condition: bool, child: Child) ?Child { + return if (condition) child else null; +} + +/// Conditionally include a widget (inline version for tuples) +pub fn maybe(condition: bool, widget: anytype, constraint: Constraint) ?Child { + if (!condition) return null; + return .{ + .widget = makeRenderable(widget), + .constraint = constraint, + }; +} + +// ============================================================================ +// Spacers +// ============================================================================ + +/// Empty space with fixed size +pub fn spacer(size: u16) Child { + return .{ + .widget = .{ + .ptr = undefined, + .renderFn = struct { + fn render(_: *const anyopaque, _: Rect, _: *Buffer) void { + // Do nothing - just takes up space + } + }.render, + }, + .constraint = Constraint.length(size), + }; +} + +/// Flexible spacer that expands +pub fn flexSpacer(factor: u16) Child { + return .{ + .widget = .{ + .ptr = undefined, + .renderFn = struct { + fn render(_: *const anyopaque, _: Rect, _: *Buffer) void {} + }.render, + }, + .constraint = Constraint.ratio(factor, 1), + }; +} + +// ============================================================================ +// Decorators +// ============================================================================ + +/// Decorator that adds padding around a widget +pub const Padded = struct { + inner: Renderable, + padding: u16, + + pub fn render(self: *const Padded, area: Rect, buf: *Buffer) void { + const inner_area = area.inner(.{ + .top = self.padding, + .right = self.padding, + .bottom = self.padding, + .left = self.padding, + }); + self.inner.render(inner_area, buf); + } +}; + +/// Add padding around a widget +pub fn padded(widget: anytype, padding: u16) *const Padded { + const decorator = Padded{ + .inner = makeRenderable(widget), + .padding = padding, + }; + // Note: This returns a pointer to stack memory which is problematic + // In practice, you'd want to allocate or use a different pattern + _ = decorator; + @compileError("padded() requires allocation - use pad() with explicit area instead"); +} + +/// Apply padding to an area (simpler version) +pub fn pad(area: Rect, padding: u16) Rect { + return area.inner(.{ + .top = padding, + .right = padding, + .bottom = padding, + .left = padding, + }); +} + +// ============================================================================ +// Centered Content +// ============================================================================ + +/// Center content within an area +pub fn center(buf: *Buffer, area: Rect, width: u16, height: u16, widget: anytype) void { + const centered_area = Rect.init( + area.x + (area.width -| width) / 2, + area.y + (area.height -| height) / 2, + @min(width, area.width), + @min(height, area.height), + ); + const renderable = makeRenderable(widget); + renderable.render(centered_area, buf); +} + +/// Center horizontally +pub fn centerH(buf: *Buffer, area: Rect, width: u16, widget: anytype) void { + const centered_area = Rect.init( + area.x + (area.width -| width) / 2, + area.y, + @min(width, area.width), + area.height, + ); + const renderable = makeRenderable(widget); + renderable.render(centered_area, buf); +} + +/// Center vertically +pub fn centerV(buf: *Buffer, area: Rect, height: u16, widget: anytype) void { + const centered_area = Rect.init( + area.x, + area.y + (area.height -| height) / 2, + area.width, + @min(height, area.height), + ); + const renderable = makeRenderable(widget); + renderable.render(centered_area, buf); +} + +// ============================================================================ +// Alignment Helpers +// ============================================================================ + +/// Align content to top-left +pub fn topLeft(area: Rect, width: u16, height: u16) Rect { + return Rect.init(area.x, area.y, @min(width, area.width), @min(height, area.height)); +} + +/// Align content to top-right +pub fn topRight(area: Rect, width: u16, height: u16) Rect { + return Rect.init( + area.x + area.width -| width, + area.y, + @min(width, area.width), + @min(height, area.height), + ); +} + +/// Align content to bottom-left +pub fn bottomLeft(area: Rect, width: u16, height: u16) Rect { + return Rect.init( + area.x, + area.y + area.height -| height, + @min(width, area.width), + @min(height, area.height), + ); +} + +/// Align content to bottom-right +pub fn bottomRight(area: Rect, width: u16, height: u16) Rect { + return Rect.init( + area.x + area.width -| width, + area.y + area.height -| height, + @min(width, area.width), + @min(height, area.height), + ); +} + +// ============================================================================ +// Split Helpers +// ============================================================================ + +/// Split area into two parts vertically +pub fn splitV(area: Rect, top_height: u16) struct { top: Rect, bottom: Rect } { + const h = @min(top_height, area.height); + return .{ + .top = Rect.init(area.x, area.y, area.width, h), + .bottom = Rect.init(area.x, area.y + h, area.width, area.height -| h), + }; +} + +/// Split area into two parts horizontally +pub fn splitH(area: Rect, left_width: u16) struct { left: Rect, right: Rect } { + const w = @min(left_width, area.width); + return .{ + .left = Rect.init(area.x, area.y, w, area.height), + .right = Rect.init(area.x + w, area.y, area.width -| w, area.height), + }; +} + +/// Split into three parts vertically (header, content, footer) +pub fn splitV3(area: Rect, header_h: u16, footer_h: u16) struct { header: Rect, content: Rect, footer: Rect } { + const h1 = @min(header_h, area.height); + const h3 = @min(footer_h, area.height -| h1); + const h2 = area.height -| h1 -| h3; + + return .{ + .header = Rect.init(area.x, area.y, area.width, h1), + .content = Rect.init(area.x, area.y + h1, area.width, h2), + .footer = Rect.init(area.x, area.y + h1 + h2, area.width, h3), + }; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +fn makeRenderable(widget: anytype) Renderable { + const T = @TypeOf(widget); + + // Check if it's already a Child + if (T == Child) { + return widget.widget; + } + + // Check if it's a pointer to something with render + const ptr_info = @typeInfo(T); + if (ptr_info == .pointer) { + const WidgetType = ptr_info.pointer.child; + if (@hasDecl(WidgetType, "render")) { + return .{ + .ptr = @ptrCast(widget), + .renderFn = struct { + fn render(p: *const anyopaque, area: Rect, buf: *Buffer) void { + const w: *const WidgetType = @ptrCast(@alignCast(p)); + w.render(area, buf); + } + }.render, + }; + } + } + + @compileError("Widget must be a pointer to a type with render(Rect, *Buffer) method"); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "splitV" { + const area = Rect.init(0, 0, 80, 24); + const split = splitV(area, 3); + + try std.testing.expectEqual(@as(u16, 3), split.top.height); + try std.testing.expectEqual(@as(u16, 21), split.bottom.height); + try std.testing.expectEqual(@as(u16, 3), split.bottom.y); +} + +test "splitH" { + const area = Rect.init(0, 0, 100, 10); + const split = splitH(area, 20); + + try std.testing.expectEqual(@as(u16, 20), split.left.width); + try std.testing.expectEqual(@as(u16, 80), split.right.width); + try std.testing.expectEqual(@as(u16, 20), split.right.x); +} + +test "splitV3" { + const area = Rect.init(0, 0, 80, 24); + const split = splitV3(area, 3, 1); + + try std.testing.expectEqual(@as(u16, 3), split.header.height); + try std.testing.expectEqual(@as(u16, 20), split.content.height); + try std.testing.expectEqual(@as(u16, 1), split.footer.height); +} + +test "topLeft alignment" { + const area = Rect.init(10, 10, 100, 50); + const aligned = topLeft(area, 20, 5); + + try std.testing.expectEqual(@as(u16, 10), aligned.x); + try std.testing.expectEqual(@as(u16, 10), aligned.y); + try std.testing.expectEqual(@as(u16, 20), aligned.width); + try std.testing.expectEqual(@as(u16, 5), aligned.height); +} + +test "bottomRight alignment" { + const area = Rect.init(0, 0, 100, 50); + const aligned = bottomRight(area, 20, 10); + + try std.testing.expectEqual(@as(u16, 80), aligned.x); + try std.testing.expectEqual(@as(u16, 40), aligned.y); +} + +test "spacer creates empty child" { + const s = spacer(5); + try std.testing.expectEqual(Constraint.length(5), s.constraint); +} + +test "flexSpacer creates ratio constraint" { + const s = flexSpacer(2); + try std.testing.expectEqual(Constraint.ratio(2, 1), s.constraint); +} + +test "pad reduces area" { + const area = Rect.init(0, 0, 100, 50); + const padded_area = pad(area, 5); + + try std.testing.expectEqual(@as(u16, 5), padded_area.x); + try std.testing.expectEqual(@as(u16, 5), padded_area.y); + try std.testing.expectEqual(@as(u16, 90), padded_area.width); + try std.testing.expectEqual(@as(u16, 40), padded_area.height); +} diff --git a/src/debug.zig b/src/debug.zig new file mode 100644 index 0000000..831879a --- /dev/null +++ b/src/debug.zig @@ -0,0 +1,338 @@ +//! Debug tools for zcatui. +//! +//! Provides debug overlays, performance counters, and inspection tools +//! for developing TUI applications. +//! +//! ## Features +//! +//! - Widget boundary visualization +//! - Performance metrics (FPS, render time) +//! - Event logging +//! - Memory usage tracking +//! +//! ## Usage +//! +//! ```zig +//! var debug = DebugOverlay.init(); +//! debug.setEnabled(true); +//! +//! // In your render loop: +//! debug.beginFrame(); +//! // ... render widgets ... +//! debug.endFrame(); +//! debug.render(area, buf); +//! ``` + +const std = @import("std"); +const Style = @import("style.zig").Style; +const Color = @import("style.zig").Color; +const Buffer = @import("buffer.zig").Buffer; +const Rect = @import("buffer.zig").Rect; + +/// Debug overlay flags. +pub const DebugFlags = packed struct { + /// Show widget boundaries. + show_boundaries: bool = false, + /// Show FPS counter. + show_fps: bool = true, + /// Show render time. + show_render_time: bool = true, + /// Show memory usage. + show_memory: bool = false, + /// Show event log. + show_events: bool = false, + /// Show mouse position. + show_mouse: bool = false, + _padding: u2 = 0, + + pub const all: DebugFlags = .{ + .show_boundaries = true, + .show_fps = true, + .show_render_time = true, + .show_memory = true, + .show_events = true, + .show_mouse = true, + }; + + pub const minimal: DebugFlags = .{ + .show_fps = true, + }; +}; + +/// A single event log entry. +pub const EventLogEntry = struct { + timestamp_ms: u64, + message: [64]u8, + len: usize, +}; + +/// Debug overlay for TUI applications. +pub const DebugOverlay = struct { + /// Whether debug mode is enabled. + enabled: bool = false, + /// Debug display flags. + flags: DebugFlags = .{}, + /// Frame timing. + frame_start_ns: i128 = 0, + frame_end_ns: i128 = 0, + last_frame_time_ns: i128 = 0, + /// FPS tracking. + frame_count: u64 = 0, + fps_update_time: i128 = 0, + current_fps: f32 = 0, + frames_since_fps_update: u32 = 0, + /// Event log (circular buffer). + event_log: [16]EventLogEntry = undefined, + event_log_head: usize = 0, + event_log_count: usize = 0, + /// Mouse position. + mouse_x: u16 = 0, + mouse_y: u16 = 0, + /// Widget count for current frame. + widget_count: u32 = 0, + /// Render call count. + render_calls: u32 = 0, + + /// Creates a new debug overlay. + pub fn init() DebugOverlay { + return .{ + .fps_update_time = std.time.nanoTimestamp(), + }; + } + + /// Enables or disables debug mode. + pub fn setEnabled(self: *DebugOverlay, enabled: bool) void { + self.enabled = enabled; + } + + /// Toggles debug mode. + pub fn toggle(self: *DebugOverlay) void { + self.enabled = !self.enabled; + } + + /// Sets debug flags. + pub fn setFlags(self: *DebugOverlay, flags: DebugFlags) void { + self.flags = flags; + } + + /// Marks the start of a frame. + pub fn beginFrame(self: *DebugOverlay) void { + self.frame_start_ns = std.time.nanoTimestamp(); + self.widget_count = 0; + self.render_calls = 0; + } + + /// Marks the end of a frame. + pub fn endFrame(self: *DebugOverlay) void { + self.frame_end_ns = std.time.nanoTimestamp(); + self.last_frame_time_ns = self.frame_end_ns - self.frame_start_ns; + self.frame_count += 1; + self.frames_since_fps_update += 1; + + // Update FPS every second + const elapsed = self.frame_end_ns - self.fps_update_time; + if (elapsed >= std.time.ns_per_s) { + self.current_fps = @as(f32, @floatFromInt(self.frames_since_fps_update)) * + @as(f32, @floatFromInt(std.time.ns_per_s)) / @as(f32, @floatFromInt(elapsed)); + self.fps_update_time = self.frame_end_ns; + self.frames_since_fps_update = 0; + } + } + + /// Records a widget render. + pub fn recordWidget(self: *DebugOverlay) void { + self.widget_count += 1; + } + + /// Records a render call. + pub fn recordRender(self: *DebugOverlay) void { + self.render_calls += 1; + } + + /// Updates mouse position. + pub fn updateMouse(self: *DebugOverlay, x: u16, y: u16) void { + self.mouse_x = x; + self.mouse_y = y; + } + + /// Logs an event. + pub fn logEvent(self: *DebugOverlay, comptime fmt: []const u8, args: anytype) void { + var entry = &self.event_log[self.event_log_head]; + entry.timestamp_ms = @intCast(@divTrunc(std.time.nanoTimestamp(), std.time.ns_per_ms)); + const written = std.fmt.bufPrint(&entry.message, fmt, args) catch { + entry.len = 0; + return; + }; + entry.len = written.len; + + self.event_log_head = (self.event_log_head + 1) % self.event_log.len; + if (self.event_log_count < self.event_log.len) { + self.event_log_count += 1; + } + } + + /// Gets the last frame render time in milliseconds. + pub fn getFrameTimeMs(self: *const DebugOverlay) f32 { + return @as(f32, @floatFromInt(self.last_frame_time_ns)) / @as(f32, @floatFromInt(std.time.ns_per_ms)); + } + + /// Renders the debug overlay. + pub fn render(self: *const DebugOverlay, area: Rect, buf: *Buffer) void { + if (!self.enabled or area.isEmpty()) return; + + const bg_style = (Style{}).bg(Color.rgb(30, 30, 30)); + const label_style = (Style{}).fg(Color.rgb(150, 150, 150)).bg(Color.rgb(30, 30, 30)); + const value_style = (Style{}).fg(Color.green).bg(Color.rgb(30, 30, 30)); + const title_style = (Style{}).fg(Color.yellow).bg(Color.rgb(30, 30, 30)).bold(); + + // Calculate overlay size + var lines_needed: u16 = 1; // Title + if (self.flags.show_fps) lines_needed += 1; + if (self.flags.show_render_time) lines_needed += 1; + if (self.flags.show_memory) lines_needed += 1; + if (self.flags.show_mouse) lines_needed += 1; + lines_needed += 1; // Widget count + + const overlay_width: u16 = 25; + const overlay_height = lines_needed + 2; + const overlay_x = area.x + area.width -| overlay_width -| 1; + const overlay_y = area.y + 1; + + // Draw background + var y: u16 = 0; + while (y < overlay_height) : (y += 1) { + var x: u16 = 0; + while (x < overlay_width) : (x += 1) { + if (overlay_x + x < area.x + area.width and overlay_y + y < area.y + area.height) { + _ = buf.setString(overlay_x + x, overlay_y + y, " ", bg_style); + } + } + } + + // Draw content + var line: u16 = 0; + _ = buf.setString(overlay_x + 1, overlay_y + line, " DEBUG ", title_style); + line += 1; + + if (self.flags.show_fps) { + _ = buf.setString(overlay_x + 1, overlay_y + line, "FPS:", label_style); + var fps_buf: [16]u8 = undefined; + const fps_str = std.fmt.bufPrint(&fps_buf, "{d:.1}", .{self.current_fps}) catch "?"; + _ = buf.setString(overlay_x + 6, overlay_y + line, fps_str, value_style); + line += 1; + } + + if (self.flags.show_render_time) { + _ = buf.setString(overlay_x + 1, overlay_y + line, "Time:", label_style); + var time_buf: [16]u8 = undefined; + const time_str = std.fmt.bufPrint(&time_buf, "{d:.2}ms", .{self.getFrameTimeMs()}) catch "?"; + _ = buf.setString(overlay_x + 7, overlay_y + line, time_str, value_style); + line += 1; + } + + if (self.flags.show_mouse) { + _ = buf.setString(overlay_x + 1, overlay_y + line, "Mouse:", label_style); + var mouse_buf: [16]u8 = undefined; + const mouse_str = std.fmt.bufPrint(&mouse_buf, "{d},{d}", .{ self.mouse_x, self.mouse_y }) catch "?"; + _ = buf.setString(overlay_x + 8, overlay_y + line, mouse_str, value_style); + line += 1; + } + + // Widget count + _ = buf.setString(overlay_x + 1, overlay_y + line, "Widgets:", label_style); + var widget_buf: [8]u8 = undefined; + const widget_str = std.fmt.bufPrint(&widget_buf, "{d}", .{self.widget_count}) catch "?"; + _ = buf.setString(overlay_x + 10, overlay_y + line, widget_str, value_style); + } + + /// Draws a boundary around a rect (for widget debugging). + pub fn drawBoundary(self: *const DebugOverlay, rect: Rect, buf: *Buffer, color: Color) void { + if (!self.enabled or !self.flags.show_boundaries) return; + + const style = (Style{}).fg(color); + + // Top and bottom + var x: u16 = rect.x; + while (x < rect.x + rect.width) : (x += 1) { + _ = buf.setString(x, rect.y, "─", style); + if (rect.height > 0) { + _ = buf.setString(x, rect.y + rect.height -| 1, "─", style); + } + } + + // Left and right + var y: u16 = rect.y; + while (y < rect.y + rect.height) : (y += 1) { + _ = buf.setString(rect.x, y, "│", style); + if (rect.width > 0) { + _ = buf.setString(rect.x + rect.width -| 1, y, "│", style); + } + } + + // Corners + _ = buf.setString(rect.x, rect.y, "┌", style); + if (rect.width > 1) { + _ = buf.setString(rect.x + rect.width -| 1, rect.y, "┐", style); + } + if (rect.height > 1) { + _ = buf.setString(rect.x, rect.y + rect.height -| 1, "└", style); + if (rect.width > 1) { + _ = buf.setString(rect.x + rect.width -| 1, rect.y + rect.height -| 1, "┘", style); + } + } + } +}; + +/// Global debug instance for convenience. +pub var global_debug: DebugOverlay = DebugOverlay.init(); + +/// Convenience function to toggle global debug. +pub fn toggleDebug() void { + global_debug.toggle(); +} + +/// Convenience function to check if debug is enabled. +pub fn isDebugEnabled() bool { + return global_debug.enabled; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "DebugOverlay creation" { + var debug = DebugOverlay.init(); + try std.testing.expect(!debug.enabled); + + debug.setEnabled(true); + try std.testing.expect(debug.enabled); + + debug.toggle(); + try std.testing.expect(!debug.enabled); +} + +test "DebugOverlay frame timing" { + var debug = DebugOverlay.init(); + debug.setEnabled(true); + + debug.beginFrame(); + // Simulate some work + std.time.sleep(1_000_000); // 1ms + debug.endFrame(); + + try std.testing.expect(debug.last_frame_time_ns > 0); +} + +test "DebugOverlay event logging" { + var debug = DebugOverlay.init(); + + debug.logEvent("Test event {d}", .{42}); + try std.testing.expectEqual(@as(usize, 1), debug.event_log_count); +} + +test "DebugFlags" { + const flags = DebugFlags.all; + try std.testing.expect(flags.show_fps); + try std.testing.expect(flags.show_boundaries); + try std.testing.expect(flags.show_memory); +} diff --git a/src/diagnostic.zig b/src/diagnostic.zig new file mode 100644 index 0000000..3d0d5d9 --- /dev/null +++ b/src/diagnostic.zig @@ -0,0 +1,401 @@ +//! Elm-style diagnostic messages for zcatui. +//! +//! Provides beautiful, helpful error messages inspired by Elm's compiler. +//! These diagnostics show context, highlight problems, and suggest fixes. +//! +//! ## Example Output +//! +//! ``` +//! ── INVALID CONSTRAINT ───────────────────────────────────────────────────────── +//! +//! I found a constraint that doesn't make sense: +//! +//! Layout.horizontal() +//! .constraints(&.{ +//! Constraint.percentage(150), // <-- HERE +//! }) +//! +//! Percentages must be between 0 and 100, but you gave me 150. +//! +//! Hint: Did you mean to use Constraint.min(150) for a minimum size? +//! ``` + +const std = @import("std"); +const Style = @import("style.zig").Style; +const Color = @import("style.zig").Color; +const Buffer = @import("buffer.zig").Buffer; +const Rect = @import("buffer.zig").Rect; + +/// Severity level for diagnostics. +pub const Severity = enum { + /// Informational message. + hint, + /// Warning that doesn't prevent operation. + warning, + /// Error that prevents operation. + @"error", + + pub fn color(self: Severity) Color { + return switch (self) { + .hint => Color.cyan, + .warning => Color.yellow, + .@"error" => Color.red, + }; + } + + pub fn label(self: Severity) []const u8 { + return switch (self) { + .hint => "HINT", + .warning => "WARNING", + .@"error" => "ERROR", + }; + } +}; + +/// A code snippet with optional highlighting. +pub const CodeSnippet = struct { + /// Lines of code. + lines: []const []const u8, + /// Line number of first line (1-indexed). + start_line: usize = 1, + /// Column to highlight (0-indexed, optional). + highlight_col: ?usize = null, + /// Length of highlight. + highlight_len: usize = 1, + /// Line containing the highlight (relative to start_line). + highlight_line: usize = 0, +}; + +/// A diagnostic message with Elm-style formatting. +pub const Diagnostic = struct { + /// Severity of the diagnostic. + severity: Severity = .@"error", + /// Short title (e.g., "INVALID CONSTRAINT"). + title: []const u8, + /// Main explanation message. + message: []const u8, + /// Optional code snippet showing context. + snippet: ?CodeSnippet = null, + /// Optional hint for fixing the issue. + hint: ?[]const u8 = null, + /// Optional "see also" reference. + see_also: ?[]const u8 = null, + + /// Creates a new error diagnostic. + pub fn err(title: []const u8, message: []const u8) Diagnostic { + return .{ + .severity = .@"error", + .title = title, + .message = message, + }; + } + + /// Creates a new warning diagnostic. + pub fn warning(title: []const u8, message: []const u8) Diagnostic { + return .{ + .severity = .warning, + .title = title, + .message = message, + }; + } + + /// Creates a new hint diagnostic. + pub fn hintDiag(title: []const u8, message: []const u8) Diagnostic { + return .{ + .severity = .hint, + .title = title, + .message = message, + }; + } + + /// Adds a code snippet. + pub fn withSnippet(self: Diagnostic, snippet: CodeSnippet) Diagnostic { + var d = self; + d.snippet = snippet; + return d; + } + + /// Adds a hint. + pub fn withHint(self: Diagnostic, h: []const u8) Diagnostic { + var d = self; + d.hint = h; + return d; + } + + /// Adds a "see also" reference. + pub fn withSeeAlso(self: Diagnostic, ref: []const u8) Diagnostic { + var d = self; + d.see_also = ref; + return d; + } + + /// Formats the diagnostic as a string. + pub fn format(self: *const Diagnostic, allocator: std.mem.Allocator) ![]u8 { + var result = std.ArrayList(u8).init(allocator); + const writer = result.writer(); + + // Header line with title + try writer.writeAll("── "); + try writer.writeAll(self.title); + try writer.writeAll(" "); + // Fill with dashes to ~80 chars + const title_len = self.title.len + 4; + const dashes_needed = if (title_len < 76) 76 - title_len else 0; + for (0..dashes_needed) |_| { + try writer.writeByte('-'); + } + try writer.writeAll("\n\n"); + + // Main message + try writer.writeAll(self.message); + try writer.writeAll("\n"); + + // Code snippet + if (self.snippet) |snippet| { + try writer.writeAll("\n"); + for (snippet.lines, 0..) |line, i| { + const line_num = snippet.start_line + i; + try writer.print(" {d: >4} │ {s}\n", .{ line_num, line }); + + // Highlight line + if (i == snippet.highlight_line) { + if (snippet.highlight_col) |col| { + try writer.writeAll(" │ "); + for (0..col) |_| { + try writer.writeByte(' '); + } + for (0..snippet.highlight_len) |_| { + try writer.writeByte('^'); + } + try writer.writeAll("\n"); + } + } + } + try writer.writeAll("\n"); + } + + // Hint + if (self.hint) |h| { + try writer.writeAll("\nHint: "); + try writer.writeAll(h); + try writer.writeAll("\n"); + } + + // See also + if (self.see_also) |ref| { + try writer.writeAll("\nSee: "); + try writer.writeAll(ref); + try writer.writeAll("\n"); + } + + return result.toOwnedSlice(); + } + + /// Renders the diagnostic to a buffer. + pub fn render(self: *const Diagnostic, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + const title_style = (Style{}).fg(self.severity.color()).bold(); + const normal_style = Style{}; + const line_num_style = (Style{}).fg(Color.rgb(100, 100, 100)); + const highlight_style = (Style{}).fg(self.severity.color()).bold(); + + var y: u16 = area.y; + + // Header + _ = buf.setString(area.x, y, "── ", title_style); + _ = buf.setString(area.x + 3, y, self.title, title_style); + const title_end = area.x + 3 + @as(u16, @intCast(@min(self.title.len, 60))); + var x = title_end + 1; + while (x < area.x + area.width -| 1) : (x += 1) { + _ = buf.setString(x, y, "─", title_style); + } + y += 2; + + // Message (wrap lines) + var msg_lines = std.mem.splitScalar(u8, self.message, '\n'); + while (msg_lines.next()) |line| { + if (y >= area.y + area.height) break; + const max_len = @min(line.len, area.width -| 2); + _ = buf.setString(area.x, y, line[0..max_len], normal_style); + y += 1; + } + y += 1; + + // Code snippet + if (self.snippet) |snippet| { + for (snippet.lines, 0..) |line, i| { + if (y >= area.y + area.height -| 3) break; + + const line_num = snippet.start_line + i; + var num_buf: [8]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d: >4}", .{line_num}) catch "????"; + _ = buf.setString(area.x + 2, y, num_str, line_num_style); + _ = buf.setString(area.x + 7, y, "│", line_num_style); + + const max_line_len = @min(line.len, area.width -| 10); + _ = buf.setString(area.x + 9, y, line[0..max_line_len], normal_style); + y += 1; + + // Highlight + if (i == snippet.highlight_line) { + if (snippet.highlight_col) |col| { + _ = buf.setString(area.x + 7, y, "│", line_num_style); + const highlight_x = area.x + 9 + @as(u16, @intCast(col)); + var hx: u16 = 0; + while (hx < snippet.highlight_len) : (hx += 1) { + if (highlight_x + hx < area.x + area.width) { + _ = buf.setString(highlight_x + hx, y, "^", highlight_style); + } + } + y += 1; + } + } + } + y += 1; + } + + // Hint + if (self.hint) |h| { + if (y < area.y + area.height -| 1) { + _ = buf.setString(area.x, y, "Hint: ", (Style{}).fg(Color.cyan).bold()); + const max_hint_len = @min(h.len, area.width -| 8); + _ = buf.setString(area.x + 6, y, h[0..max_hint_len], normal_style); + y += 1; + } + } + + // See also + if (self.see_also) |ref| { + if (y < area.y + area.height) { + _ = buf.setString(area.x, y, "See: ", (Style{}).fg(Color.blue)); + const max_ref_len = @min(ref.len, area.width -| 6); + _ = buf.setString(area.x + 5, y, ref[0..max_ref_len], (Style{}).fg(Color.blue).underline()); + } + } + } +}; + +/// Builder for creating diagnostics with common patterns. +pub const DiagnosticBuilder = struct { + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) DiagnosticBuilder { + return .{ .allocator = allocator }; + } + + /// Creates a "value out of range" diagnostic. + pub fn outOfRange( + self: DiagnosticBuilder, + what: []const u8, + value: anytype, + min: anytype, + max: anytype, + ) Diagnostic { + _ = self; + _ = value; + _ = min; + _ = max; + return Diagnostic.err("VALUE OUT OF RANGE", what) + .withHint("Check that your value is within the valid range."); + } + + /// Creates a "missing required field" diagnostic. + pub fn missingField(self: DiagnosticBuilder, struct_name: []const u8, field_name: []const u8) Diagnostic { + _ = self; + _ = struct_name; + return Diagnostic.err("MISSING REQUIRED FIELD", field_name) + .withHint("Add the missing field to complete the configuration."); + } + + /// Creates a "type mismatch" diagnostic. + pub fn typeMismatch(self: DiagnosticBuilder, expected: []const u8, got: []const u8) Diagnostic { + _ = self; + _ = expected; + return Diagnostic.err("TYPE MISMATCH", got) + .withHint("Make sure you're using the correct type."); + } +}; + +// ============================================================================ +// Pre-built diagnostics for common zcatui errors +// ============================================================================ + +/// Diagnostic for invalid percentage constraint. +pub fn invalidPercentage(value: u16) Diagnostic { + return Diagnostic.err( + "INVALID PERCENTAGE", + "Percentage constraints must be between 0 and 100.", + ).withSnippet(.{ + .lines = &.{ + "Layout.horizontal()", + " .constraints(&.{", + " Constraint.percentage(???),", + " })", + }, + .start_line = 1, + .highlight_line = 2, + .highlight_col = 30, + .highlight_len = 3, + }).withHint(if (value > 100) + "Did you mean to use Constraint.min() for a minimum pixel size?" + else + "Use a value between 0 and 100."); +} + +/// Diagnostic for empty layout constraints. +pub fn emptyConstraints() Diagnostic { + return Diagnostic.err( + "EMPTY CONSTRAINTS", + "A Layout needs at least one constraint to split the area.", + ).withHint("Add constraints like Constraint.percentage(50) or Constraint.min(10)."); +} + +/// Diagnostic for widget rendered outside bounds. +pub fn widgetOutOfBounds(widget_name: []const u8) Diagnostic { + _ = widget_name; + return Diagnostic.warning( + "WIDGET OUT OF BOUNDS", + "A widget is being rendered outside its designated area.", + ).withHint("Check that your layout constraints sum to 100% or fit within the available space."); +} + +/// Diagnostic for invalid color value. +pub fn invalidColor(component: []const u8, value: u16) Diagnostic { + _ = component; + _ = value; + return Diagnostic.err( + "INVALID COLOR", + "RGB color components must be between 0 and 255.", + ).withHint("Use Color.rgb(r, g, b) with values from 0 to 255."); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "Diagnostic creation" { + const d = Diagnostic.err("TEST ERROR", "This is a test message") + .withHint("Try this instead") + .withSeeAlso("https://example.com"); + + try std.testing.expectEqualStrings("TEST ERROR", d.title); + try std.testing.expectEqualStrings("This is a test message", d.message); + try std.testing.expectEqualStrings("Try this instead", d.hint.?); +} + +test "Diagnostic format" { + const d = Diagnostic.err("TEST", "Message"); + const formatted = try d.format(std.testing.allocator); + defer std.testing.allocator.free(formatted); + + try std.testing.expect(std.mem.indexOf(u8, formatted, "TEST") != null); + try std.testing.expect(std.mem.indexOf(u8, formatted, "Message") != null); +} + +test "invalidPercentage diagnostic" { + const d = invalidPercentage(150); + try std.testing.expectEqualStrings("INVALID PERCENTAGE", d.title); + try std.testing.expect(d.snippet != null); + try std.testing.expect(d.hint != null); +} diff --git a/src/drag.zig b/src/drag.zig new file mode 100644 index 0000000..21797d1 --- /dev/null +++ b/src/drag.zig @@ -0,0 +1,342 @@ +//! Mouse drag and drop support for zcatui. +//! +//! Provides drag state tracking and utilities for implementing +//! draggable UI elements like resizable panels and splitters. +//! +//! ## Example +//! +//! ```zig +//! var drag_state = DragState{}; +//! +//! switch (event.mouse.kind) { +//! .down => { +//! if (isOnSplitter(event.mouse.column, event.mouse.row)) { +//! drag_state.start(.horizontal_resize, event.mouse.column, event.mouse.row); +//! } +//! }, +//! .drag => drag_state.update(event.mouse.column, event.mouse.row), +//! .up => drag_state.end(), +//! else => {}, +//! } +//! ``` + +const std = @import("std"); +const Rect = @import("buffer.zig").Rect; + +/// The type of drag operation in progress. +pub const DragType = enum { + /// No drag in progress. + none, + /// Horizontal resize (dragging left/right). + horizontal_resize, + /// Vertical resize (dragging up/down). + vertical_resize, + /// Moving/reordering an element. + move, + /// Selection drag (e.g., text selection). + selection, + /// Custom drag type for user-defined operations. + custom, +}; + +/// State for tracking mouse drag operations. +pub const DragState = struct { + /// Current drag type. + drag_type: DragType = .none, + /// Starting X position of drag. + start_x: u16 = 0, + /// Starting Y position of drag. + start_y: u16 = 0, + /// Current X position. + current_x: u16 = 0, + /// Current Y position. + current_y: u16 = 0, + /// Custom data associated with drag (e.g., panel index). + data: usize = 0, + /// Whether the drag has moved from the start position. + has_moved: bool = false, + + /// Starts a new drag operation. + pub fn start(self: *DragState, drag_type: DragType, x: u16, y: u16) void { + self.drag_type = drag_type; + self.start_x = x; + self.start_y = y; + self.current_x = x; + self.current_y = y; + self.has_moved = false; + } + + /// Starts a drag with associated data. + pub fn startWithData(self: *DragState, drag_type: DragType, x: u16, y: u16, data: usize) void { + self.start(drag_type, x, y); + self.data = data; + } + + /// Updates the drag position. + pub fn update(self: *DragState, x: u16, y: u16) void { + if (self.drag_type == .none) return; + self.current_x = x; + self.current_y = y; + if (x != self.start_x or y != self.start_y) { + self.has_moved = true; + } + } + + /// Ends the drag operation. + pub fn end(self: *DragState) void { + self.drag_type = .none; + self.has_moved = false; + self.data = 0; + } + + /// Checks if a drag is in progress. + pub fn isDragging(self: *const DragState) bool { + return self.drag_type != .none; + } + + /// Returns the horizontal delta from start. + pub fn deltaX(self: *const DragState) i32 { + return @as(i32, self.current_x) - @as(i32, self.start_x); + } + + /// Returns the vertical delta from start. + pub fn deltaY(self: *const DragState) i32 { + return @as(i32, self.current_y) - @as(i32, self.start_y); + } + + /// Returns the absolute horizontal distance. + pub fn distanceX(self: *const DragState) u16 { + const dx = self.deltaX(); + return @intCast(if (dx < 0) -dx else dx); + } + + /// Returns the absolute vertical distance. + pub fn distanceY(self: *const DragState) u16 { + const dy = self.deltaY(); + return @intCast(if (dy < 0) -dy else dy); + } +}; + +/// Configuration for resizable splitters. +pub const SplitterConfig = struct { + /// Minimum size for the first panel (left/top). + min_first: u16 = 5, + /// Minimum size for the second panel (right/bottom). + min_second: u16 = 5, + /// Width/height of the splitter handle. + handle_size: u16 = 1, + /// Whether to show a visual indicator on the splitter. + show_indicator: bool = true, +}; + +/// Splitter handle for resizable panels. +pub const Splitter = struct { + /// Orientation of the splitter. + direction: Direction, + /// Position of the splitter (percentage 0-100 or absolute). + position: u16, + /// Whether position is percentage or absolute. + is_percentage: bool = true, + /// Configuration options. + config: SplitterConfig = .{}, + + pub const Direction = enum { horizontal, vertical }; + + /// Creates a horizontal splitter (splits left/right). + pub fn horizontal(position_percent: u16) Splitter { + return .{ + .direction = .horizontal, + .position = position_percent, + }; + } + + /// Creates a vertical splitter (splits top/bottom). + pub fn vertical(position_percent: u16) Splitter { + return .{ + .direction = .vertical, + .position = position_percent, + }; + } + + /// Sets minimum sizes. + pub fn setMinSizes(self: Splitter, first: u16, second: u16) Splitter { + var s = self; + s.config.min_first = first; + s.config.min_second = second; + return s; + } + + /// Calculates the split areas given a total area. + pub fn split(self: *const Splitter, area: Rect) struct { first: Rect, second: Rect, handle: Rect } { + if (self.direction == .horizontal) { + // Split left/right + const total_width = area.width; + var first_width: u16 = if (self.is_percentage) + @intCast(@as(u32, total_width) * self.position / 100) + else + self.position; + + // Enforce minimums + first_width = @max(first_width, self.config.min_first); + first_width = @min(first_width, total_width -| self.config.min_second -| self.config.handle_size); + + const handle_x = area.x + first_width; + const second_x = handle_x + self.config.handle_size; + const second_width = total_width -| first_width -| self.config.handle_size; + + return .{ + .first = Rect.init(area.x, area.y, first_width, area.height), + .second = Rect.init(second_x, area.y, second_width, area.height), + .handle = Rect.init(handle_x, area.y, self.config.handle_size, area.height), + }; + } else { + // Split top/bottom + const total_height = area.height; + var first_height: u16 = if (self.is_percentage) + @intCast(@as(u32, total_height) * self.position / 100) + else + self.position; + + // Enforce minimums + first_height = @max(first_height, self.config.min_first); + first_height = @min(first_height, total_height -| self.config.min_second -| self.config.handle_size); + + const handle_y = area.y + first_height; + const second_y = handle_y + self.config.handle_size; + const second_height = total_height -| first_height -| self.config.handle_size; + + return .{ + .first = Rect.init(area.x, area.y, area.width, first_height), + .second = Rect.init(area.x, second_y, area.width, second_height), + .handle = Rect.init(area.x, handle_y, area.width, self.config.handle_size), + }; + } + } + + /// Checks if a point is on the splitter handle. + pub fn isOnHandle(self: *const Splitter, area: Rect, x: u16, y: u16) bool { + const parts = self.split(area); + return parts.handle.contains(x, y); + } + + /// Updates position based on drag delta. + pub fn adjustPosition(self: *Splitter, area: Rect, delta: i32) void { + if (self.direction == .horizontal) { + const total = area.width; + if (self.is_percentage) { + // Convert delta to percentage + const delta_percent: i32 = @intCast(@divTrunc(@as(i64, delta) * 100, @as(i64, total))); + const new_pos = @as(i32, self.position) + delta_percent; + self.position = @intCast(@max(0, @min(100, new_pos))); + } else { + const new_pos = @as(i32, self.position) + delta; + self.position = @intCast(@max(0, @min(@as(i32, total), new_pos))); + } + } else { + const total = area.height; + if (self.is_percentage) { + const delta_percent: i32 = @intCast(@divTrunc(@as(i64, delta) * 100, @as(i64, total))); + const new_pos = @as(i32, self.position) + delta_percent; + self.position = @intCast(@max(0, @min(100, new_pos))); + } else { + const new_pos = @as(i32, self.position) + delta; + self.position = @intCast(@max(0, @min(@as(i32, total), new_pos))); + } + } + } +}; + +/// Helper to process mouse events for drag operations. +pub fn processDragEvent( + drag_state: *DragState, + kind: @import("event.zig").MouseEventKind, + x: u16, + y: u16, + check_start: *const fn (u16, u16) ?DragType, +) bool { + switch (kind) { + .down => { + if (check_start(x, y)) |drag_type| { + drag_state.start(drag_type, x, y); + return true; + } + }, + .drag => { + if (drag_state.isDragging()) { + drag_state.update(x, y); + return true; + } + }, + .up => { + if (drag_state.isDragging()) { + drag_state.end(); + return true; + } + }, + else => {}, + } + return false; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "DragState basic operations" { + var state = DragState{}; + + try std.testing.expect(!state.isDragging()); + + state.start(.horizontal_resize, 10, 20); + try std.testing.expect(state.isDragging()); + try std.testing.expectEqual(@as(u16, 10), state.start_x); + try std.testing.expectEqual(@as(u16, 20), state.start_y); + + state.update(15, 25); + try std.testing.expectEqual(@as(i32, 5), state.deltaX()); + try std.testing.expectEqual(@as(i32, 5), state.deltaY()); + try std.testing.expect(state.has_moved); + + state.end(); + try std.testing.expect(!state.isDragging()); +} + +test "Splitter horizontal split" { + var splitter = Splitter.horizontal(50); + const area = Rect.init(0, 0, 100, 50); + const parts = splitter.split(area); + + try std.testing.expectEqual(@as(u16, 50), parts.first.width); + try std.testing.expectEqual(@as(u16, 49), parts.second.width); // 100 - 50 - 1 handle + try std.testing.expectEqual(@as(u16, 1), parts.handle.width); +} + +test "Splitter vertical split" { + var splitter = Splitter.vertical(30); + const area = Rect.init(0, 0, 80, 100); + const parts = splitter.split(area); + + try std.testing.expectEqual(@as(u16, 30), parts.first.height); + try std.testing.expectEqual(@as(u16, 69), parts.second.height); // 100 - 30 - 1 handle + try std.testing.expectEqual(@as(u16, 1), parts.handle.height); +} + +test "Splitter isOnHandle" { + var splitter = Splitter.horizontal(50); + const area = Rect.init(0, 0, 100, 50); + + try std.testing.expect(splitter.isOnHandle(area, 50, 25)); // On handle + try std.testing.expect(!splitter.isOnHandle(area, 25, 25)); // In first panel + try std.testing.expect(!splitter.isOnHandle(area, 75, 25)); // In second panel +} + +test "Splitter adjustPosition" { + var splitter = Splitter.horizontal(50); + const area = Rect.init(0, 0, 100, 50); + + splitter.adjustPosition(area, 10); // Drag right + try std.testing.expectEqual(@as(u16, 60), splitter.position); + + splitter.adjustPosition(area, -20); // Drag left + try std.testing.expectEqual(@as(u16, 40), splitter.position); +} diff --git a/src/layout.zig b/src/layout.zig index 47511aa..e1e473e 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -68,7 +68,62 @@ pub const Constraint = union(enum) { /// Creates a ratio constraint. pub fn ratio(num: u32, den: u32) Constraint { - return .{ .rat = .{ .num = num, .den = den } }; + return .{ .rat = .{ .num = num, .den = if (den == 0) 1 else den } }; + } + + // ======================================== + // Common ratio helpers + // ======================================== + + /// Half of available space (1/2) + pub fn half() Constraint { + return ratio(1, 2); + } + + /// One third of available space (1/3) + pub fn third() Constraint { + return ratio(1, 3); + } + + /// Two thirds of available space (2/3) + pub fn twoThirds() Constraint { + return ratio(2, 3); + } + + /// One quarter of available space (1/4) + pub fn quarter() Constraint { + return ratio(1, 4); + } + + /// Three quarters of available space (3/4) + pub fn threeQuarters() Constraint { + return ratio(3, 4); + } + + /// One fifth of available space (1/5) + pub fn fifth() Constraint { + return ratio(1, 5); + } + + /// Golden ratio - larger portion (~61.8%) + pub fn goldenLarge() Constraint { + return ratio(618, 1000); + } + + /// Golden ratio - smaller portion (~38.2%) + pub fn goldenSmall() Constraint { + return ratio(382, 1000); + } + + /// Fill remaining space (equivalent to min(0) but more readable) + pub fn fill() Constraint { + return .{ .min_size = 0 }; + } + + /// Proportional constraint - takes N parts out of total parts + /// Example: prop(2, 5) means 2 parts when total is 5 parts + pub fn prop(parts: u32, total_parts: u32) Constraint { + return ratio(parts, if (total_parts == 0) 1 else total_parts); } }; @@ -608,3 +663,106 @@ test "alignRight helper" { try std.testing.expectEqual(@as(u16, 20), inner.width); try std.testing.expectEqual(@as(u16, 50), inner.height); } + +// ============================================================================ +// Ratio Constraint Tests +// ============================================================================ + +test "Layout ratio thirds" { + const area = Rect.init(0, 0, 90, 10); + const layout = Layout.horizontal(&.{ + Constraint.third(), + Constraint.third(), + Constraint.third(), + }); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(usize, 3), result.count); + try std.testing.expectEqual(@as(u16, 30), result.rects[0].width); + try std.testing.expectEqual(@as(u16, 30), result.rects[1].width); + try std.testing.expectEqual(@as(u16, 30), result.rects[2].width); +} + +test "Layout ratio halves" { + const area = Rect.init(0, 0, 100, 10); + const layout = Layout.horizontal(&.{ + Constraint.half(), + Constraint.half(), + }); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(usize, 2), result.count); + try std.testing.expectEqual(@as(u16, 50), result.rects[0].width); + try std.testing.expectEqual(@as(u16, 50), result.rects[1].width); +} + +test "Layout golden ratio" { + const area = Rect.init(0, 0, 100, 10); + const layout = Layout.horizontal(&.{ + Constraint.goldenSmall(), + Constraint.goldenLarge(), + }); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(usize, 2), result.count); + // 38.2% of 100 = 38, 61.8% of 100 = 61 + try std.testing.expectEqual(@as(u16, 38), result.rects[0].width); + try std.testing.expectEqual(@as(u16, 61), result.rects[1].width); +} + +test "Layout ratio with fixed" { + const area = Rect.init(0, 0, 100, 10); + const layout = Layout.horizontal(&.{ + Constraint.length(20), // Fixed sidebar + Constraint.fill(), // Fill rest + }); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(usize, 2), result.count); + try std.testing.expectEqual(@as(u16, 20), result.rects[0].width); + try std.testing.expectEqual(@as(u16, 80), result.rects[1].width); +} + +test "Layout proportional parts" { + const area = Rect.init(0, 0, 100, 10); + const layout = Layout.horizontal(&.{ + Constraint.prop(1, 4), // 25% + Constraint.prop(2, 4), // 50% + Constraint.prop(1, 4), // 25% + }); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(usize, 3), result.count); + try std.testing.expectEqual(@as(u16, 25), result.rects[0].width); + try std.testing.expectEqual(@as(u16, 50), result.rects[1].width); + try std.testing.expectEqual(@as(u16, 25), result.rects[2].width); +} + +test "Layout ratio zero denominator protection" { + // Should not crash, denominator becomes 1 + const c = Constraint.ratio(1, 0); + try std.testing.expectEqual(@as(u32, 1), c.rat.den); +} + +test "Layout vertical ratio" { + const area = Rect.init(0, 0, 80, 24); + const layout = Layout.vertical(&.{ + Constraint.length(3), // Header + Constraint.twoThirds(), // Content (2/3 of remaining) + Constraint.third(), // Footer (1/3 of remaining) + }); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(usize, 3), result.count); + try std.testing.expectEqual(@as(u16, 3), result.rects[0].height); // Header fixed + // Remaining: 24 - 3 = 21, but ratio is of total + // 2/3 of 24 = 16, 1/3 of 24 = 8 + try std.testing.expectEqual(@as(u16, 16), result.rects[1].height); + try std.testing.expectEqual(@as(u16, 5), result.rects[2].height); // Remaining after consuming +} diff --git a/src/profile.zig b/src/profile.zig new file mode 100644 index 0000000..7872c4e --- /dev/null +++ b/src/profile.zig @@ -0,0 +1,311 @@ +//! Performance profiling for zcatui. +//! +//! Provides timing and profiling tools for measuring render performance. +//! +//! ## Usage +//! +//! ```zig +//! var profiler = Profiler.init(); +//! +//! profiler.begin("render"); +//! // ... render code ... +//! profiler.end("render"); +//! +//! // Get statistics +//! if (profiler.getStats("render")) |stats| { +//! std.debug.print("Avg: {d}ms\n", .{stats.avg_ms}); +//! } +//! ``` + +const std = @import("std"); + +/// Maximum number of named timers. +const MAX_TIMERS = 32; +/// Number of samples to keep for averaging. +const SAMPLE_COUNT = 60; + +/// Statistics for a profiled section. +pub const ProfileStats = struct { + /// Minimum time in nanoseconds. + min_ns: i128 = std.math.maxInt(i128), + /// Maximum time in nanoseconds. + max_ns: i128 = 0, + /// Total time accumulated. + total_ns: i128 = 0, + /// Number of samples. + count: u64 = 0, + /// Recent samples for moving average. + samples: [SAMPLE_COUNT]i128 = [_]i128{0} ** SAMPLE_COUNT, + sample_index: usize = 0, + sample_count: usize = 0, + + /// Returns average time in milliseconds. + pub fn avgMs(self: *const ProfileStats) f64 { + if (self.count == 0) return 0; + const avg_ns = @divTrunc(self.total_ns, @as(i128, @intCast(self.count))); + return @as(f64, @floatFromInt(avg_ns)) / 1_000_000.0; + } + + /// Returns minimum time in milliseconds. + pub fn minMs(self: *const ProfileStats) f64 { + if (self.min_ns == std.math.maxInt(i128)) return 0; + return @as(f64, @floatFromInt(self.min_ns)) / 1_000_000.0; + } + + /// Returns maximum time in milliseconds. + pub fn maxMs(self: *const ProfileStats) f64 { + return @as(f64, @floatFromInt(self.max_ns)) / 1_000_000.0; + } + + /// Returns recent average in milliseconds (last N samples). + pub fn recentAvgMs(self: *const ProfileStats) f64 { + if (self.sample_count == 0) return 0; + var sum: i128 = 0; + for (0..self.sample_count) |i| { + sum += self.samples[i]; + } + const avg = @divTrunc(sum, @as(i128, @intCast(self.sample_count))); + return @as(f64, @floatFromInt(avg)) / 1_000_000.0; + } + + fn addSample(self: *ProfileStats, ns: i128) void { + self.total_ns += ns; + self.count += 1; + self.min_ns = @min(self.min_ns, ns); + self.max_ns = @max(self.max_ns, ns); + + self.samples[self.sample_index] = ns; + self.sample_index = (self.sample_index + 1) % SAMPLE_COUNT; + if (self.sample_count < SAMPLE_COUNT) { + self.sample_count += 1; + } + } + + fn reset(self: *ProfileStats) void { + self.* = .{}; + } +}; + +/// A named timer entry. +const TimerEntry = struct { + name: [32]u8 = undefined, + name_len: usize = 0, + start_time: i128 = 0, + stats: ProfileStats = .{}, + active: bool = false, + + fn setName(self: *TimerEntry, name: []const u8) void { + const len = @min(name.len, 32); + @memcpy(self.name[0..len], name[0..len]); + self.name_len = len; + } + + fn getName(self: *const TimerEntry) []const u8 { + return self.name[0..self.name_len]; + } +}; + +/// Performance profiler. +pub const Profiler = struct { + timers: [MAX_TIMERS]TimerEntry = [_]TimerEntry{.{}} ** MAX_TIMERS, + timer_count: usize = 0, + enabled: bool = true, + + /// Creates a new profiler. + pub fn init() Profiler { + return .{}; + } + + /// Enables or disables profiling. + pub fn setEnabled(self: *Profiler, enabled: bool) void { + self.enabled = enabled; + } + + /// Finds or creates a timer by name. + fn getOrCreateTimer(self: *Profiler, name: []const u8) ?*TimerEntry { + // Look for existing + for (&self.timers) |*timer| { + if (timer.name_len > 0 and std.mem.eql(u8, timer.getName(), name)) { + return timer; + } + } + + // Create new + if (self.timer_count < MAX_TIMERS) { + var timer = &self.timers[self.timer_count]; + timer.setName(name); + self.timer_count += 1; + return timer; + } + + return null; + } + + /// Begins timing a named section. + pub fn begin(self: *Profiler, name: []const u8) void { + if (!self.enabled) return; + + if (self.getOrCreateTimer(name)) |timer| { + timer.start_time = std.time.nanoTimestamp(); + timer.active = true; + } + } + + /// Ends timing a named section. + pub fn end(self: *Profiler, name: []const u8) void { + if (!self.enabled) return; + + const end_time = std.time.nanoTimestamp(); + + if (self.getOrCreateTimer(name)) |timer| { + if (timer.active) { + const elapsed = end_time - timer.start_time; + timer.stats.addSample(elapsed); + timer.active = false; + } + } + } + + /// Gets statistics for a named timer. + pub fn getStats(self: *const Profiler, name: []const u8) ?*const ProfileStats { + for (&self.timers) |*timer| { + if (timer.name_len > 0 and std.mem.eql(u8, timer.getName(), name)) { + return &timer.stats; + } + } + return null; + } + + /// Resets all statistics. + pub fn reset(self: *Profiler) void { + for (&self.timers) |*timer| { + timer.stats.reset(); + } + } + + /// Returns an iterator over all timers with data. + pub fn iterator(self: *const Profiler) TimerIterator { + return .{ .profiler = self, .index = 0 }; + } + + pub const TimerIterator = struct { + profiler: *const Profiler, + index: usize, + + pub fn next(self: *TimerIterator) ?struct { name: []const u8, stats: *const ProfileStats } { + while (self.index < self.profiler.timer_count) { + const timer = &self.profiler.timers[self.index]; + self.index += 1; + if (timer.name_len > 0 and timer.stats.count > 0) { + return .{ .name = timer.getName(), .stats = &timer.stats }; + } + } + return null; + } + }; +}; + +/// Scoped timer that automatically ends on scope exit. +pub const ScopedTimer = struct { + profiler: *Profiler, + name: []const u8, + + pub fn init(profiler: *Profiler, name: []const u8) ScopedTimer { + profiler.begin(name); + return .{ .profiler = profiler, .name = name }; + } + + pub fn deinit(self: ScopedTimer) void { + self.profiler.end(self.name); + } +}; + +/// Global profiler instance. +pub var global_profiler: Profiler = Profiler.init(); + +/// Convenience function to begin timing. +pub fn begin(name: []const u8) void { + global_profiler.begin(name); +} + +/// Convenience function to end timing. +pub fn end(name: []const u8) void { + global_profiler.end(name); +} + +/// Creates a scoped timer on the global profiler. +pub fn scoped(name: []const u8) ScopedTimer { + return ScopedTimer.init(&global_profiler, name); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "Profiler basic timing" { + var profiler = Profiler.init(); + + profiler.begin("test"); + std.time.sleep(1_000_000); // 1ms + profiler.end("test"); + + const stats = profiler.getStats("test").?; + try std.testing.expect(stats.count == 1); + try std.testing.expect(stats.avgMs() > 0); +} + +test "Profiler multiple samples" { + var profiler = Profiler.init(); + + for (0..10) |_| { + profiler.begin("loop"); + std.time.sleep(100_000); // 0.1ms + profiler.end("loop"); + } + + const stats = profiler.getStats("loop").?; + try std.testing.expectEqual(@as(u64, 10), stats.count); +} + +test "ProfileStats calculations" { + var stats = ProfileStats{}; + + stats.addSample(1_000_000); // 1ms + stats.addSample(2_000_000); // 2ms + stats.addSample(3_000_000); // 3ms + + try std.testing.expectEqual(@as(u64, 3), stats.count); + try std.testing.expect(stats.avgMs() > 1.9 and stats.avgMs() < 2.1); + try std.testing.expect(stats.minMs() > 0.9 and stats.minMs() < 1.1); + try std.testing.expect(stats.maxMs() > 2.9 and stats.maxMs() < 3.1); +} + +test "Profiler iterator" { + var profiler = Profiler.init(); + + profiler.begin("a"); + profiler.end("a"); + profiler.begin("b"); + profiler.end("b"); + + var count: usize = 0; + var iter = profiler.iterator(); + while (iter.next()) |_| { + count += 1; + } + + try std.testing.expectEqual(@as(usize, 2), count); +} + +test "ScopedTimer" { + var profiler = Profiler.init(); + + { + var timer = ScopedTimer.init(&profiler, "scoped"); + defer timer.deinit(); + std.time.sleep(100_000); + } + + const stats = profiler.getStats("scoped").?; + try std.testing.expectEqual(@as(u64, 1), stats.count); +} diff --git a/src/resize.zig b/src/resize.zig new file mode 100644 index 0000000..a5b391b --- /dev/null +++ b/src/resize.zig @@ -0,0 +1,217 @@ +//! Terminal resize handling for zcatui. +//! +//! Provides automatic terminal resize detection using SIGWINCH signal handler. +//! This allows TUI applications to respond to terminal size changes gracefully. +//! +//! ## Usage +//! +//! ```zig +//! var resize_handler = try ResizeHandler.init(); +//! defer resize_handler.deinit(); +//! +//! // In your main loop: +//! if (resize_handler.hasResized()) { +//! const new_size = resize_handler.getSize(); +//! try term.resize(new_size.width, new_size.height); +//! } +//! ``` + +const std = @import("std"); + +/// Terminal size. +pub const Size = struct { + width: u16, + height: u16, + + /// Checks if two sizes are equal. + pub fn eql(self: Size, other: Size) bool { + return self.width == other.width and self.height == other.height; + } +}; + +/// Global flag set by SIGWINCH handler. +/// Using a simple atomic bool for thread-safe access. +var resize_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); + +/// Cached size from last check. +var cached_size: Size = .{ .width = 80, .height = 24 }; + +/// SIGWINCH signal handler. +fn sigwinchHandler(_: c_int) callconv(.c) void { + resize_pending.store(true, .release); +} + +/// Resize handler for terminal resize events. +/// +/// Uses SIGWINCH to detect terminal size changes efficiently. +/// The handler sets a flag which can be checked in the event loop. +pub const ResizeHandler = struct { + original_handler: ?std.posix.Sigaction = null, + auto_resize_callback: ?*const fn (Size) void = null, + last_known_size: Size, + + /// Initializes the resize handler. + /// + /// Installs a SIGWINCH handler to detect terminal resizes. + pub fn init() ResizeHandler { + const initial_size = queryTerminalSize(); + cached_size = initial_size; + + // Install SIGWINCH handler + var action = std.posix.Sigaction{ + .handler = .{ .handler = sigwinchHandler }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }; + + var old_action: std.posix.Sigaction = undefined; + std.posix.sigaction(std.posix.SIG.WINCH, &action, &old_action); + + return .{ + .original_handler = old_action, + .last_known_size = initial_size, + }; + } + + /// Cleans up the resize handler. + /// + /// Restores the original SIGWINCH handler. + pub fn deinit(self: *ResizeHandler) void { + if (self.original_handler) |original| { + var restore = original; + std.posix.sigaction(std.posix.SIG.WINCH, &restore, null); + } + } + + /// Checks if a resize has occurred since the last check. + /// + /// This is a non-blocking check. Returns true if SIGWINCH was received. + pub fn hasResized(self: *ResizeHandler) bool { + if (resize_pending.swap(false, .acquire)) { + const new_size = queryTerminalSize(); + if (!new_size.eql(self.last_known_size)) { + self.last_known_size = new_size; + cached_size = new_size; + return true; + } + } + return false; + } + + /// Gets the current terminal size. + /// + /// Always queries the terminal directly for the latest size. + pub fn getSize(self: *const ResizeHandler) Size { + _ = self; + return queryTerminalSize(); + } + + /// Gets the last known size without querying the terminal. + pub fn getLastKnownSize(self: *const ResizeHandler) Size { + return self.last_known_size; + } + + /// Sets a callback to be called when resize is detected. + /// + /// The callback receives the new size. + pub fn setAutoResizeCallback(self: *ResizeHandler, callback: *const fn (Size) void) void { + self.auto_resize_callback = callback; + } + + /// Processes any pending resize events. + /// + /// If a resize has occurred and a callback is set, the callback is invoked. + /// Returns the new size if resize occurred, null otherwise. + pub fn processResize(self: *ResizeHandler) ?Size { + if (self.hasResized()) { + if (self.auto_resize_callback) |callback| { + callback(self.last_known_size); + } + return self.last_known_size; + } + return null; + } +}; + +/// Queries the terminal size directly. +/// +/// Uses TIOCGWINSZ ioctl to get the current terminal dimensions. +pub fn queryTerminalSize() Size { + const winsize = extern struct { + row: u16, + col: u16, + xpixel: u16, + ypixel: u16, + }; + + var ws: winsize = undefined; + const fd = std.posix.STDOUT_FILENO; + const TIOCGWINSZ = 0x5413; // Linux value + + if (std.posix.system.ioctl(fd, TIOCGWINSZ, @intFromPtr(&ws)) == 0) { + return .{ + .width = ws.col, + .height = ws.row, + }; + } + + // Fallback to default size + return .{ .width = 80, .height = 24 }; +} + +/// Gets the cached terminal size. +/// +/// Returns the size from the last resize check. This is faster than +/// queryTerminalSize() but may not reflect the very latest size. +pub fn getCachedSize() Size { + return cached_size; +} + +/// Checks if a resize is pending. +/// +/// Returns true if SIGWINCH was received since the last check. +pub fn isResizePending() bool { + return resize_pending.load(.acquire); +} + +/// Clears the resize pending flag. +pub fn clearResizePending() void { + resize_pending.store(false, .release); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "Size equality" { + const s1 = Size{ .width = 80, .height = 24 }; + const s2 = Size{ .width = 80, .height = 24 }; + const s3 = Size{ .width = 100, .height = 30 }; + + try std.testing.expect(s1.eql(s2)); + try std.testing.expect(!s1.eql(s3)); +} + +test "queryTerminalSize returns valid size" { + const size = queryTerminalSize(); + // Terminal might not be available in test, so just check it doesn't crash + // and returns reasonable default + try std.testing.expect(size.width > 0); + try std.testing.expect(size.height > 0); +} + +test "ResizeHandler basic functionality" { + // Skip in non-terminal environment + if (std.posix.system.ioctl(std.posix.STDOUT_FILENO, 0x5413, 0) != 0) { + return error.SkipZigTest; + } + + var handler = try ResizeHandler.init(); + defer handler.deinit(); + + // Initial state should not have resize pending + // (unless terminal resized during test) + const size = handler.getSize(); + try std.testing.expect(size.width > 0); + try std.testing.expect(size.height > 0); +} diff --git a/src/root.zig b/src/root.zig index 9de194d..52e0ea0 100644 --- a/src/root.zig +++ b/src/root.zig @@ -52,6 +52,47 @@ pub const Alignment = text.Alignment; // Re-exports for convenience pub const terminal = @import("terminal.zig"); pub const Terminal = terminal.Terminal; +pub const Size = terminal.Size; + +// Resize handling +pub const resize = @import("resize.zig"); +pub const ResizeHandler = resize.ResizeHandler; + +// Drag and drop +pub const drag = @import("drag.zig"); +pub const DragState = drag.DragState; +pub const DragType = drag.DragType; +pub const Splitter = drag.Splitter; +pub const SplitterConfig = drag.SplitterConfig; + +// Diagnostics (Elm-style errors) +pub const diagnostic = @import("diagnostic.zig"); +pub const Diagnostic = diagnostic.Diagnostic; +pub const DiagnosticBuilder = diagnostic.DiagnosticBuilder; +pub const Severity = diagnostic.Severity; + +// Debug tools +pub const debug = @import("debug.zig"); +pub const DebugOverlay = debug.DebugOverlay; +pub const DebugFlags = debug.DebugFlags; + +// Performance profiling +pub const profile = @import("profile.zig"); +pub const Profiler = profile.Profiler; +pub const ProfileStats = profile.ProfileStats; +pub const ScopedTimer = profile.ScopedTimer; + +// Sixel graphics +pub const sixel = @import("sixel.zig"); +pub const SixelEncoder = sixel.SixelEncoder; +pub const Pixel = sixel.Pixel; + +// Async event loop +pub const async_loop = @import("async_loop.zig"); +pub const AsyncLoop = async_loop.AsyncLoop; +pub const AsyncEvent = async_loop.AsyncEvent; +pub const EventSource = async_loop.EventSource; +pub const Ticker = async_loop.Ticker; // Layout pub const layout = @import("layout.zig"); @@ -249,6 +290,13 @@ pub const widgets = struct { pub const SyntaxLanguage = syntax_mod.Language; pub const SyntaxTheme = syntax_mod.SyntaxTheme; pub const TokenType = syntax_mod.TokenType; + + pub const logo_mod = @import("widgets/logo.zig"); + pub const Logo = logo_mod.Logo; + pub const LogoAnimation = logo_mod.Animation; + pub const LogoGradientDirection = logo_mod.GradientDirection; + pub const LogoAlignment = logo_mod.Alignment; + pub const predefined_logos = logo_mod.logos; }; // Backend @@ -367,6 +415,27 @@ pub const prefersReducedMotion = accessibility.prefersReducedMotion; pub const prefersHighContrast = accessibility.prefersHighContrast; pub const high_contrast_theme = accessibility.high_contrast_theme; +// Configurable shortcuts +pub const shortcuts = @import("shortcuts.zig"); +pub const Shortcut = shortcuts.Shortcut; +pub const ShortcutMap = shortcuts.ShortcutMap; +pub const ShortcutAction = shortcuts.Action; +pub const ShortcutModifiers = shortcuts.Modifiers; +pub const ShortcutContext = shortcuts.ShortcutContext; + +// Ergonomic widget composition +pub const compose = @import("compose.zig"); +pub const vstack = compose.vstack; +pub const hstack = compose.hstack; +pub const zstack = compose.zstack; +pub const sized = compose.sized; +pub const flexChild = compose.flex; +pub const fillChild = compose.fill; +pub const spacer = compose.spacer; +pub const splitV = compose.splitV; +pub const splitH = compose.splitH; +pub const splitV3 = compose.splitV3; + // ============================================================================ // Tests // ============================================================================ @@ -393,8 +462,11 @@ test { _ = @import("theme_loader.zig"); _ = @import("serialize.zig"); _ = @import("accessibility.zig"); + _ = @import("shortcuts.zig"); + _ = @import("compose.zig"); // New widgets + _ = @import("widgets/logo.zig"); _ = @import("widgets/spinner.zig"); _ = @import("widgets/help.zig"); _ = @import("widgets/viewport.zig"); diff --git a/src/shortcuts.zig b/src/shortcuts.zig new file mode 100644 index 0000000..15093e3 --- /dev/null +++ b/src/shortcuts.zig @@ -0,0 +1,783 @@ +//! Configurable Keyboard Shortcuts System +//! +//! Provides a flexible system for mapping keyboard events to actions, +//! with support for presets (vim, emacs, arrows), custom bindings, +//! conflict detection, and persistence. +//! +//! ## Example +//! +//! ```zig +//! const shortcuts = @import("shortcuts.zig"); +//! +//! // Use a preset +//! var map = shortcuts.ShortcutMap.vim(); +//! +//! // Customize a binding +//! map.rebind(.move_up, Shortcut.parse("Ctrl+k").?); +//! +//! // In event loop +//! if (map.getAction(key_event)) |action| { +//! switch (action) { +//! .move_up => cursor_up(), +//! .move_down => cursor_down(), +//! // ... +//! } +//! } +//! ``` + +const std = @import("std"); +const KeyEvent = @import("event.zig").KeyEvent; +const KeyCode = @import("event.zig").KeyCode; + +// ============================================================================ +// Shortcut Definition +// ============================================================================ + +/// Key modifiers +pub const Modifiers = packed struct { + ctrl: bool = false, + alt: bool = false, + shift: bool = false, + super: bool = false, + + pub const none = Modifiers{}; + pub const ctrl_only = Modifiers{ .ctrl = true }; + pub const alt_only = Modifiers{ .alt = true }; + pub const shift_only = Modifiers{ .shift = true }; + pub const ctrl_shift = Modifiers{ .ctrl = true, .shift = true }; + pub const ctrl_alt = Modifiers{ .ctrl = true, .alt = true }; +}; + +/// A single keyboard shortcut +pub const Shortcut = struct { + /// The key code + key: KeyCode, + /// Modifier keys + modifiers: Modifiers = .{}, + + /// Check if this shortcut matches a key event + pub fn matches(self: Shortcut, event_: KeyEvent) bool { + // Check key code + const key_matches = switch (self.key) { + .char => |c| switch (event_.code) { + .char => |ec| c == ec, + else => false, + }, + .f => |n| switch (event_.code) { + .f => |en| n == en, + else => false, + }, + .backspace => event_.code == .backspace, + .enter => event_.code == .enter, + .tab => event_.code == .tab, + .backtab => event_.code == .backtab, + .left => event_.code == .left, + .right => event_.code == .right, + .up => event_.code == .up, + .down => event_.code == .down, + .home => event_.code == .home, + .end => event_.code == .end, + .page_up => event_.code == .page_up, + .page_down => event_.code == .page_down, + .insert => event_.code == .insert, + .delete => event_.code == .delete, + .esc => event_.code == .esc, + .null_key => event_.code == .null_key, + else => false, + }; + + if (!key_matches) return false; + + // Check modifiers + return self.modifiers.ctrl == event_.modifiers.ctrl and + self.modifiers.alt == event_.modifiers.alt and + self.modifiers.shift == event_.modifiers.shift; + } + + /// Parse a shortcut from string like "Ctrl+Shift+S" or "Alt+F4" + pub fn parse(str: []const u8) ?Shortcut { + var mods = Modifiers{}; + var remaining = str; + + // Parse modifiers + while (true) { + if (startsWithIgnoreCase(remaining, "ctrl+")) { + mods.ctrl = true; + remaining = remaining[5..]; + } else if (startsWithIgnoreCase(remaining, "alt+")) { + mods.alt = true; + remaining = remaining[4..]; + } else if (startsWithIgnoreCase(remaining, "shift+")) { + mods.shift = true; + remaining = remaining[6..]; + } else if (startsWithIgnoreCase(remaining, "super+") or startsWithIgnoreCase(remaining, "meta+")) { + mods.super = true; + remaining = remaining[5..]; + } else { + break; + } + } + + // Parse key + const key = parseKey(remaining) orelse return null; + + return Shortcut{ + .key = key, + .modifiers = mods, + }; + } + + /// Format shortcut to string + pub fn format(self: Shortcut, buf: []u8) []const u8 { + var fbs = std.io.fixedBufferStream(buf); + const writer = fbs.writer(); + + if (self.modifiers.ctrl) writer.writeAll("Ctrl+") catch {}; + if (self.modifiers.alt) writer.writeAll("Alt+") catch {}; + if (self.modifiers.shift) writer.writeAll("Shift+") catch {}; + if (self.modifiers.super) writer.writeAll("Super+") catch {}; + + switch (self.key) { + .char => |c| { + if (c >= 'a' and c <= 'z') { + // Uppercase for display + writer.writeByte(@as(u8, @intCast(c)) - 32) catch {}; + } else if (c < 128) { + writer.writeByte(@intCast(c)) catch {}; + } else { + // Unicode character - write as UTF-8 + var utf8_buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(c, &utf8_buf) catch 0; + writer.writeAll(utf8_buf[0..len]) catch {}; + } + }, + .f => |n| writer.print("F{d}", .{n}) catch {}, + .up => writer.writeAll("Up") catch {}, + .down => writer.writeAll("Down") catch {}, + .left => writer.writeAll("Left") catch {}, + .right => writer.writeAll("Right") catch {}, + .enter => writer.writeAll("Enter") catch {}, + .tab => writer.writeAll("Tab") catch {}, + .backspace => writer.writeAll("Backspace") catch {}, + .delete => writer.writeAll("Delete") catch {}, + .home => writer.writeAll("Home") catch {}, + .end => writer.writeAll("End") catch {}, + .page_up => writer.writeAll("PageUp") catch {}, + .page_down => writer.writeAll("PageDown") catch {}, + .esc => writer.writeAll("Escape") catch {}, + .insert => writer.writeAll("Insert") catch {}, + else => writer.writeAll("?") catch {}, + } + + return fbs.getWritten(); + } + + // Predefined shortcuts + pub const escape = Shortcut{ .key = .esc }; + pub const enter = Shortcut{ .key = .enter }; + pub const tab = Shortcut{ .key = .tab }; + pub const space = Shortcut{ .key = .{ .char = ' ' } }; + pub const backspace = Shortcut{ .key = .backspace }; + + pub const up = Shortcut{ .key = .up }; + pub const down = Shortcut{ .key = .down }; + pub const left = Shortcut{ .key = .left }; + pub const right = Shortcut{ .key = .right }; + + pub const home = Shortcut{ .key = .home }; + pub const end = Shortcut{ .key = .end }; + pub const page_up = Shortcut{ .key = .page_up }; + pub const page_down = Shortcut{ .key = .page_down }; + + pub const ctrl_c = Shortcut{ .key = .{ .char = 'c' }, .modifiers = .{ .ctrl = true } }; + pub const ctrl_v = Shortcut{ .key = .{ .char = 'v' }, .modifiers = .{ .ctrl = true } }; + pub const ctrl_x = Shortcut{ .key = .{ .char = 'x' }, .modifiers = .{ .ctrl = true } }; + pub const ctrl_z = Shortcut{ .key = .{ .char = 'z' }, .modifiers = .{ .ctrl = true } }; + pub const ctrl_y = Shortcut{ .key = .{ .char = 'y' }, .modifiers = .{ .ctrl = true } }; + pub const ctrl_s = Shortcut{ .key = .{ .char = 's' }, .modifiers = .{ .ctrl = true } }; + pub const ctrl_a = Shortcut{ .key = .{ .char = 'a' }, .modifiers = .{ .ctrl = true } }; +}; + +// ============================================================================ +// Actions +// ============================================================================ + +/// Standard actions that can be bound to shortcuts +pub const Action = enum { + // Navigation + move_up, + move_down, + move_left, + move_right, + move_word_left, + move_word_right, + move_line_start, + move_line_end, + page_up, + page_down, + go_start, + go_end, + + // Selection + select, + toggle, + select_all, + select_none, + extend_selection_up, + extend_selection_down, + + // Editing + delete_char, + delete_word, + delete_line, + backspace, + copy, + paste, + cut, + undo, + redo, + + // UI Navigation + focus_next, + focus_prev, + tab_next, + tab_prev, + + // Actions + confirm, + cancel, + close, + quit, + help, + search, + refresh, + save, + + // Resize + resize_grow_h, + resize_shrink_h, + resize_grow_v, + resize_shrink_v, + resize_maximize, + resize_restore, + + // Custom actions (for app-specific use) + custom_1, + custom_2, + custom_3, + custom_4, + custom_5, + custom_6, + custom_7, + custom_8, + custom_9, + custom_10, +}; + +// ============================================================================ +// Shortcut Map +// ============================================================================ + +/// Maximum shortcuts per action (for multi-key support) +pub const MAX_SHORTCUTS_PER_ACTION = 3; + +/// Binding entry +pub const Binding = struct { + shortcuts: [MAX_SHORTCUTS_PER_ACTION]?Shortcut = .{ null, null, null }, + + pub fn matches(self: Binding, event_: KeyEvent) bool { + for (self.shortcuts) |opt_shortcut| { + if (opt_shortcut) |shortcut| { + if (shortcut.matches(event_)) return true; + } + } + return false; + } + + pub fn set(self: *Binding, index: usize, shortcut: Shortcut) void { + if (index < MAX_SHORTCUTS_PER_ACTION) { + self.shortcuts[index] = shortcut; + } + } + + pub fn clear(self: *Binding) void { + self.shortcuts = .{ null, null, null }; + } +}; + +/// Map of actions to shortcuts +pub const ShortcutMap = struct { + bindings: [@typeInfo(Action).@"enum".fields.len]Binding, + + /// Initialize empty map + pub fn init() ShortcutMap { + return .{ + .bindings = [_]Binding{.{}} ** @typeInfo(Action).@"enum".fields.len, + }; + } + + /// Get the binding for an action + pub fn get(self: *const ShortcutMap, action: Action) Binding { + return self.bindings[@intFromEnum(action)]; + } + + /// Set primary shortcut for an action + pub fn bind(self: *ShortcutMap, action: Action, shortcut: Shortcut) void { + self.bindings[@intFromEnum(action)].shortcuts[0] = shortcut; + } + + /// Add alternative shortcut for an action + pub fn bindAlt(self: *ShortcutMap, action: Action, shortcut: Shortcut) void { + const binding = &self.bindings[@intFromEnum(action)]; + for (&binding.shortcuts) |*slot| { + if (slot.* == null) { + slot.* = shortcut; + return; + } + } + } + + /// Clear all shortcuts for an action + pub fn unbind(self: *ShortcutMap, action: Action) void { + self.bindings[@intFromEnum(action)].clear(); + } + + /// Find action for a key event + pub fn getAction(self: *const ShortcutMap, event_: KeyEvent) ?Action { + for (self.bindings, 0..) |binding, i| { + if (binding.matches(event_)) { + return @enumFromInt(i); + } + } + return null; + } + + /// Find all conflicts (multiple actions with same shortcut) + pub fn findConflicts(self: *const ShortcutMap, allocator: std.mem.Allocator) ![]Conflict { + var conflicts = std.ArrayListUnmanaged(Conflict){}; + errdefer conflicts.deinit(allocator); + + // Compare each binding against all others + for (self.bindings, 0..) |binding1, i| { + for (binding1.shortcuts) |opt_s1| { + if (opt_s1) |s1| { + for (self.bindings[i + 1 ..], i + 1..) |binding2, j| { + for (binding2.shortcuts) |opt_s2| { + if (opt_s2) |s2| { + if (shortcutsEqual(s1, s2)) { + try conflicts.append(allocator, .{ + .shortcut = s1, + .action1 = @enumFromInt(i), + .action2 = @enumFromInt(j), + }); + } + } + } + } + } + } + } + + return conflicts.toOwnedSlice(allocator); + } + + // ======================================== + // Presets + // ======================================== + + /// Vim-style keybindings + pub fn vim() ShortcutMap { + var map = ShortcutMap.init(); + + // Navigation + map.bind(.move_up, .{ .key = .{ .char = 'k' } }); + map.bindAlt(.move_up, Shortcut.up); + map.bind(.move_down, .{ .key = .{ .char = 'j' } }); + map.bindAlt(.move_down, Shortcut.down); + map.bind(.move_left, .{ .key = .{ .char = 'h' } }); + map.bindAlt(.move_left, Shortcut.left); + map.bind(.move_right, .{ .key = .{ .char = 'l' } }); + map.bindAlt(.move_right, Shortcut.right); + + map.bind(.move_word_left, .{ .key = .{ .char = 'b' } }); + map.bind(.move_word_right, .{ .key = .{ .char = 'w' } }); + map.bind(.move_line_start, .{ .key = .{ .char = '0' } }); + map.bind(.move_line_end, .{ .key = .{ .char = '$' } }); + + map.bind(.page_up, .{ .key = .{ .char = 'u' }, .modifiers = .{ .ctrl = true } }); + map.bindAlt(.page_up, Shortcut.page_up); + map.bind(.page_down, .{ .key = .{ .char = 'd' }, .modifiers = .{ .ctrl = true } }); + map.bindAlt(.page_down, Shortcut.page_down); + + map.bind(.go_start, .{ .key = .{ .char = 'g' } }); // gg in vim, simplified + map.bindAlt(.go_start, Shortcut.home); + map.bind(.go_end, .{ .key = .{ .char = 'G' }, .modifiers = .{ .shift = true } }); + map.bindAlt(.go_end, Shortcut.end); + + // Selection/Action + map.bind(.select, Shortcut.enter); + map.bind(.toggle, Shortcut.space); + + // Editing + map.bind(.delete_char, .{ .key = .{ .char = 'x' } }); + map.bind(.delete_line, .{ .key = .{ .char = 'd' } }); // dd in vim, simplified + map.bind(.copy, .{ .key = .{ .char = 'y' } }); // yy in vim + map.bind(.paste, .{ .key = .{ .char = 'p' } }); + map.bind(.undo, .{ .key = .{ .char = 'u' } }); + map.bind(.redo, .{ .key = .{ .char = 'r' }, .modifiers = .{ .ctrl = true } }); + + // UI + map.bind(.cancel, Shortcut.escape); + map.bind(.quit, .{ .key = .{ .char = 'q' } }); + map.bind(.search, .{ .key = .{ .char = '/' } }); + map.bind(.help, .{ .key = .{ .char = '?' } }); + + // Focus + map.bind(.focus_next, Shortcut.tab); + map.bind(.focus_prev, .{ .key = .tab, .modifiers = .{ .shift = true } }); + + return map; + } + + /// Arrow keys / traditional style + pub fn arrows() ShortcutMap { + var map = ShortcutMap.init(); + + // Navigation + map.bind(.move_up, Shortcut.up); + map.bind(.move_down, Shortcut.down); + map.bind(.move_left, Shortcut.left); + map.bind(.move_right, Shortcut.right); + + map.bind(.move_word_left, .{ .key = .left, .modifiers = .{ .ctrl = true } }); + map.bind(.move_word_right, .{ .key = .right, .modifiers = .{ .ctrl = true } }); + map.bind(.move_line_start, Shortcut.home); + map.bind(.move_line_end, Shortcut.end); + + map.bind(.page_up, Shortcut.page_up); + map.bind(.page_down, Shortcut.page_down); + map.bind(.go_start, .{ .key = .home, .modifiers = .{ .ctrl = true } }); + map.bind(.go_end, .{ .key = .end, .modifiers = .{ .ctrl = true } }); + + // Selection + map.bind(.select, Shortcut.enter); + map.bind(.toggle, Shortcut.space); + map.bind(.select_all, Shortcut.ctrl_a); + + // Editing + map.bind(.delete_char, .{ .key = .delete }); + map.bind(.backspace, Shortcut.backspace); + map.bind(.copy, Shortcut.ctrl_c); + map.bind(.paste, Shortcut.ctrl_v); + map.bind(.cut, Shortcut.ctrl_x); + map.bind(.undo, Shortcut.ctrl_z); + map.bind(.redo, Shortcut.ctrl_y); + map.bind(.save, Shortcut.ctrl_s); + + // UI + map.bind(.cancel, Shortcut.escape); + map.bind(.close, .{ .key = .{ .char = 'w' }, .modifiers = .{ .ctrl = true } }); + map.bind(.quit, .{ .key = .{ .char = 'q' }, .modifiers = .{ .ctrl = true } }); + map.bind(.search, .{ .key = .{ .char = 'f' }, .modifiers = .{ .ctrl = true } }); + map.bind(.help, .{ .key = .{ .f = 1 }, .modifiers = .{} }); // F1 + map.bindAlt(.help, .{ .key = .{ .char = '?' } }); + + // Focus + map.bind(.focus_next, Shortcut.tab); + map.bind(.focus_prev, .{ .key = .tab, .modifiers = .{ .shift = true } }); + + // Resize + map.bind(.resize_grow_h, .{ .key = .right, .modifiers = .{ .ctrl = true, .shift = true } }); + map.bind(.resize_shrink_h, .{ .key = .left, .modifiers = .{ .ctrl = true, .shift = true } }); + map.bind(.resize_grow_v, .{ .key = .down, .modifiers = .{ .ctrl = true, .shift = true } }); + map.bind(.resize_shrink_v, .{ .key = .up, .modifiers = .{ .ctrl = true, .shift = true } }); + + return map; + } + + /// Emacs-style keybindings + pub fn emacs() ShortcutMap { + var map = ShortcutMap.init(); + + // Navigation (C-p, C-n, C-b, C-f) + map.bind(.move_up, .{ .key = .{ .char = 'p' }, .modifiers = .{ .ctrl = true } }); + map.bindAlt(.move_up, Shortcut.up); + map.bind(.move_down, .{ .key = .{ .char = 'n' }, .modifiers = .{ .ctrl = true } }); + map.bindAlt(.move_down, Shortcut.down); + map.bind(.move_left, .{ .key = .{ .char = 'b' }, .modifiers = .{ .ctrl = true } }); + map.bindAlt(.move_left, Shortcut.left); + map.bind(.move_right, .{ .key = .{ .char = 'f' }, .modifiers = .{ .ctrl = true } }); + map.bindAlt(.move_right, Shortcut.right); + + map.bind(.move_word_left, .{ .key = .{ .char = 'b' }, .modifiers = .{ .alt = true } }); + map.bind(.move_word_right, .{ .key = .{ .char = 'f' }, .modifiers = .{ .alt = true } }); + map.bind(.move_line_start, .{ .key = .{ .char = 'a' }, .modifiers = .{ .ctrl = true } }); + map.bind(.move_line_end, .{ .key = .{ .char = 'e' }, .modifiers = .{ .ctrl = true } }); + + map.bind(.page_up, .{ .key = .{ .char = 'v' }, .modifiers = .{ .alt = true } }); + map.bindAlt(.page_up, Shortcut.page_up); + map.bind(.page_down, .{ .key = .{ .char = 'v' }, .modifiers = .{ .ctrl = true } }); + map.bindAlt(.page_down, Shortcut.page_down); + + map.bind(.go_start, .{ .key = .{ .char = '<' }, .modifiers = .{ .alt = true } }); + map.bind(.go_end, .{ .key = .{ .char = '>' }, .modifiers = .{ .alt = true } }); + + // Selection + map.bind(.select, Shortcut.enter); + + // Editing + map.bind(.delete_char, .{ .key = .{ .char = 'd' }, .modifiers = .{ .ctrl = true } }); + map.bind(.backspace, Shortcut.backspace); + map.bind(.delete_word, .{ .key = .{ .char = 'd' }, .modifiers = .{ .alt = true } }); + map.bind(.delete_line, .{ .key = .{ .char = 'k' }, .modifiers = .{ .ctrl = true } }); + map.bind(.copy, .{ .key = .{ .char = 'w' }, .modifiers = .{ .alt = true } }); + map.bind(.paste, .{ .key = .{ .char = 'y' }, .modifiers = .{ .ctrl = true } }); + map.bind(.undo, .{ .key = .{ .char = '/' }, .modifiers = .{ .ctrl = true } }); + + // UI + map.bind(.cancel, .{ .key = .{ .char = 'g' }, .modifiers = .{ .ctrl = true } }); + map.bindAlt(.cancel, Shortcut.escape); + map.bind(.quit, .{ .key = .{ .char = 'c' }, .modifiers = .{ .ctrl = true } }); // C-x C-c simplified + map.bind(.search, .{ .key = .{ .char = 's' }, .modifiers = .{ .ctrl = true } }); + map.bind(.save, .{ .key = .{ .char = 'x' }, .modifiers = .{ .ctrl = true } }); // C-x C-s simplified + + return map; + } + + /// Minimal - only essential bindings + pub fn minimal() ShortcutMap { + var map = ShortcutMap.init(); + + map.bind(.move_up, Shortcut.up); + map.bind(.move_down, Shortcut.down); + map.bind(.move_left, Shortcut.left); + map.bind(.move_right, Shortcut.right); + map.bind(.select, Shortcut.enter); + map.bind(.cancel, Shortcut.escape); + map.bind(.focus_next, Shortcut.tab); + + return map; + } +}; + +/// Conflict between two actions +pub const Conflict = struct { + shortcut: Shortcut, + action1: Action, + action2: Action, +}; + +// ============================================================================ +// Shortcut Context +// ============================================================================ + +/// Context for hierarchical shortcuts (mode-specific) +pub const ShortcutContext = struct { + name: []const u8, + map: ShortcutMap, + parent: ?*const ShortcutContext = null, + + /// Get action, checking this context first, then parent + pub fn getAction(self: *const ShortcutContext, event_: KeyEvent) ?Action { + if (self.map.getAction(event_)) |action| { + return action; + } + if (self.parent) |p| { + return p.getAction(event_); + } + return null; + } + + /// Create child context + pub fn child(self: *const ShortcutContext, name: []const u8) ShortcutContext { + return .{ + .name = name, + .map = ShortcutMap.init(), + .parent = self, + }; + } +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool { + if (haystack.len < needle.len) return false; + for (haystack[0..needle.len], needle) |h, n| { + const h_lower = if (h >= 'A' and h <= 'Z') h + 32 else h; + const n_lower = if (n >= 'A' and n <= 'Z') n + 32 else n; + if (h_lower != n_lower) return false; + } + return true; +} + +fn parseKey(str: []const u8) ?KeyCode { + if (str.len == 0) return null; + + // Single character + if (str.len == 1) { + // Convert to lowercase for consistency + const c = str[0]; + const lower = if (c >= 'A' and c <= 'Z') c + 32 else c; + return .{ .char = lower }; + } + + // Named keys + if (eqlIgnoreCase(str, "up")) return .up; + if (eqlIgnoreCase(str, "down")) return .down; + if (eqlIgnoreCase(str, "left")) return .left; + if (eqlIgnoreCase(str, "right")) return .right; + if (eqlIgnoreCase(str, "enter") or eqlIgnoreCase(str, "return")) return .enter; + if (eqlIgnoreCase(str, "tab")) return .tab; + if (eqlIgnoreCase(str, "backspace") or eqlIgnoreCase(str, "bs")) return .backspace; + if (eqlIgnoreCase(str, "delete") or eqlIgnoreCase(str, "del")) return .delete; + if (eqlIgnoreCase(str, "home")) return .home; + if (eqlIgnoreCase(str, "end")) return .end; + if (eqlIgnoreCase(str, "pageup") or eqlIgnoreCase(str, "pgup")) return .page_up; + if (eqlIgnoreCase(str, "pagedown") or eqlIgnoreCase(str, "pgdn")) return .page_down; + if (eqlIgnoreCase(str, "escape") or eqlIgnoreCase(str, "esc")) return .esc; + if (eqlIgnoreCase(str, "insert") or eqlIgnoreCase(str, "ins")) return .insert; + if (eqlIgnoreCase(str, "space")) return .{ .char = ' ' }; + + // Function keys F1-F12 + if (str.len >= 2 and (str[0] == 'F' or str[0] == 'f')) { + const num = std.fmt.parseInt(u8, str[1..], 10) catch return null; + if (num >= 1 and num <= 12) { + return .{ .f = num }; + } + } + + return null; +} + +fn eqlIgnoreCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |ac, bc| { + const a_lower = if (ac >= 'A' and ac <= 'Z') ac + 32 else ac; + const b_lower = if (bc >= 'A' and bc <= 'Z') bc + 32 else bc; + if (a_lower != b_lower) return false; + } + return true; +} + +fn shortcutsEqual(s1: Shortcut, s2: Shortcut) bool { + // Compare keys + const keys_equal = switch (s1.key) { + .char => |c1| switch (s2.key) { + .char => |c2| c1 == c2, + else => false, + }, + else => s1.key == s2.key, + }; + + if (!keys_equal) return false; + + // Compare modifiers + return s1.modifiers.ctrl == s2.modifiers.ctrl and + s1.modifiers.alt == s2.modifiers.alt and + s1.modifiers.shift == s2.modifiers.shift and + s1.modifiers.super == s2.modifiers.super; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "Shortcut.parse basic" { + const s1 = Shortcut.parse("Enter").?; + try std.testing.expectEqual(KeyCode.enter, s1.key); + + const s2 = Shortcut.parse("Ctrl+S").?; + try std.testing.expectEqual(KeyCode{ .char = 's' }, s2.key); + try std.testing.expect(s2.modifiers.ctrl); + + const s3 = Shortcut.parse("Ctrl+Shift+P").?; + try std.testing.expect(s3.modifiers.ctrl); + try std.testing.expect(s3.modifiers.shift); + + const s4 = Shortcut.parse("F5").?; + try std.testing.expectEqual(KeyCode{ .f = 5 }, s4.key); +} + +test "Shortcut.format" { + var buf: [32]u8 = undefined; + + const s1 = Shortcut{ .key = .enter }; + try std.testing.expectEqualStrings("Enter", s1.format(&buf)); + + const s2 = Shortcut{ .key = .{ .char = 's' }, .modifiers = .{ .ctrl = true } }; + try std.testing.expectEqualStrings("Ctrl+S", s2.format(&buf)); +} + +test "ShortcutMap.vim preset" { + const map = ShortcutMap.vim(); + + // j should be move_down + const j_event = KeyEvent{ .code = .{ .char = 'j' }, .modifiers = .{} }; + try std.testing.expectEqual(Action.move_down, map.getAction(j_event).?); + + // k should be move_up + const k_event = KeyEvent{ .code = .{ .char = 'k' }, .modifiers = .{} }; + try std.testing.expectEqual(Action.move_up, map.getAction(k_event).?); + + // q should be quit + const q_event = KeyEvent{ .code = .{ .char = 'q' }, .modifiers = .{} }; + try std.testing.expectEqual(Action.quit, map.getAction(q_event).?); +} + +test "ShortcutMap.arrows preset" { + const map = ShortcutMap.arrows(); + + const up_event = KeyEvent{ .code = .up, .modifiers = .{} }; + try std.testing.expectEqual(Action.move_up, map.getAction(up_event).?); + + const ctrl_z = KeyEvent{ .code = .{ .char = 'z' }, .modifiers = .{ .ctrl = true } }; + try std.testing.expectEqual(Action.undo, map.getAction(ctrl_z).?); +} + +test "ShortcutMap custom binding" { + var map = ShortcutMap.minimal(); + + // Add custom binding + map.bind(.custom_1, Shortcut.parse("Ctrl+1").?); + + const event_ = KeyEvent{ .code = .{ .char = '1' }, .modifiers = .{ .ctrl = true } }; + try std.testing.expectEqual(Action.custom_1, map.getAction(event_).?); +} + +test "ShortcutContext hierarchy" { + const base = ShortcutContext{ + .name = "base", + .map = ShortcutMap.minimal(), + }; + + var child_map = ShortcutMap.init(); + child_map.bind(.custom_1, .{ .key = .{ .char = 'x' } }); + + const child_ctx = ShortcutContext{ + .name = "child", + .map = child_map, + .parent = &base, + }; + + // Child-specific binding + const x_event = KeyEvent{ .code = .{ .char = 'x' }, .modifiers = .{} }; + try std.testing.expectEqual(Action.custom_1, child_ctx.getAction(x_event).?); + + // Inherited from parent + const enter_event = KeyEvent{ .code = .enter, .modifiers = .{} }; + try std.testing.expectEqual(Action.select, child_ctx.getAction(enter_event).?); +} + +test "parseKey function keys" { + try std.testing.expectEqual(KeyCode{ .f = 1 }, parseKey("F1").?); + try std.testing.expectEqual(KeyCode{ .f = 12 }, parseKey("F12").?); + try std.testing.expectEqual(KeyCode{ .f = 5 }, parseKey("f5").?); +} + +test "eqlIgnoreCase" { + try std.testing.expect(eqlIgnoreCase("Enter", "enter")); + try std.testing.expect(eqlIgnoreCase("CTRL", "ctrl")); + try std.testing.expect(!eqlIgnoreCase("a", "b")); +} diff --git a/src/sixel.zig b/src/sixel.zig new file mode 100644 index 0000000..96db07a --- /dev/null +++ b/src/sixel.zig @@ -0,0 +1,316 @@ +//! Sixel graphics support for zcatui. +//! +//! Sixel is a bitmap graphics format that can be displayed in compatible terminals +//! like xterm (with -ti vt340), mlterm, and others. +//! +//! ## Features +//! +//! - Convert RGB pixels to Sixel format +//! - Palette optimization +//! - Render to terminal at specific positions +//! +//! ## Supported Terminals +//! +//! - xterm (with sixel support enabled) +//! - mlterm +//! - foot +//! - WezTerm +//! - Some versions of Windows Terminal +//! +//! ## Example +//! +//! ```zig +//! var encoder = SixelEncoder.init(allocator); +//! defer encoder.deinit(); +//! +//! // Create a simple red square +//! var pixels: [100]Pixel = undefined; +//! for (&pixels) |*p| p.* = .{ .r = 255, .g = 0, .b = 0 }; +//! +//! const sixel = try encoder.encode(&pixels, 10, 10); +//! defer allocator.free(sixel); +//! +//! // Write to terminal at position +//! try term.backend.stdout.writeAll(sixel); +//! ``` + +const std = @import("std"); + +/// An RGB pixel. +pub const Pixel = struct { + r: u8, + g: u8, + b: u8, + a: u8 = 255, + + pub fn rgb(r: u8, g: u8, b: u8) Pixel { + return .{ .r = r, .g = g, .b = b }; + } + + pub fn rgba(r: u8, g: u8, b: u8, a: u8) Pixel { + return .{ .r = r, .g = g, .b = b, .a = a }; + } + + /// Converts to palette index using simple quantization. + pub fn toPaletteIndex(self: Pixel, palette_size: u8) u8 { + // Simple RGB quantization + const levels = @as(u8, @intCast(std.math.cbrt(@as(f32, @floatFromInt(palette_size))))); + const r_idx = @as(u8, @intCast(@as(u16, self.r) * (levels - 1) / 255)); + const g_idx = @as(u8, @intCast(@as(u16, self.g) * (levels - 1) / 255)); + const b_idx = @as(u8, @intCast(@as(u16, self.b) * (levels - 1) / 255)); + return r_idx * levels * levels + g_idx * levels + b_idx; + } +}; + +/// Sixel encoder configuration. +pub const SixelConfig = struct { + /// Number of colors in palette (max 256). + palette_size: u8 = 256, + /// Whether to use transparency. + transparent: bool = false, + /// Transparent color index. + transparent_index: u8 = 0, +}; + +/// Sixel graphics encoder. +pub const SixelEncoder = struct { + allocator: std.mem.Allocator, + config: SixelConfig, + + /// Creates a new Sixel encoder. + pub fn init(allocator: std.mem.Allocator) SixelEncoder { + return .{ + .allocator = allocator, + .config = .{}, + }; + } + + /// Creates with custom configuration. + pub fn initWithConfig(allocator: std.mem.Allocator, config: SixelConfig) SixelEncoder { + return .{ + .allocator = allocator, + .config = config, + }; + } + + pub fn deinit(self: *SixelEncoder) void { + _ = self; + } + + /// Encodes pixels to Sixel format. + pub fn encode(self: *SixelEncoder, pixels: []const Pixel, width: usize, height: usize) ![]u8 { + var result = std.ArrayList(u8).init(self.allocator); + errdefer result.deinit(); + + const writer = result.writer(); + + // Sixel start sequence: ESC P q + try writer.writeAll("\x1bPq"); + + // Set raster attributes: "Pan;Pad;Ph;Pv + // Pan = pixel aspect ratio numerator + // Pad = pixel aspect ratio denominator + // Ph = horizontal size + // Pv = vertical size + try writer.print("\"1;1;{d};{d}", .{ width, height }); + + // Generate palette + try self.writePalette(writer); + + // Encode pixels row by row (6 rows = 1 sixel row) + const sixel_rows = (height + 5) / 6; + for (0..sixel_rows) |sixel_row| { + try self.encodeSixelRow(writer, pixels, width, height, sixel_row); + if (sixel_row < sixel_rows - 1) { + try writer.writeByte('-'); // Graphics newline + } + } + + // Sixel end sequence: ESC \ + try writer.writeAll("\x1b\\"); + + return result.toOwnedSlice(); + } + + fn writePalette(self: *SixelEncoder, writer: anytype) !void { + // Generate a simple RGB palette + const levels: u8 = @intCast(std.math.cbrt(@as(f32, @floatFromInt(self.config.palette_size)))); + + var idx: u8 = 0; + for (0..levels) |r| { + for (0..levels) |g| { + for (0..levels) |b| { + if (idx >= self.config.palette_size) break; + // Sixel palette: #idx;2;R;G;B (2 = RGB mode, values 0-100) + const r_pct = @as(u8, @intCast(r * 100 / (levels - 1))); + const g_pct = @as(u8, @intCast(g * 100 / (levels - 1))); + const b_pct = @as(u8, @intCast(b * 100 / (levels - 1))); + try writer.print("#{d};2;{d};{d};{d}", .{ idx, r_pct, g_pct, b_pct }); + idx += 1; + } + } + } + } + + fn encodeSixelRow( + self: *SixelEncoder, + writer: anytype, + pixels: []const Pixel, + width: usize, + height: usize, + sixel_row: usize, + ) !void { + // Each sixel character represents 6 vertical pixels + const start_y = sixel_row * 6; + + // Process each color in palette + for (0..self.config.palette_size) |color_idx| { + var has_color = false; + var run_start: ?usize = null; + var run_char: u8 = 0; + + // Select color + var color_data = std.ArrayList(u8).init(self.allocator); + defer color_data.deinit(); + const color_writer = color_data.writer(); + + for (0..width) |x| { + var sixel_bits: u8 = 0; + + // Build sixel character from 6 vertical pixels + for (0..6) |bit| { + const y = start_y + bit; + if (y < height) { + const pixel_idx = y * width + x; + if (pixel_idx < pixels.len) { + const pixel = pixels[pixel_idx]; + const pixel_color = pixel.toPaletteIndex(self.config.palette_size); + if (pixel_color == color_idx) { + sixel_bits |= @as(u8, 1) << @intCast(bit); + } + } + } + } + + // Convert to sixel character (add 63) + const sixel_char = sixel_bits + 63; + + // Run-length encode + if (run_start == null) { + run_start = x; + run_char = sixel_char; + } else if (sixel_char == run_char) { + // Continue run + } else { + // End run, write it + const run_len = x - run_start.?; + if (run_char != 63) { // Skip empty + has_color = true; + try writeRun(color_writer, run_char, run_len); + } else if (has_color) { + try writeRun(color_writer, run_char, run_len); + } + run_start = x; + run_char = sixel_char; + } + } + + // Write final run + if (run_start) |start| { + const run_len = width - start; + if (run_char != 63) { + has_color = true; + try writeRun(color_writer, run_char, run_len); + } + } + + // Write color data if any + if (has_color) { + try writer.print("#{d}", .{color_idx}); + try writer.writeAll(color_data.items); + try writer.writeByte('$'); // Carriage return (stay on same line) + } + } + } + + fn writeRun(writer: anytype, char: u8, len: usize) !void { + if (len == 1) { + try writer.writeByte(char); + } else if (len == 2) { + try writer.writeByte(char); + try writer.writeByte(char); + } else { + try writer.print("!{d}{c}", .{ len, char }); + } + } +}; + +/// Checks if the terminal supports Sixel graphics. +pub fn isSixelSupported() bool { + // Check TERM environment variable + const term = std.posix.getenv("TERM") orelse return false; + + // Known Sixel-supporting terminals + const sixel_terms = [_][]const u8{ + "xterm", + "xterm-256color", + "mlterm", + "yaft", + "foot", + "contour", + }; + + for (sixel_terms) |t| { + if (std.mem.eql(u8, term, t)) return true; + } + + // Also check for sixel in TERM + if (std.mem.indexOf(u8, term, "sixel") != null) return true; + + return false; +} + +/// Positions cursor for Sixel output. +pub fn positionForSixel(writer: anytype, x: u16, y: u16) !void { + // Move cursor to position + try writer.print("\x1b[{d};{d}H", .{ y + 1, x + 1 }); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "Pixel creation" { + const p = Pixel.rgb(255, 128, 64); + try std.testing.expectEqual(@as(u8, 255), p.r); + try std.testing.expectEqual(@as(u8, 128), p.g); + try std.testing.expectEqual(@as(u8, 64), p.b); +} + +test "Pixel palette index" { + const red = Pixel.rgb(255, 0, 0); + const idx = red.toPaletteIndex(64); + try std.testing.expect(idx < 64); +} + +test "SixelEncoder basic" { + const allocator = std.testing.allocator; + var encoder = SixelEncoder.init(allocator); + defer encoder.deinit(); + + // Create a 2x2 red image + const pixels = [_]Pixel{ + Pixel.rgb(255, 0, 0), + Pixel.rgb(255, 0, 0), + Pixel.rgb(255, 0, 0), + Pixel.rgb(255, 0, 0), + }; + + const sixel = try encoder.encode(&pixels, 2, 2); + defer allocator.free(sixel); + + // Check it starts with ESC P q + try std.testing.expect(std.mem.startsWith(u8, sixel, "\x1bPq")); + // Check it ends with ESC \ + try std.testing.expect(std.mem.endsWith(u8, sixel, "\x1b\\")); +} diff --git a/src/terminal.zig b/src/terminal.zig index f295a8e..75662b8 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -37,6 +37,9 @@ const event_mod = @import("event.zig"); const Event = event_mod.Event; const event_reader = @import("event/reader.zig"); const EventReader = event_reader.EventReader; +const resize_mod = @import("resize.zig"); +const ResizeHandler = resize_mod.ResizeHandler; +pub const Size = resize_mod.Size; /// Terminal provides the main interface for TUI applications. /// @@ -48,6 +51,8 @@ pub const Terminal = struct { current_buffer: Buffer, previous_buffer: Buffer, event_reader: EventReader, + resize_handler: ?ResizeHandler = null, + auto_resize_enabled: bool = false, mouse_enabled: bool = false, focus_enabled: bool = false, bracketed_paste_enabled: bool = false, @@ -87,7 +92,7 @@ pub const Terminal = struct { /// Cleans up terminal state. /// /// Shows cursor, exits alternate screen, and restores terminal mode. - /// Also disables any enabled features (mouse, focus, paste). + /// Also disables any enabled features (mouse, focus, paste, resize handler). pub fn deinit(self: *Terminal) void { // Disable enabled features if (self.mouse_enabled) { @@ -100,6 +105,11 @@ pub const Terminal = struct { self.disableBracketedPaste() catch {}; } + // Clean up resize handler + if (self.resize_handler) |*handler| { + handler.deinit(); + } + self.backend.disableRawMode() catch {}; self.backend.showCursor() catch {}; self.backend.leaveAlternateScreen() catch {}; @@ -122,7 +132,15 @@ pub const Terminal = struct { /// /// The render function receives the terminal area and buffer, /// and should render all widgets to the buffer. + /// + /// If auto-resize is enabled, this will automatically detect and + /// handle terminal size changes before rendering. pub fn draw(self: *Terminal, comptime render_fn: fn (Rect, *Buffer) void) !void { + // Check for resize if enabled + if (self.auto_resize_enabled) { + _ = try self.checkAndHandleResize(); + } + // Clear buffer self.current_buffer.clear(); @@ -134,11 +152,19 @@ pub const Terminal = struct { } /// Draws using a context-aware render function. + /// + /// If auto-resize is enabled, this will automatically detect and + /// handle terminal size changes before rendering. pub fn drawWithContext( self: *Terminal, context: anytype, comptime render_fn: fn (@TypeOf(context), Rect, *Buffer) void, ) !void { + // Check for resize if enabled + if (self.auto_resize_enabled) { + _ = try self.checkAndHandleResize(); + } + self.current_buffer.clear(); render_fn(context, self.area(), &self.current_buffer); try self.flush(); @@ -186,6 +212,74 @@ pub const Terminal = struct { self.current_buffer.markDirty(); } + // ======================================================================== + // Resize Handling + // ======================================================================== + + /// Enables automatic terminal resize detection. + /// + /// When enabled, the terminal will automatically detect size changes + /// (via SIGWINCH) and resize buffers accordingly during draw() calls. + /// + /// This is the recommended way to handle terminal resizes in most applications. + /// + /// Example: + /// ```zig + /// var term = try Terminal.init(allocator); + /// term.enableAutoResize(); + /// // Now resize is handled automatically during draw() + /// ``` + pub fn enableAutoResize(self: *Terminal) void { + if (self.resize_handler == null) { + self.resize_handler = ResizeHandler.init(); + } + self.auto_resize_enabled = true; + } + + /// Disables automatic resize detection. + /// + /// After calling this, you must handle resize events manually. + pub fn disableAutoResize(self: *Terminal) void { + self.auto_resize_enabled = false; + } + + /// Gets the current terminal size. + /// + /// Queries the terminal directly for the latest dimensions. + pub fn getSize(self: *Terminal) Size { + const backend_size = self.backend.getSize(); + return Size{ + .width = backend_size.width, + .height = backend_size.height, + }; + } + + /// Checks if a resize has occurred and handles it. + /// + /// Returns true if resize was detected and handled. + /// This is called automatically by draw() when auto-resize is enabled. + pub fn checkAndHandleResize(self: *Terminal) !bool { + if (self.resize_handler) |*handler| { + if (handler.hasResized()) { + const new_size = handler.getLastKnownSize(); + try self.resize(new_size.width, new_size.height); + return true; + } + } + return false; + } + + /// Checks if a resize event is pending. + /// + /// This is useful if you want to check for resize without handling it. + pub fn isResizePending(self: *const Terminal) bool { + if (self.resize_handler) |handler| { + _ = handler; + return resize_mod.isResizePending(); + } + return false; + } + // ======================================================================== // Event Handling // ======================================================================== diff --git a/src/widgets/dirtree.zig b/src/widgets/dirtree.zig index 6f174df..f95d8f1 100644 --- a/src/widgets/dirtree.zig +++ b/src/widgets/dirtree.zig @@ -187,8 +187,8 @@ pub const TreeSymbols = struct { pub const DirectoryTree = struct { allocator: std.mem.Allocator, root_path: []const u8, - nodes: std.ArrayList(DirNode), - flat_view: std.ArrayList(usize), // Indices into nodes for visible items + nodes: std.ArrayListUnmanaged(DirNode), + flat_view: std.ArrayListUnmanaged(usize), // Indices into nodes for visible items selected: usize = 0, scroll_offset: u16 = 0, theme: DirTreeTheme = DirTreeTheme.default, @@ -204,12 +204,12 @@ pub const DirectoryTree = struct { var tree = DirectoryTree{ .allocator = allocator, .root_path = try allocator.dupe(u8, root_path), - .nodes = std.ArrayList(DirNode).init(allocator), - .flat_view = std.ArrayList(usize).init(allocator), + .nodes = .{}, + .flat_view = .{}, }; // Add root node - try tree.nodes.append(.{ + try tree.nodes.append(allocator, .{ .name = try allocator.dupe(u8, std.fs.path.basename(root_path)), .path = tree.root_path, .kind = .directory, @@ -235,8 +235,8 @@ pub const DirectoryTree = struct { self.allocator.free(node.path); } } - self.nodes.deinit(); - self.flat_view.deinit(); + self.nodes.deinit(self.allocator); + self.flat_view.deinit(self.allocator); self.allocator.free(self.root_path); } @@ -245,8 +245,7 @@ pub const DirectoryTree = struct { var node = &self.nodes.items[node_idx]; if (node.loaded or node.kind != .directory) return; - const dir = fs.openDirAbsolute(node.path, .{ .iterate = true }) catch |err| { - _ = err; + var dir = fs.openDirAbsolute(node.path, .{ .iterate = true }) catch { node.loaded = true; return; }; @@ -272,7 +271,7 @@ pub const DirectoryTree = struct { const full_path = try fs.path.join(self.allocator, &.{ node.path, entry.name }); const name = try self.allocator.dupe(u8, entry.name); - try self.nodes.append(.{ + try self.nodes.append(self.allocator, .{ .name = name, .path = full_path, .kind = FileKind.fromEntry(entry), @@ -305,7 +304,7 @@ pub const DirectoryTree = struct { } fn addToFlatView(self: *DirectoryTree, node_idx: usize) !void { - try self.flat_view.append(node_idx); + try self.flat_view.append(self.allocator, node_idx); const node = self.nodes.items[node_idx]; if (node.expanded and node.loaded) { diff --git a/src/widgets/logo.zig b/src/widgets/logo.zig new file mode 100644 index 0000000..10fa86a --- /dev/null +++ b/src/widgets/logo.zig @@ -0,0 +1,678 @@ +//! Logo Widget - ASCII Art Display with Animations +//! +//! Renders ASCII art logos with support for: +//! - Multi-line ASCII art text +//! - Color gradients (vertical, horizontal, diagonal) +//! - Animations (typewriter, fade, slide, rainbow, pulse) +//! - Alignment options +//! +//! ## Example +//! +//! ```zig +//! const logo = Logo.init() +//! .setText( +//! \\ _______ _______ ______ +//! \\ |___ | _ |_ _| +//! \\ ___| | | | | +//! \\ |_______|___|___| |__| +//! ) +//! .setStyle(Style{}.fg(Color.cyan).bold()) +//! .setAlignment(.center); +//! +//! logo.render(area, buf); +//! +//! // With gradient +//! const gradient_logo = Logo.init() +//! .setText(ascii_art) +//! .setGradient(&.{ Color.red, Color.yellow, Color.green }); +//! +//! // With animation +//! const animated = Logo.init() +//! .setText(ascii_art) +//! .setAnimation(.typewriter) +//! .tick(frame_count); // Call each frame +//! ``` + +const std = @import("std"); +const Buffer = @import("../buffer.zig").Buffer; +const Rect = @import("../buffer.zig").Rect; +const Cell = @import("../buffer.zig").Cell; +const Style = @import("../style.zig").Style; +const Color = @import("../style.zig").Color; + +/// Animation types for logo display +pub const Animation = enum { + /// No animation (static display) + none, + /// Characters appear one by one + typewriter, + /// Fade in using block characters ░▒▓█ + fade_in, + /// Lines appear from top to bottom + slide_down, + /// Lines appear from bottom to top + slide_up, + /// Continuous rainbow color cycling + rainbow, + /// Pulsing brightness effect + pulse, + /// Glitch/matrix effect + glitch, +}; + +/// Gradient direction +pub const GradientDirection = enum { + /// Top to bottom + vertical, + /// Left to right + horizontal, + /// Top-left to bottom-right + diagonal, + /// Center outward + radial, +}; + +/// Text alignment +pub const Alignment = enum { + left, + center, + right, +}; + +/// Logo widget for displaying ASCII art +pub const Logo = struct { + /// ASCII art text (multi-line) + text: []const u8 = "", + /// Base style for rendering + style: Style = Style{}, + /// Text alignment + alignment: Alignment = .center, + /// Animation type + animation: Animation = .none, + /// Animation progress (0-1000 for precision) + progress: u32 = 1000, + /// Animation speed (ticks per frame) + speed: u32 = 50, + /// Gradient colors (if set) + gradient: ?[]const Color = null, + /// Gradient direction + gradient_dir: GradientDirection = .vertical, + /// Frame counter for animations + frame: u64 = 0, + + // Cached calculations + line_count: u16 = 0, + max_line_width: u16 = 0, + char_count: u32 = 0, + + /// Initialize a new logo widget + pub fn init() Logo { + return .{}; + } + + /// Set the ASCII art text + pub fn setText(self: Logo, text: []const u8) Logo { + var logo = self; + logo.text = text; + // Calculate dimensions + logo.line_count = 0; + logo.max_line_width = 0; + logo.char_count = 0; + + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| { + logo.line_count += 1; + logo.max_line_width = @max(logo.max_line_width, @as(u16, @intCast(line.len))); + logo.char_count += @intCast(line.len); + } + + return logo; + } + + /// Set the base style + pub fn setStyle(self: Logo, style: Style) Logo { + var logo = self; + logo.style = style; + return logo; + } + + /// Set text alignment + pub fn setAlignment(self: Logo, alignment: Alignment) Logo { + var logo = self; + logo.alignment = alignment; + return logo; + } + + /// Set animation type + pub fn setAnimation(self: Logo, animation: Animation) Logo { + var logo = self; + logo.animation = animation; + if (animation != .none) { + logo.progress = 0; // Start from beginning + } + return logo; + } + + /// Set animation speed (lower = faster) + pub fn setSpeed(self: Logo, speed: u32) Logo { + var logo = self; + logo.speed = if (speed == 0) 1 else speed; + return logo; + } + + /// Set gradient colors + pub fn setGradient(self: Logo, colors: []const Color) Logo { + var logo = self; + logo.gradient = colors; + return logo; + } + + /// Set gradient direction + pub fn setGradientDirection(self: Logo, dir: GradientDirection) Logo { + var logo = self; + logo.gradient_dir = dir; + return logo; + } + + /// Advance animation by one tick + pub fn tick(self: Logo) Logo { + var logo = self; + logo.frame +%= 1; + + if (logo.animation != .none and logo.progress < 1000) { + logo.progress = @min(1000, logo.progress + logo.speed); + } + + return logo; + } + + /// Reset animation to beginning + pub fn reset(self: Logo) Logo { + var logo = self; + logo.progress = 0; + logo.frame = 0; + return logo; + } + + /// Check if animation is complete + pub fn isComplete(self: Logo) bool { + return self.animation == .none or self.progress >= 1000; + } + + /// Render the logo + pub fn render(self: Logo, area: Rect, buf: *Buffer) void { + if (area.isEmpty() or self.text.len == 0) return; + + // Calculate vertical centering + const start_y = if (self.line_count < area.height) + area.y + (area.height - self.line_count) / 2 + else + area.y; + + var y: u16 = 0; + var char_index: u32 = 0; + var lines = std.mem.splitScalar(u8, self.text, '\n'); + + while (lines.next()) |line| { + if (y >= area.height) break; + + const render_y = start_y + y; + if (render_y >= area.y + area.height) break; + + // Check slide animations + const line_visible = switch (self.animation) { + .slide_down => blk: { + const visible_lines = (self.progress * self.line_count) / 1000; + break :blk y < visible_lines; + }, + .slide_up => blk: { + const visible_lines = (self.progress * self.line_count) / 1000; + break :blk (self.line_count - 1 - y) < visible_lines; + }, + else => true, + }; + + if (line_visible) { + // Calculate horizontal alignment + const line_width = @as(u16, @intCast(line.len)); + const start_x = switch (self.alignment) { + .left => area.x, + .center => area.x + (area.width -| line_width) / 2, + .right => area.x + area.width -| line_width, + }; + + // Render each character + for (line, 0..) |char, x_offset| { + const render_x = start_x + @as(u16, @intCast(x_offset)); + if (render_x >= area.x + area.width) break; + + const char_visible = self.isCharVisible(char_index); + if (char_visible and char != ' ') { + const style = self.getCharStyle( + @intCast(x_offset), + y, + char_index, + ); + const display_char = self.getDisplayChar(char, char_index); + + buf.setCell(render_x, render_y, Cell{ + .symbol = Cell.Symbol.fromCodepoint(display_char), + .style = style, + }); + } + + char_index += 1; + } + } else { + char_index += @intCast(line.len); + } + + y += 1; + } + } + + /// Check if a character should be visible based on animation + fn isCharVisible(self: Logo, char_index: u32) bool { + return switch (self.animation) { + .none => true, + .typewriter => blk: { + if (self.char_count == 0) break :blk true; + const visible_chars = (self.progress * self.char_count) / 1000; + break :blk char_index < visible_chars; + }, + .fade_in, .rainbow, .pulse, .glitch => true, + .slide_down, .slide_up => true, // Handled at line level + }; + } + + /// Get the display character (may be modified for fade effect) + fn getDisplayChar(self: Logo, char: u8, char_index: u32) u21 { + return switch (self.animation) { + .fade_in => blk: { + if (self.char_count == 0) break :blk char; + // Calculate fade level based on progress + _ = self.progress; // Used for animation state + const char_progress = (char_index * 1000) / self.char_count; + + // Characters fade in sequentially + if (char_progress > self.progress) { + break :blk ' '; + } + + const local_progress = if (self.progress > char_progress) + @min(4, (self.progress - char_progress) * 4 / 200) + else + 0; + + break :blk switch (local_progress) { + 0 => ' ', + 1 => '░', + 2 => '▒', + 3 => '▓', + else => char, + }; + }, + .glitch => blk: { + // Random glitch based on frame + const hash = (char_index +% @as(u32, @truncate(self.frame))) *% 2654435761; + if (hash % 100 < 5) { // 5% glitch chance + const glitch_chars = "█▓▒░#@$%&*"; + break :blk glitch_chars[hash % glitch_chars.len]; + } + break :blk char; + }, + else => char, + }; + } + + /// Get style for a character (handles gradients and effects) + fn getCharStyle(self: Logo, x: u16, y: u16, char_index: u32) Style { + var style = self.style; + + // Apply gradient if set + if (self.gradient) |colors| { + if (colors.len > 0) { + const color = self.interpolateGradient(colors, x, y); + style = style.fg(color); + } + } + + // Apply animation effects + switch (self.animation) { + .rainbow => { + const hue = (@as(u32, @truncate(self.frame)) *% 10 + char_index * 30) % 360; + style = style.fg(hsvToRgb(hue, 100, 100)); + }, + .pulse => { + // Pulse brightness using sine wave approximation + const phase = (@as(u32, @truncate(self.frame)) *% 20) % 360; + const brightness = 50 + (sinApprox(phase) * 50 / 100); + if (style.foreground) |fg| { + style = style.fg(adjustBrightness(fg, @intCast(brightness))); + } + }, + else => {}, + } + + return style; + } + + /// Interpolate gradient color at position + fn interpolateGradient(self: Logo, colors: []const Color, x: u16, y: u16) Color { + if (colors.len == 1) return colors[0]; + + const t: f32 = switch (self.gradient_dir) { + .vertical => if (self.line_count > 0) + @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(self.line_count)) + else + 0.0, + .horizontal => if (self.max_line_width > 0) + @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(self.max_line_width)) + else + 0.0, + .diagonal => blk: { + const max_dist = self.line_count + self.max_line_width; + if (max_dist > 0) { + break :blk @as(f32, @floatFromInt(x + y)) / @as(f32, @floatFromInt(max_dist)); + } + break :blk 0.0; + }, + .radial => blk: { + const center_x = self.max_line_width / 2; + const center_y = self.line_count / 2; + const dx = if (x > center_x) x - center_x else center_x - x; + const dy = if (y > center_y) y - center_y else center_y - y; + const max_dist = @max(center_x, center_y); + if (max_dist > 0) { + const dist = @max(dx, dy); // Chebyshev distance + break :blk @as(f32, @floatFromInt(dist)) / @as(f32, @floatFromInt(max_dist)); + } + break :blk 0.0; + }, + }; + + // Find the two colors to interpolate between + const segment_count = colors.len - 1; + const segment_f = t * @as(f32, @floatFromInt(segment_count)); + const segment: usize = @min(@as(usize, @intFromFloat(segment_f)), segment_count - 1); + const local_t = segment_f - @as(f32, @floatFromInt(segment)); + + return lerpColor(colors[segment], colors[segment + 1], local_t); + } +}; + +// ============================================================================ +// Color Utility Functions +// ============================================================================ + +/// Linear interpolation between two colors +fn lerpColor(c1: Color, c2: Color, t: f32) Color { + const rgb1 = colorToRgb(c1); + const rgb2 = colorToRgb(c2); + + const r: u8 = @intFromFloat(@as(f32, @floatFromInt(rgb1.r)) * (1.0 - t) + @as(f32, @floatFromInt(rgb2.r)) * t); + const g: u8 = @intFromFloat(@as(f32, @floatFromInt(rgb1.g)) * (1.0 - t) + @as(f32, @floatFromInt(rgb2.g)) * t); + const b: u8 = @intFromFloat(@as(f32, @floatFromInt(rgb1.b)) * (1.0 - t) + @as(f32, @floatFromInt(rgb2.b)) * t); + + return Color.rgb(r, g, b); +} + +/// Convert any color to RGB components +fn colorToRgb(color: Color) struct { r: u8, g: u8, b: u8 } { + return switch (color) { + .true_color => |c| .{ .r = c.r, .g = c.g, .b = c.b }, + .ansi => |a| switch (a) { + .black => .{ .r = 0, .g = 0, .b = 0 }, + .red => .{ .r = 205, .g = 49, .b = 49 }, + .green => .{ .r = 13, .g = 188, .b = 121 }, + .yellow => .{ .r = 229, .g = 229, .b = 16 }, + .blue => .{ .r = 36, .g = 114, .b = 200 }, + .magenta => .{ .r = 188, .g = 63, .b = 188 }, + .cyan => .{ .r = 17, .g = 168, .b = 205 }, + .white => .{ .r = 229, .g = 229, .b = 229 }, + .bright_black => .{ .r = 102, .g = 102, .b = 102 }, + .bright_red => .{ .r = 241, .g = 76, .b = 76 }, + .bright_green => .{ .r = 35, .g = 209, .b = 139 }, + .bright_yellow => .{ .r = 245, .g = 245, .b = 67 }, + .bright_blue => .{ .r = 59, .g = 142, .b = 234 }, + .bright_magenta => .{ .r = 214, .g = 112, .b = 214 }, + .bright_cyan => .{ .r = 41, .g = 184, .b = 219 }, + .bright_white => .{ .r = 255, .g = 255, .b = 255 }, + .default => .{ .r = 229, .g = 229, .b = 229 }, + }, + .idx => |i| { + // 256 color palette approximation + if (i < 16) { + // Standard colors + return colorToRgb(Color{ .ansi = @enumFromInt(i) }); + } else if (i < 232) { + // 6x6x6 color cube + const c = i - 16; + const r: u8 = @intCast((c / 36) * 51); + const g: u8 = @intCast(((c % 36) / 6) * 51); + const b: u8 = @intCast((c % 6) * 51); + return .{ .r = r, .g = g, .b = b }; + } else { + // Grayscale + const gray: u8 = @intCast((i - 232) * 10 + 8); + return .{ .r = gray, .g = gray, .b = gray }; + } + }, + .reset => .{ .r = 229, .g = 229, .b = 229 }, + }; +} + +/// Convert HSV to RGB color +fn hsvToRgb(h: u32, s: u32, v: u32) Color { + if (s == 0) { + const gray: u8 = @intCast(v * 255 / 100); + return Color.rgb(gray, gray, gray); + } + + const hue = h % 360; + const sat = @as(f32, @floatFromInt(s)) / 100.0; + const val = @as(f32, @floatFromInt(v)) / 100.0; + + const sector: u32 = hue / 60; + const f = @as(f32, @floatFromInt(hue % 60)) / 60.0; + + const p = val * (1.0 - sat); + const q = val * (1.0 - sat * f); + const t = val * (1.0 - sat * (1.0 - f)); + + const rgb: struct { r: f32, g: f32, b: f32 } = switch (sector) { + 0 => .{ .r = val, .g = t, .b = p }, + 1 => .{ .r = q, .g = val, .b = p }, + 2 => .{ .r = p, .g = val, .b = t }, + 3 => .{ .r = p, .g = q, .b = val }, + 4 => .{ .r = t, .g = p, .b = val }, + else => .{ .r = val, .g = p, .b = q }, + }; + + return Color.rgb( + @intFromFloat(rgb.r * 255.0), + @intFromFloat(rgb.g * 255.0), + @intFromFloat(rgb.b * 255.0), + ); +} + +/// Adjust brightness of a color +fn adjustBrightness(color: Color, percent: u8) Color { + const rgb = colorToRgb(color); + const factor = @as(f32, @floatFromInt(percent)) / 100.0; + + return Color.rgb( + @intFromFloat(@min(255.0, @as(f32, @floatFromInt(rgb.r)) * factor)), + @intFromFloat(@min(255.0, @as(f32, @floatFromInt(rgb.g)) * factor)), + @intFromFloat(@min(255.0, @as(f32, @floatFromInt(rgb.b)) * factor)), + ); +} + +/// Approximate sine function (input: degrees 0-360, output: -100 to 100) +fn sinApprox(degrees: u32) i32 { + const d = degrees % 360; + // Simple parabolic approximation + if (d <= 180) { + const x = @as(i32, @intCast(d)) - 90; + return 100 - @divTrunc(x * x * 100, 8100); + } else { + const x = @as(i32, @intCast(d)) - 270; + return -100 + @divTrunc(x * x * 100, 8100); + } +} + +// ============================================================================ +// Predefined ASCII Art +// ============================================================================ + +/// Collection of predefined ASCII art logos +pub const logos = struct { + pub const zcatui = + \\ ███████╗ ██████╗ █████╗ ████████╗██╗ ██╗██╗ + \\ ╚══███╔╝██╔════╝██╔══██╗╚══██╔══╝██║ ██║██║ + \\ ███╔╝ ██║ ███████║ ██║ ██║ ██║██║ + \\ ███╔╝ ██║ ██╔══██║ ██║ ██║ ██║██║ + \\ ███████╗╚██████╗██║ ██║ ██║ ╚██████╔╝██║ + \\ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ + ; + + pub const zcatui_simple = + \\ ____ ___ _ _____ _ _ ___ + \\ |_ / / __| /_\|_ _|| | | ||_ _| + \\ / / | (__ / _ \ | | | |_| | | | + \\ /___| \___/_/ \_\|_| \___/ |___| + ; + + pub const zig = + \\ ███████╗██╗ ██████╗ + \\ ╚══███╔╝██║██╔════╝ + \\ ███╔╝ ██║██║ ███╗ + \\ ███╔╝ ██║██║ ██║ + \\ ███████╗██║╚██████╔╝ + \\ ╚══════╝╚═╝ ╚═════╝ + ; + + pub const rust = + \\ ██████╗ ██╗ ██╗███████╗████████╗ + \\ ██╔══██╗██║ ██║██╔════╝╚══██╔══╝ + \\ ██████╔╝██║ ██║███████╗ ██║ + \\ ██╔══██╗██║ ██║╚════██║ ██║ + \\ ██║ ██║╚██████╔╝███████║ ██║ + \\ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ + ; + + pub const box_small = + \\ ╔═══════╗ + \\ ║ LOGO ║ + \\ ╚═══════╝ + ; + + pub const banner = + \\ ╭──────────────────────────────────╮ + \\ │ │ + \\ │ YOUR TEXT HERE │ + \\ │ │ + \\ ╰──────────────────────────────────╯ + ; +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Logo init and setText" { + const logo = Logo.init() + .setText("Line1\nLine2\nLine3"); + + try std.testing.expectEqual(@as(u16, 3), logo.line_count); + try std.testing.expectEqual(@as(u16, 5), logo.max_line_width); +} + +test "Logo with style" { + const base_style = Style{}; + const cyan_bold = base_style.fg(Color.cyan).bold(); + const logo = Logo.init() + .setText("TEST") + .setStyle(cyan_bold) + .setAlignment(.center); + + try std.testing.expectEqual(Alignment.center, logo.alignment); + try std.testing.expect(logo.style.foreground != null); +} + +test "Logo animation tick" { + var logo = Logo.init() + .setText("TEST") + .setAnimation(.typewriter) + .setSpeed(100); + + try std.testing.expectEqual(@as(u32, 0), logo.progress); + + logo = logo.tick(); + try std.testing.expectEqual(@as(u32, 100), logo.progress); + + // Progress should cap at 1000 + for (0..20) |_| { + logo = logo.tick(); + } + try std.testing.expectEqual(@as(u32, 1000), logo.progress); + try std.testing.expect(logo.isComplete()); +} + +test "Logo reset" { + var logo = Logo.init() + .setText("TEST") + .setAnimation(.typewriter); + + logo = logo.tick().tick().tick(); + try std.testing.expect(logo.progress > 0); + + logo = logo.reset(); + try std.testing.expectEqual(@as(u32, 0), logo.progress); + try std.testing.expectEqual(@as(u64, 0), logo.frame); +} + +test "hsvToRgb basic" { + // Red + const red = hsvToRgb(0, 100, 100); + try std.testing.expectEqual(Color.rgb(255, 0, 0), red); + + // Green + const green = hsvToRgb(120, 100, 100); + try std.testing.expectEqual(Color.rgb(0, 255, 0), green); + + // Blue + const blue = hsvToRgb(240, 100, 100); + try std.testing.expectEqual(Color.rgb(0, 0, 255), blue); + + // White (no saturation) + const white = hsvToRgb(0, 0, 100); + try std.testing.expectEqual(Color.rgb(255, 255, 255), white); +} + +test "sinApprox" { + // sin(0) = 0 + try std.testing.expect(sinApprox(0) < 10 and sinApprox(0) > -10); + // sin(90) = 1 (100 in our scale) + try std.testing.expect(sinApprox(90) > 90); + // sin(180) = 0 + try std.testing.expect(sinApprox(180) < 10 and sinApprox(180) > -10); + // sin(270) = -1 (-100 in our scale) + try std.testing.expect(sinApprox(270) < -90); +} + +test "Logo gradient" { + const logo = Logo.init() + .setText("TEST\nTEST") + .setGradient(&.{ Color.red, Color.blue }) + .setGradientDirection(.vertical); + + try std.testing.expect(logo.gradient != null); + try std.testing.expectEqual(GradientDirection.vertical, logo.gradient_dir); +} + +test "predefined logos exist" { + try std.testing.expect(logos.zcatui.len > 0); + try std.testing.expect(logos.zig.len > 0); + try std.testing.expect(logos.rust.len > 0); +}