commit 2a62c0f60b8f32d3f8edb03e239b5b8ce21146a0 Author: reugenio Date: Mon Dec 8 01:56:44 2025 +0100 Inicio proyecto zcatui - TUI library para Zig Librería TUI inspirada en ratatui (Rust), implementada en Zig 0.15.2. Estructura inicial: - src/style.zig: Color, Style, Modifier - src/buffer.zig: Cell, Buffer, Rect - src/layout.zig: Layout, Constraint, Direction - src/terminal.zig: Terminal abstraction - src/backend/backend.zig: ANSI escape sequences - src/widgets/block.zig: Block con borders y título - src/widgets/paragraph.zig: Paragraph con wrapping - examples/hello.zig: Demo funcional 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f76104e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Zig build artifacts +.zig-cache/ +zig-out/ + +# Editor files +*.swp +*~ +.vscode/ +.idea/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1980f0e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,462 @@ +# zcatui - TUI Library para Zig + +> **Última actualización**: 2025-12-08 +> **Lenguaje**: Zig 0.15.2 +> **Inspiración**: [ratatui](https://github.com/ratatui/ratatui) (Rust TUI library) + +## Descripción del Proyecto + +**zcatui** es una librería para crear interfaces de usuario en terminal (TUI) en Zig puro, inspirada en ratatui de Rust. + +**Objetivo**: Proveer una API idiomática Zig para construir aplicaciones TUI con widgets, layouts, y estilos, manteniendo la filosofía de Zig: simple, explícito, y sin magia. + +**Nombre**: "zcat" + "ui" (un guiño a ratatui y la mascota de Zig) + +--- + +## Arquitectura Objetivo + +### Diseño: Immediate Mode Rendering + +Como ratatui, usamos **renderizado inmediato con buffers intermedios**: + +``` +┌─────────────┐ ┌────────┐ ┌──────────┐ +│ Application │───▶│ Buffer │───▶│ Terminal │ +│ (widgets) │ │ (cells)│ │ (output) │ +└─────────────┘ └────────┘ └──────────┘ +``` + +- Cada frame, la aplicación renderiza TODOS los widgets al buffer +- El buffer se compara con el anterior (diff) +- Solo se envían cambios a la terminal (eficiencia) + +### Módulos Principales (Objetivo) + +``` +zcatui/ +├── src/ +│ ├── root.zig # Entry point, re-exports públicos +│ ├── terminal.zig # Terminal abstraction +│ ├── buffer.zig # Buffer + Cell types +│ ├── layout.zig # Layout, Constraint, Rect +│ ├── style.zig # Color, Style, Modifier +│ ├── text.zig # Text, Line, Span +│ ├── backend/ +│ │ ├── backend.zig # Backend interface +│ │ └── ansi.zig # ANSI escape sequences (default) +│ └── widgets/ +│ ├── widget.zig # Widget trait/interface +│ ├── block.zig # Block (borders, titles) +│ ├── paragraph.zig # Text paragraphs +│ ├── list.zig # Selectable lists +│ ├── table.zig # Tables with columns +│ ├── gauge.zig # Progress bars +│ ├── chart.zig # Line/bar charts +│ ├── canvas.zig # Arbitrary drawing +│ └── tabs.zig # Tab navigation +├── build.zig +└── examples/ + ├── hello.zig # Minimal example + ├── demo.zig # Feature showcase + └── counter.zig # Interactive counter +``` + +--- + +## Fases de Implementación + +### Fase 1: Core (Mínimo Viable) +- [ ] Buffer + Cell (almacenamiento de caracteres + estilos) +- [ ] Style + Color (colores 16, 256, RGB) +- [ ] Rect (área rectangular) +- [ ] Backend ANSI (escape sequences para cualquier terminal) +- [ ] Terminal (init, draw, restore) +- [ ] Widget trait básico + +### Fase 2: Layout +- [ ] Constraint (Min, Max, Percentage, Length, Ratio) +- [ ] Layout (horizontal, vertical splitting) +- [ ] Direction (Horizontal, Vertical) + +### Fase 3: Widgets Básicos +- [ ] Block (borders, titles, padding) +- [ ] Paragraph (texto con wrapping) +- [ ] List (items seleccionables) + +### Fase 4: Widgets Avanzados +- [ ] Table (columnas, headers, selección) +- [ ] Gauge (barra de progreso) +- [ ] Tabs (navegación por pestañas) +- [ ] Chart (gráficos simples) +- [ ] Canvas (dibujo libre con braille/block chars) + +### Fase 5: Extras +- [ ] Input handling (keyboard events) +- [ ] Mouse support +- [ ] Scrolling +- [ ] Animations (opcional) + +--- + +## Conceptos Clave de ratatui a Replicar + +### 1. Cell +Unidad mínima del buffer: un carácter + su estilo. + +```zig +const Cell = struct { + char: u21, // Unicode codepoint + fg: Color, // Foreground color + bg: Color, // Background color + modifiers: Modifiers, // Bold, italic, underline, etc. +}; +``` + +### 2. Buffer +Grid de celdas que representa el estado de la terminal. + +```zig +const Buffer = struct { + area: Rect, + cells: []Cell, + + pub fn get(self: *Buffer, x: u16, y: u16) *Cell { ... } + pub fn set_string(self: *Buffer, x: u16, y: u16, text: []const u8, style: Style) void { ... } + pub fn diff(self: *Buffer, other: *Buffer) []Update { ... } +}; +``` + +### 3. Rect +Área rectangular en la terminal. + +```zig +const Rect = struct { + x: u16, + y: u16, + width: u16, + height: u16, + + pub fn inner(self: Rect, margin: Margin) Rect { ... } + pub fn intersection(self: Rect, other: Rect) Rect { ... } +}; +``` + +### 4. Style +Combinación de colores y modificadores. + +```zig +const Style = struct { + fg: ?Color = null, + bg: ?Color = null, + modifiers: Modifiers = .{}, + + pub fn fg(color: Color) Style { ... } + pub fn bg(color: Color) Style { ... } + pub fn bold() Style { ... } + pub fn patch(self: Style, other: Style) Style { ... } +}; +``` + +### 5. Layout +Sistema de distribución de espacio. + +```zig +const Layout = struct { + direction: Direction, + constraints: []const Constraint, + + pub fn split(self: Layout, area: Rect) []Rect { ... } +}; + +const Constraint = union(enum) { + length: u16, // Exactly N cells + min: u16, // At least N cells + max: u16, // At most N cells + percentage: u16, // N% of available space + ratio: struct { num: u32, den: u32 }, +}; +``` + +### 6. Widget Interface +Trait que deben implementar todos los widgets. + +```zig +const Widget = struct { + ptr: *anyopaque, + render_fn: *const fn(*anyopaque, Rect, *Buffer) void, + + pub fn render(self: Widget, area: Rect, buf: *Buffer) void { + self.render_fn(self.ptr, area, buf); + } +}; + +// Ejemplo implementación: +const Block = struct { + title: ?[]const u8 = null, + borders: Borders = .all, + style: Style = .{}, + + pub fn widget(self: *Block) Widget { + return .{ + .ptr = self, + .render_fn = render, + }; + } + + fn render(ptr: *anyopaque, area: Rect, buf: *Buffer) void { + const self: *Block = @ptrCast(@alignCast(ptr)); + // ... render logic + } +}; +``` + +--- + +## Referencia: ratatui Widgets + +| Widget | Descripción | Prioridad | +|--------|-------------|-----------| +| **Block** | Contenedor con bordes y título | Alta | +| **Paragraph** | Texto con wrap y scroll | Alta | +| **List** | Lista seleccionable | Alta | +| **Table** | Tabla con columnas | Media | +| **Gauge** | Barra de progreso | Media | +| **Sparkline** | Gráfico mini de línea | Baja | +| **Chart** | Gráficos de línea/barras | Baja | +| **Canvas** | Dibujo libre (braille) | Baja | +| **BarChart** | Gráfico de barras | Baja | +| **Tabs** | Navegación por tabs | Media | +| **Scrollbar** | Indicador de scroll | Media | +| **Calendar** | Widget de calendario | Baja | + +--- + +## Stack Técnico + +| Componente | Elección | +|------------|----------| +| **Lenguaje** | Zig 0.15.2 | +| **Zig path** | `/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig` | +| **Backend** | ANSI escape sequences (portable) | +| **Sin dependencias externas** | Solo stdlib de Zig | +| **Target** | Linux primario, cross-platform objetivo | + +--- + +## Equipo y Metodología + +### Quiénes Somos +- **Usuario**: Desarrollador independiente, proyectos comerciales propios +- **Claude**: Asistente de programación (Claude Code) + +### Normas de Trabajo Centralizadas + +**IMPORTANTE**: Todas las normas de trabajo están en: +``` +/mnt/cello2/arno/re/recode/TEAM_STANDARDS/ +``` + +**Archivos clave a leer**: +- `LAST_UPDATE.md` - **LEER PRIMERO** - Cambios recientes en normas +- `NORMAS_TRABAJO_CONSENSUADAS.md` - Metodología fundamental +- `QUICK_REFERENCE.md` - Cheat sheet rápido +- `INFRASTRUCTURE/` - Documentación de servidores + +### Protocolo de Inicio de Conversación + +1. **Leer** `TEAM_STANDARDS/LAST_UPDATE.md` (detectar cambios recientes) +2. **Leer** este archivo `CLAUDE.md` +3. **Verificar** estado del proyecto (`git status`, `zig build`) +4. **Continuar** desde donde se dejó + +--- + +## Principios de Desarrollo + +### Estándares Zig Open Source (#24 de NORMAS) + +> **Este proyecto será público. El código debe ser ejemplar.** + +| Aspecto | Estándar | +|---------|----------| +| **Claridad** | Código autoexplicativo, nombres descriptivos | +| **Comentarios** | Doc comments (`///`) en TODAS las funciones públicas | +| **Estructura** | Organización lógica, separación de responsabilidades | +| **Idiomático** | snake_case, error handling explícito, sin magia | + +```zig +/// Renderiza un widget Block en el área especificada. +/// +/// Dibuja los bordes según `borders` y el título si está definido. +/// El área interior queda disponible para contenido hijo. +pub fn render(self: *Block, area: Rect, buf: *Buffer) void { + // ... +} +``` + +### Principios Generales + +- **DRY**: Una sola función por tarea +- **Fragmentación**: <400 líneas core, <200 líneas utils +- **Testing progresivo**: Compilar y probar cada cambio +- **Funcionalidad > Performance**: Primero que funcione, luego optimizar + +--- + +## API de Zig 0.15.2 - Cambios Importantes + +> Ver guía completa: `TEAM_STANDARDS/INFRASTRUCTURE/ZIG_0.15_GUIA.md` + +### Cambios Clave para este Proyecto + +| Componente | Zig 0.15 | +|------------|----------| +| stdout | `std.fs.File.stdout().deprecatedWriter()` | +| ArrayList | `std.array_list.Managed(T).init(alloc)` | +| file.reader() | `file.deprecatedReader()` | +| sleep | `std.Thread.sleep()` | + +### Terminal I/O + +```zig +// Escribir a stdout +const stdout = std.fs.File.stdout(); +const writer = stdout.deprecatedWriter(); +try writer.print("\x1b[2J", .{}); // Clear screen + +// Leer de stdin (para eventos) +const stdin = std.fs.File.stdin(); +const reader = stdin.deprecatedReader(); +``` + +--- + +## Otros Proyectos del Ecosistema + +### Proyectos Zig +| Proyecto | Descripción | Estado | +|----------|-------------|--------| +| **service-monitor** | Monitor HTTP/TCP con notificaciones | Completado | + +### Proyectos Go (referencia) +| Proyecto | Descripción | +|----------|-------------| +| **simifactu** | API facturación electrónica | +| **ms-web** (mundisofa) | Web e-commerce | +| **0fiS** | Aplicación desktop Fyne | + +### Infraestructura +| Recurso | Ubicación | +|---------|-----------| +| **Git server** | git.reugenio.com (Forgejo) | +| **Servidor** | Simba (188.245.244.244) | +| **Docs infra** | `TEAM_STANDARDS/INFRASTRUCTURE/` | + +--- + +## Control de Versiones + +```bash +# Remote (cuando se cree el repo) +git remote: git@git.reugenio.com:reugenio/zcatui.git + +# Comandos frecuentes +zig build # Compilar +zig build test # Tests +zig build run -- examples/hello.zig # Ejecutar ejemplo +``` + +--- + +## Ejemplo de Uso (Objetivo Final) + +```zig +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Block = zcatui.widgets.Block; +const Paragraph = zcatui.widgets.Paragraph; +const Layout = zcatui.Layout; +const Constraint = zcatui.Constraint; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Inicializar terminal + var term = try Terminal.init(allocator); + defer term.deinit(); + + // Loop principal + while (true) { + try term.draw(struct { + pub fn render(frame: *Frame) void { + // Layout: dividir en 2 áreas + const chunks = Layout.vertical(&.{ + Constraint.length(3), + Constraint.min(0), + }).split(frame.area); + + // Header + var header = Block.init() + .title("zcatui Demo") + .borders(.all); + frame.render(header.widget(), chunks[0]); + + // Content + var content = Paragraph.init("Hello from zcatui!") + .block(Block.init().borders(.all)); + frame.render(content.widget(), chunks[1]); + } + }.render); + + // Handle input + if (try term.pollEvent()) |event| { + if (event.key == .q) break; + } + } +} +``` + +--- + +## Recursos y Referencias + +### ratatui (Rust) +- Repo: https://github.com/ratatui/ratatui +- Docs: https://docs.rs/ratatui/latest/ratatui/ +- Website: https://ratatui.rs/ + +### Zig +- Docs 0.15: https://ziglang.org/documentation/0.15.0/std/ +- Guía migración: `TEAM_STANDARDS/INFRASTRUCTURE/ZIG_0.15_GUIA.md` + +### ANSI Escape Codes +- Referencia: https://en.wikipedia.org/wiki/ANSI_escape_code +- Colores: https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 + +--- + +## Estado del Proyecto + +| Componente | Estado | +|------------|--------| +| CLAUDE.md | ✅ Creado | +| build.zig | ⏳ Pendiente | +| Fase 1 (Core) | ⏳ Pendiente | +| Fase 2 (Layout) | ⏳ Pendiente | +| Fase 3 (Widgets básicos) | ⏳ Pendiente | +| Fase 4 (Widgets avanzados) | ⏳ Pendiente | +| Fase 5 (Input/extras) | ⏳ Pendiente | + +--- + +**Próximos pasos sugeridos para la primera sesión:** +1. Crear `build.zig` básico +2. Implementar `src/style.zig` (Color, Style, Modifiers) +3. Implementar `src/buffer.zig` (Cell, Buffer, Rect) +4. Implementar `src/backend/ansi.zig` (escape sequences) +5. Crear ejemplo mínimo que pinte algo en pantalla diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..91d3909 --- /dev/null +++ b/build.zig @@ -0,0 +1,45 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Módulo de la librería + const zcatui_mod = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }); + + // Tests + const unit_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }), + }); + + const run_unit_tests = b.addRunArtifact(unit_tests); + const test_step = b.step("test", "Ejecutar tests"); + test_step.dependOn(&run_unit_tests.step); + + // Ejemplo: hello + const hello_exe = b.addExecutable(.{ + .name = "hello", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/hello.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(hello_exe); + + const run_hello = b.addRunArtifact(hello_exe); + run_hello.step.dependOn(b.getInstallStep()); + const hello_step = b.step("hello", "Ejecutar ejemplo hello"); + hello_step.dependOn(&run_hello.step); +} diff --git a/examples/hello.zig b/examples/hello.zig new file mode 100644 index 0000000..62cad27 --- /dev/null +++ b/examples/hello.zig @@ -0,0 +1,93 @@ +//! Hello World example for zcatui. +//! +//! Demonstrates basic usage of the library: +//! - Creating a Terminal +//! - Rendering a Block with a title +//! - Using Layout to split the screen +//! - Displaying a Paragraph + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Buffer = zcatui.Buffer; +const Rect = zcatui.Rect; +const Layout = zcatui.Layout; +const Constraint = zcatui.Constraint; +const Style = zcatui.Style; +const Color = zcatui.Color; +const block_mod = @import("zcatui").widgets.block_mod; +const Block = block_mod.Block; +const Borders = block_mod.Borders; +const Paragraph = zcatui.widgets.Paragraph; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Initialize terminal + var term = try Terminal.init(allocator); + defer term.deinit(); + + // Draw the UI + try term.draw(render); + + // Wait for 'q' to quit + const stdin = std.fs.File.stdin(); + var buf: [1]u8 = undefined; + while (true) { + const bytes_read = stdin.read(&buf) catch break; + if (bytes_read == 0) break; + if (buf[0] == 'q') break; + + // Redraw on any key + try term.draw(render); + } +} + +fn render(area: Rect, buf: *Buffer) void { + // Split screen: header (3 rows) + content (rest) + const chunks = Layout.vertical(&.{ + Constraint.length(3), + Constraint.min(0), + }).split(area); + + // Header block + const header = Block.init() + .title(" zcatui Demo ") + .setBorders(Borders.all) + .borderStyle(Style.default.fg(Color.cyan)); + + header.render(chunks.get(0), buf); + + // Content area with paragraph + const content_block = Block.init() + .title(" Welcome ") + .setBorders(Borders.all) + .borderStyle(Style.default.fg(Color.green)); + + const content_area = chunks.get(1); + content_block.render(content_area, buf); + + // Paragraph inside the content block + const text = + \\Hello from zcatui! + \\ + \\This is a TUI library for Zig, inspired by ratatui. + \\ + \\Features: + \\ - Immediate mode rendering + \\ - Layout system with constraints + \\ - Styled text and colors + \\ - Reusable widgets + \\ + \\Press 'q' to quit. + ; + + const para = Paragraph.init(text) + .style(Style.default.fg(Color.white)) + .setWrap(.word); + + para.render(content_block.inner(content_area), buf); +} diff --git a/src/backend/backend.zig b/src/backend/backend.zig new file mode 100644 index 0000000..f26de56 --- /dev/null +++ b/src/backend/backend.zig @@ -0,0 +1,222 @@ +//! Backend for terminal I/O. +//! +//! Provides abstraction over terminal escape sequences. +//! Currently implements ANSI escape codes which work on most terminals. + +const std = @import("std"); +const style = @import("../style.zig"); +const Color = style.Color; +const Modifier = style.Modifier; + +/// Terminal size. +pub const Size = struct { + width: u16, + height: u16, +}; + +/// ANSI escape sequence backend. +/// +/// Writes ANSI escape codes directly to stdout. +pub const AnsiBackend = struct { + stdout: std.fs.File, + original_termios: ?std.posix.termios = null, + + /// Creates a new ANSI backend. + pub fn init() AnsiBackend { + return .{ + .stdout = std.fs.File.stdout(), + }; + } + + /// Gets the terminal size. + pub fn getSize(self: *AnsiBackend) Size { + _ = self; + // Try to get size from ioctl + 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 80x24 + return .{ .width = 80, .height = 24 }; + } + + /// Enables raw mode (disables line buffering, echo, etc.). + pub fn enableRawMode(self: *AnsiBackend) !void { + const fd = std.posix.STDIN_FILENO; + self.original_termios = try std.posix.tcgetattr(fd); + + var raw = self.original_termios.?; + + // Input flags: disable break signal, CR to NL, parity, strip, flow control + raw.iflag.BRKINT = false; + raw.iflag.ICRNL = false; + raw.iflag.INPCK = false; + raw.iflag.ISTRIP = false; + raw.iflag.IXON = false; + + // Output flags: disable post-processing + raw.oflag.OPOST = false; + + // Control flags: set 8-bit chars + raw.cflag.CSIZE = .CS8; + + // Local flags: disable echo, canonical mode, signals, extended input + raw.lflag.ECHO = false; + raw.lflag.ICANON = false; + raw.lflag.IEXTEN = false; + raw.lflag.ISIG = false; + + // Control chars: return immediately with any amount of data + raw.cc[@intFromEnum(std.posix.V.MIN)] = 0; + raw.cc[@intFromEnum(std.posix.V.TIME)] = 1; // 100ms timeout + + try std.posix.tcsetattr(fd, .FLUSH, raw); + } + + /// Disables raw mode (restores original terminal settings). + pub fn disableRawMode(self: *AnsiBackend) !void { + if (self.original_termios) |termios| { + try std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, termios); + self.original_termios = null; + } + } + + /// Enters the alternate screen buffer. + pub fn enterAlternateScreen(self: *AnsiBackend) !void { + try self.writeEscape("\x1b[?1049h"); + } + + /// Leaves the alternate screen buffer. + pub fn leaveAlternateScreen(self: *AnsiBackend) !void { + try self.writeEscape("\x1b[?1049l"); + } + + /// Hides the cursor. + pub fn hideCursor(self: *AnsiBackend) !void { + try self.writeEscape("\x1b[?25l"); + } + + /// Shows the cursor. + pub fn showCursor(self: *AnsiBackend) !void { + try self.writeEscape("\x1b[?25h"); + } + + /// Clears the screen. + pub fn clear(self: *AnsiBackend) !void { + try self.writeEscape("\x1b[2J"); + try self.moveCursor(0, 0); + } + + /// Moves the cursor to (x, y). + pub fn moveCursor(self: *AnsiBackend, x: u16, y: u16) !void { + var buf: [32]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d};{d}H", .{ y + 1, x + 1 }) catch return; + try self.writeEscape(seq); + } + + /// Sets the current style (colors and modifiers). + pub fn setStyle(self: *AnsiBackend, fg: Color, bg: Color, modifiers: Modifier) !void { + // Reset first + try self.writeEscape("\x1b[0m"); + + // Apply modifiers + if (modifiers.bold) try self.writeEscape("\x1b[1m"); + if (modifiers.dim) try self.writeEscape("\x1b[2m"); + if (modifiers.italic) try self.writeEscape("\x1b[3m"); + if (modifiers.underlined) try self.writeEscape("\x1b[4m"); + if (modifiers.slow_blink) try self.writeEscape("\x1b[5m"); + if (modifiers.rapid_blink) try self.writeEscape("\x1b[6m"); + if (modifiers.reversed) try self.writeEscape("\x1b[7m"); + if (modifiers.hidden) try self.writeEscape("\x1b[8m"); + if (modifiers.crossed_out) try self.writeEscape("\x1b[9m"); + + // Apply foreground color + try self.applyColor(fg, false); + + // Apply background color + try self.applyColor(bg, true); + } + + /// Applies a color (foreground or background). + fn applyColor(self: *AnsiBackend, color: Color, is_bg: bool) !void { + var buf: [32]u8 = undefined; + + switch (color) { + .reset => { + const code: u8 = if (is_bg) 49 else 39; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}m", .{code}) catch return; + try self.writeEscape(seq); + }, + .ansi => |c| { + const base: u8 = if (is_bg) 40 else 30; + const code = @intFromEnum(c); + if (code < 8) { + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}m", .{base + code}) catch return; + try self.writeEscape(seq); + } else { + // Bright colors + const bright_base: u8 = if (is_bg) 100 else 90; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}m", .{bright_base + code - 8}) catch return; + try self.writeEscape(seq); + } + }, + .idx => |i| { + const prefix: u8 = if (is_bg) 48 else 38; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d};5;{d}m", .{ prefix, i }) catch return; + try self.writeEscape(seq); + }, + .true_color => |tc| { + const prefix: u8 = if (is_bg) 48 else 38; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d};2;{d};{d};{d}m", .{ prefix, tc.r, tc.g, tc.b }) catch return; + try self.writeEscape(seq); + }, + } + } + + /// Writes a single character. + pub fn writeChar(self: *AnsiBackend, char: u21) !void { + var buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(char, &buf) catch return; + _ = self.stdout.write(buf[0..len]) catch {}; + } + + /// Flushes output to the terminal. + pub fn flush(self: *AnsiBackend) !void { + // stdout is typically unbuffered or line-buffered + // No explicit flush needed for std.fs.File + _ = self; + } + + /// Writes an escape sequence. + fn writeEscape(self: *AnsiBackend, seq: []const u8) !void { + _ = self.stdout.write(seq) catch {}; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "AnsiBackend creation" { + _ = AnsiBackend.init(); +} + +test "Size defaults" { + var backend = AnsiBackend.init(); + const size = backend.getSize(); + try std.testing.expect(size.width > 0); + try std.testing.expect(size.height > 0); +} diff --git a/src/buffer.zig b/src/buffer.zig new file mode 100644 index 0000000..a369acf --- /dev/null +++ b/src/buffer.zig @@ -0,0 +1,337 @@ +//! Buffer and Cell types for terminal rendering. +//! +//! A Buffer represents the state of the terminal as a grid of Cells. +//! Each Cell contains a character and its styling information. +//! +//! ## Architecture +//! +//! ``` +//! Buffer (grid of cells) +//! ┌────┬────┬────┬────┐ +//! │Cell│Cell│Cell│Cell│ row 0 +//! ├────┼────┼────┼────┤ +//! │Cell│Cell│Cell│Cell│ row 1 +//! └────┴────┴────┴────┘ +//! ``` + +const std = @import("std"); +const style = @import("style.zig"); +const Color = style.Color; +const Style = style.Style; +const Modifier = style.Modifier; + +/// A rectangular area in the terminal. +/// +/// All coordinates are 0-indexed from the top-left corner. +pub const Rect = struct { + x: u16 = 0, + y: u16 = 0, + width: u16 = 0, + height: u16 = 0, + + pub const empty: Rect = .{}; + + /// Creates a new Rect. + pub fn init(x: u16, y: u16, width: u16, height: u16) Rect { + return .{ .x = x, .y = y, .width = width, .height = height }; + } + + /// Area (number of cells). + pub fn area(self: Rect) u32 { + return @as(u32, self.width) * @as(u32, self.height); + } + + /// Returns true if the rect has no area. + pub fn isEmpty(self: Rect) bool { + return self.width == 0 or self.height == 0; + } + + /// Left edge (x coordinate). + pub fn left(self: Rect) u16 { + return self.x; + } + + /// Right edge (x + width). + pub fn right(self: Rect) u16 { + return self.x +| self.width; + } + + /// Top edge (y coordinate). + pub fn top(self: Rect) u16 { + return self.y; + } + + /// Bottom edge (y + height). + pub fn bottom(self: Rect) u16 { + return self.y +| self.height; + } + + /// Returns a new Rect with margins applied (shrunk inward). + pub fn inner(self: Rect, margin: Margin) Rect { + const horizontal = margin.left +| margin.right; + const vertical = margin.top +| margin.bottom; + + if (horizontal >= self.width or vertical >= self.height) { + return Rect.empty; + } + + return .{ + .x = self.x +| margin.left, + .y = self.y +| margin.top, + .width = self.width -| horizontal, + .height = self.height -| vertical, + }; + } + + /// Returns the intersection of two rectangles. + pub fn intersection(self: Rect, other: Rect) Rect { + const x1 = @max(self.x, other.x); + const y1 = @max(self.y, other.y); + const x2 = @min(self.right(), other.right()); + const y2 = @min(self.bottom(), other.bottom()); + + if (x1 >= x2 or y1 >= y2) { + return Rect.empty; + } + + return .{ + .x = x1, + .y = y1, + .width = x2 - x1, + .height = y2 - y1, + }; + } + + /// Returns true if the point (x, y) is inside the rect. + pub fn contains(self: Rect, x: u16, y: u16) bool { + return x >= self.x and x < self.right() and + y >= self.y and y < self.bottom(); + } +}; + +/// Margin for Rect.inner(). +pub const Margin = struct { + top: u16 = 0, + right: u16 = 0, + bottom: u16 = 0, + left: u16 = 0, + + pub const zero: Margin = .{}; + + /// Uniform margin on all sides. + pub fn uniform(value: u16) Margin { + return .{ .top = value, .right = value, .bottom = value, .left = value }; + } + + /// Symmetric margin (horizontal, vertical). + pub fn symmetric(horizontal: u16, vertical: u16) Margin { + return .{ .top = vertical, .right = horizontal, .bottom = vertical, .left = horizontal }; + } +}; + +/// A single cell in the terminal buffer. +/// +/// Contains a character (as Unicode codepoint) and styling information. +pub const Cell = struct { + /// The character to display (Unicode codepoint, space by default). + char: u21 = ' ', + /// Foreground color. + fg: Color = .reset, + /// Background color. + bg: Color = .reset, + /// Text modifiers (bold, italic, etc.). + modifiers: Modifier = .{}, + /// Whether this cell has been modified and needs redraw. + dirty: bool = true, + + pub const empty: Cell = .{}; + + /// Creates a cell with the given character. + pub fn init(char: u21) Cell { + return .{ .char = char }; + } + + /// Sets the style of the cell. + pub fn setStyle(self: *Cell, s: Style) void { + if (s.foreground) |fg_color| self.fg = fg_color; + if (s.background) |bg_color| self.bg = bg_color; + self.modifiers = self.modifiers.insert(s.add_modifiers); + self.modifiers = self.modifiers.remove(s.sub_modifiers); + self.dirty = true; + } + + /// Resets the cell to empty. + pub fn reset(self: *Cell) void { + self.* = Cell.empty; + } +}; + +/// A buffer holding a grid of cells representing terminal state. +/// +/// The buffer uses row-major order: index = y * width + x +pub const Buffer = struct { + area: Rect, + cells: []Cell, + allocator: std.mem.Allocator, + + /// Creates a new buffer for the given area. + pub fn init(allocator: std.mem.Allocator, rect: Rect) !Buffer { + const size = rect.area(); + const cells = try allocator.alloc(Cell, size); + @memset(cells, Cell.empty); + + return .{ + .area = rect, + .cells = cells, + .allocator = allocator, + }; + } + + /// Frees the buffer memory. + pub fn deinit(self: *Buffer) void { + self.allocator.free(self.cells); + } + + /// Gets the index in the cells array for position (x, y). + fn indexAt(self: *const Buffer, x: u16, y: u16) ?usize { + if (!self.area.contains(x, y)) { + return null; + } + const local_x = x - self.area.x; + const local_y = y - self.area.y; + return @as(usize, local_y) * @as(usize, self.area.width) + @as(usize, local_x); + } + + /// Gets a pointer to the cell at (x, y), or null if out of bounds. + pub fn getPtr(self: *Buffer, x: u16, y: u16) ?*Cell { + const idx = self.indexAt(x, y) orelse return null; + return &self.cells[idx]; + } + + /// Gets the cell at (x, y), or null if out of bounds. + pub fn get(self: *const Buffer, x: u16, y: u16) ?Cell { + const idx = self.indexAt(x, y) orelse return null; + return self.cells[idx]; + } + + /// Sets a single character at (x, y) with the given style. + pub fn setChar(self: *Buffer, x: u16, y: u16, char: u21, s: Style) void { + if (self.getPtr(x, y)) |cell| { + cell.char = char; + cell.setStyle(s); + } + } + + /// Writes a string starting at (x, y) with the given style. + /// Returns the number of cells written. + pub fn setString(self: *Buffer, x: u16, y: u16, text: []const u8, s: Style) u16 { + var current_x = x; + var iter = std.unicode.Utf8Iterator{ .bytes = text, .i = 0 }; + + while (iter.nextCodepoint()) |cp| { + if (current_x >= self.area.right()) break; + self.setChar(current_x, y, cp, s); + current_x += 1; + } + + return current_x - x; + } + + /// Fills the entire buffer (or a sub-area) with a character and style. + pub fn fill(self: *Buffer, rect: Rect, char: u21, s: Style) void { + const target = self.area.intersection(rect); + if (target.isEmpty()) return; + + var y = target.y; + while (y < target.bottom()) : (y += 1) { + var cur_x = target.x; + while (cur_x < target.right()) : (cur_x += 1) { + self.setChar(cur_x, y, char, s); + } + } + } + + /// Clears the buffer (fills with spaces and default style). + pub fn clear(self: *Buffer) void { + @memset(self.cells, Cell.empty); + } + + /// Marks all cells as dirty (need redraw). + pub fn markDirty(self: *Buffer) void { + for (self.cells) |*cell| { + cell.dirty = true; + } + } + + /// Marks all cells as clean. + pub fn markClean(self: *Buffer) void { + for (self.cells) |*cell| { + cell.dirty = false; + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Rect basic operations" { + const r = Rect.init(10, 20, 100, 50); + + try std.testing.expectEqual(@as(u16, 10), r.left()); + try std.testing.expectEqual(@as(u16, 110), r.right()); + try std.testing.expectEqual(@as(u16, 20), r.top()); + try std.testing.expectEqual(@as(u16, 70), r.bottom()); + try std.testing.expectEqual(@as(u32, 5000), r.area()); +} + +test "Rect inner" { + const r = Rect.init(0, 0, 10, 10); + const inner = r.inner(Margin.uniform(1)); + + try std.testing.expectEqual(@as(u16, 1), inner.x); + try std.testing.expectEqual(@as(u16, 1), inner.y); + try std.testing.expectEqual(@as(u16, 8), inner.width); + try std.testing.expectEqual(@as(u16, 8), inner.height); +} + +test "Rect intersection" { + const r1 = Rect.init(0, 0, 10, 10); + const r2 = Rect.init(5, 5, 10, 10); + const inter = r1.intersection(r2); + + try std.testing.expectEqual(@as(u16, 5), inter.x); + try std.testing.expectEqual(@as(u16, 5), inter.y); + try std.testing.expectEqual(@as(u16, 5), inter.width); + try std.testing.expectEqual(@as(u16, 5), inter.height); +} + +test "Buffer creation and access" { + const allocator = std.testing.allocator; + var buf = try Buffer.init(allocator, Rect.init(0, 0, 80, 24)); + defer buf.deinit(); + + try std.testing.expectEqual(@as(usize, 80 * 24), buf.cells.len); + + // Set a character + buf.setChar(5, 5, 'X', Style.default.fg(Color.red)); + + const cell = buf.get(5, 5).?; + try std.testing.expectEqual(@as(u21, 'X'), cell.char); + try std.testing.expectEqual(Color.red, cell.fg); +} + +test "Buffer setString" { + const allocator = std.testing.allocator; + var buf = try Buffer.init(allocator, Rect.init(0, 0, 80, 24)); + defer buf.deinit(); + + const written = buf.setString(0, 0, "Hello", Style{}); + try std.testing.expectEqual(@as(u16, 5), written); + + try std.testing.expectEqual(@as(u21, 'H'), buf.get(0, 0).?.char); + try std.testing.expectEqual(@as(u21, 'e'), buf.get(1, 0).?.char); + try std.testing.expectEqual(@as(u21, 'l'), buf.get(2, 0).?.char); + try std.testing.expectEqual(@as(u21, 'l'), buf.get(3, 0).?.char); + try std.testing.expectEqual(@as(u21, 'o'), buf.get(4, 0).?.char); +} diff --git a/src/layout.zig b/src/layout.zig new file mode 100644 index 0000000..c713ed6 --- /dev/null +++ b/src/layout.zig @@ -0,0 +1,253 @@ +//! Layout system for dividing terminal space. +//! +//! Layouts allow you to split a Rect into multiple sub-areas +//! using flexible constraints. +//! +//! ## Example +//! +//! ```zig +//! // Split vertically: 3 rows header, rest for content +//! const chunks = Layout.vertical(&.{ +//! Constraint.length(3), +//! Constraint.min(0), +//! }).split(frame.area); +//! +//! // chunks[0] = header area (3 rows) +//! // chunks[1] = content area (remaining space) +//! ``` + +const std = @import("std"); +const Rect = @import("buffer.zig").Rect; + +/// Layout direction. +pub const Direction = enum { + horizontal, + vertical, +}; + +/// Constraints for layout sizing. +/// +/// Each constraint specifies how much space a section should take. +pub const Constraint = union(enum) { + /// Exactly N cells. + len: u16, + /// At least N cells. + min_size: u16, + /// At most N cells. + max_size: u16, + /// N% of available space. + pct: u16, + /// Ratio of available space (numerator/denominator). + rat: struct { num: u32, den: u32 }, + + /// Creates a length constraint. + pub fn length(n: u16) Constraint { + return .{ .len = n }; + } + + /// Creates a min constraint. + pub fn min(n: u16) Constraint { + return .{ .min_size = n }; + } + + /// Creates a max constraint. + pub fn max(n: u16) Constraint { + return .{ .max_size = n }; + } + + /// Creates a percentage constraint. + pub fn percentage(n: u16) Constraint { + return .{ .pct = n }; + } + + /// Creates a ratio constraint. + pub fn ratio(num: u32, den: u32) Constraint { + return .{ .rat = .{ .num = num, .den = den } }; + } +}; + +/// Layout splits an area into multiple sub-areas. +pub const Layout = struct { + direction: Direction, + constraints: []const Constraint, + margin: u16 = 0, + + /// Creates a horizontal layout (left to right). + pub fn horizontal(constraints: []const Constraint) Layout { + return .{ + .direction = .horizontal, + .constraints = constraints, + }; + } + + /// Creates a vertical layout (top to bottom). + pub fn vertical(constraints: []const Constraint) Layout { + return .{ + .direction = .vertical, + .constraints = constraints, + }; + } + + /// Sets the margin around the entire layout. + pub fn withMargin(self: Layout, m: u16) Layout { + var layout = self; + layout.margin = m; + return layout; + } + + /// Splits the given area according to the constraints. + /// + /// Returns an array of Rects, one for each constraint. + /// Uses a simple greedy algorithm for distribution. + pub fn split(self: Layout, area: Rect) SplitResult { + // Apply margin + const inner_area = if (self.margin > 0) + area.inner(.{ + .top = self.margin, + .right = self.margin, + .bottom = self.margin, + .left = self.margin, + }) + else + area; + + if (inner_area.isEmpty()) { + return .{}; + } + + const total_space: u32 = switch (self.direction) { + .horizontal => inner_area.width, + .vertical => inner_area.height, + }; + + // Calculate sizes for each constraint + var result: SplitResult = .{}; + var remaining: u32 = total_space; + var position: u16 = switch (self.direction) { + .horizontal => inner_area.x, + .vertical => inner_area.y, + }; + + for (self.constraints) |constraint| { + if (result.count >= SplitResult.MAX_SPLITS) break; + + const size: u16 = switch (constraint) { + .len => |n| @min(n, @as(u16, @intCast(remaining))), + .min_size => |n| @min(n, @as(u16, @intCast(remaining))), + .max_size => |n| @min(n, @as(u16, @intCast(remaining))), + .pct => |p| blk: { + const s = (total_space * p) / 100; + break :blk @min(@as(u16, @intCast(s)), @as(u16, @intCast(remaining))); + }, + .rat => |r| blk: { + if (r.den == 0) break :blk 0; + const s = (total_space * r.num) / r.den; + break :blk @min(@as(u16, @intCast(s)), @as(u16, @intCast(remaining))); + }, + }; + + result.rects[result.count] = switch (self.direction) { + .horizontal => Rect.init(position, inner_area.y, size, inner_area.height), + .vertical => Rect.init(inner_area.x, position, inner_area.width, size), + }; + result.count += 1; + + position += size; + remaining -= size; + } + + // Give remaining space to the last constraint with min(0) + if (remaining > 0 and result.count > 0) { + for (0..self.constraints.len) |i| { + const idx = self.constraints.len - 1 - i; + if (idx >= result.count) continue; + + switch (self.constraints[idx]) { + .min_size => |n| { + if (n == 0) { + switch (self.direction) { + .horizontal => { + result.rects[idx].width += @intCast(remaining); + }, + .vertical => { + result.rects[idx].height += @intCast(remaining); + }, + } + break; + } + }, + else => {}, + } + } + } + + return result; + } +}; + +/// Result of Layout.split(). +pub const SplitResult = struct { + pub const MAX_SPLITS: usize = 16; + + rects: [MAX_SPLITS]Rect = [_]Rect{Rect.empty} ** MAX_SPLITS, + count: usize = 0, + + /// Gets the rect at index, or empty if out of bounds. + pub fn get(self: *const SplitResult, index: usize) Rect { + if (index >= self.count) return Rect.empty; + return self.rects[index]; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Layout vertical split" { + const area = Rect.init(0, 0, 80, 24); + const layout = Layout.vertical(&.{ + Constraint.length(3), + Constraint.min(0), + }); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(usize, 2), result.count); + + // First chunk: 3 rows at top + try std.testing.expectEqual(@as(u16, 0), result.rects[0].y); + try std.testing.expectEqual(@as(u16, 3), result.rects[0].height); + try std.testing.expectEqual(@as(u16, 80), result.rects[0].width); + + // Second chunk: remaining 21 rows + try std.testing.expectEqual(@as(u16, 3), result.rects[1].y); + try std.testing.expectEqual(@as(u16, 21), result.rects[1].height); +} + +test "Layout horizontal split" { + const area = Rect.init(0, 0, 100, 10); + const layout = Layout.horizontal(&.{ + Constraint.percentage(30), + Constraint.percentage(70), + }); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(usize, 2), result.count); + try std.testing.expectEqual(@as(u16, 30), result.rects[0].width); + try std.testing.expectEqual(@as(u16, 70), result.rects[1].width); +} + +test "Layout with margin" { + const area = Rect.init(0, 0, 80, 24); + const layout = Layout.vertical(&.{ + Constraint.min(0), + }).withMargin(2); + + const result = layout.split(area); + + try std.testing.expectEqual(@as(u16, 2), result.rects[0].x); + try std.testing.expectEqual(@as(u16, 2), result.rects[0].y); + try std.testing.expectEqual(@as(u16, 76), result.rects[0].width); + try std.testing.expectEqual(@as(u16, 20), result.rects[0].height); +} diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..6e590ea --- /dev/null +++ b/src/root.zig @@ -0,0 +1,73 @@ +//! zcatui - Terminal User Interface library for Zig +//! +//! Inspired by ratatui (Rust), zcatui provides a simple and flexible way +//! to create text-based user interfaces in the terminal. +//! +//! ## Quick Start +//! +//! ```zig +//! const std = @import("std"); +//! const zcatui = @import("zcatui"); +//! +//! pub fn main() !void { +//! var term = try zcatui.Terminal.init(); +//! defer term.deinit(); +//! +//! try term.draw(ui); +//! } +//! ``` +//! +//! ## Architecture +//! +//! zcatui uses immediate mode rendering with intermediate buffers: +//! - Each frame, all widgets are rendered to a Buffer +//! - Buffer is diffed against previous state +//! - Only changes are sent to the terminal +//! + +const std = @import("std"); + +// Core types +pub const style = @import("style.zig"); +pub const Color = style.Color; +pub const Style = style.Style; +pub const Modifier = style.Modifier; + +pub const buffer = @import("buffer.zig"); +pub const Cell = buffer.Cell; +pub const Buffer = buffer.Buffer; +pub const Rect = buffer.Rect; + +// Re-exports for convenience +pub const terminal = @import("terminal.zig"); +pub const Terminal = terminal.Terminal; + +// Layout +pub const layout = @import("layout.zig"); +pub const Layout = layout.Layout; +pub const Constraint = layout.Constraint; +pub const Direction = layout.Direction; + +// Widgets +pub const widgets = struct { + pub const block_mod = @import("widgets/block.zig"); + pub const Block = block_mod.Block; + pub const Borders = block_mod.Borders; + pub const BorderSet = block_mod.BorderSet; + pub const paragraph_mod = @import("widgets/paragraph.zig"); + pub const Paragraph = paragraph_mod.Paragraph; + // More widgets will be added here +}; + +// Backend +pub const backend = @import("backend/backend.zig"); + +// ============================================================================ +// Tests +// ============================================================================ + +test "zcatui module compiles" { + // Basic compilation test + _ = style; + _ = buffer; +} diff --git a/src/style.zig b/src/style.zig new file mode 100644 index 0000000..d6b908c --- /dev/null +++ b/src/style.zig @@ -0,0 +1,247 @@ +//! Style primitives for terminal rendering. +//! +//! This module provides color and style types used throughout zcatui +//! to define how text and widgets appear in the terminal. +//! +//! ## Example +//! +//! ```zig +//! const style = Style{} +//! .fg(Color.red) +//! .bg(Color.black) +//! .add_modifier(.bold); +//! ``` + +const std = @import("std"); + +/// Terminal colors. +/// +/// Supports ANSI 16 colors, 256-color palette, and 24-bit RGB. +pub const Color = union(enum) { + /// Reset to terminal default. + reset, + /// ANSI 16-color palette. + ansi: Ansi, + /// 256-color palette (0-255). + idx: u8, + /// 24-bit RGB color. + true_color: struct { r: u8, g: u8, b: u8 }, + + /// Standard ANSI colors. + pub const Ansi = enum(u8) { + black = 0, + red = 1, + green = 2, + yellow = 3, + blue = 4, + magenta = 5, + cyan = 6, + white = 7, + bright_black = 8, + bright_red = 9, + bright_green = 10, + bright_yellow = 11, + bright_blue = 12, + bright_magenta = 13, + bright_cyan = 14, + bright_white = 15, + }; + + // Convenience constructors + pub const reset_color: Color = .reset; + pub const black: Color = .{ .ansi = .black }; + pub const red: Color = .{ .ansi = .red }; + pub const green: Color = .{ .ansi = .green }; + pub const yellow: Color = .{ .ansi = .yellow }; + pub const blue: Color = .{ .ansi = .blue }; + pub const magenta: Color = .{ .ansi = .magenta }; + pub const cyan: Color = .{ .ansi = .cyan }; + pub const white: Color = .{ .ansi = .white }; + + /// Creates an RGB color. + pub fn rgb(r: u8, g: u8, b: u8) Color { + return .{ .true_color = .{ .r = r, .g = g, .b = b } }; + } + + /// Creates an indexed color (0-255). + pub fn indexed(index: u8) Color { + return .{ .idx = index }; + } +}; + +/// Text modifiers (bold, italic, underline, etc.). +pub const Modifier = packed struct { + bold: bool = false, + dim: bool = false, + italic: bool = false, + underlined: bool = false, + slow_blink: bool = false, + rapid_blink: bool = false, + reversed: bool = false, + hidden: bool = false, + crossed_out: bool = false, + + pub const empty: Modifier = .{}; + pub const BOLD: Modifier = .{ .bold = true }; + pub const DIM: Modifier = .{ .dim = true }; + pub const ITALIC: Modifier = .{ .italic = true }; + pub const UNDERLINED: Modifier = .{ .underlined = true }; + pub const REVERSED: Modifier = .{ .reversed = true }; + pub const CROSSED_OUT: Modifier = .{ .crossed_out = true }; + + /// Combines two modifiers (union). + pub fn insert(self: Modifier, other: Modifier) Modifier { + return .{ + .bold = self.bold or other.bold, + .dim = self.dim or other.dim, + .italic = self.italic or other.italic, + .underlined = self.underlined or other.underlined, + .slow_blink = self.slow_blink or other.slow_blink, + .rapid_blink = self.rapid_blink or other.rapid_blink, + .reversed = self.reversed or other.reversed, + .hidden = self.hidden or other.hidden, + .crossed_out = self.crossed_out or other.crossed_out, + }; + } + + /// Removes modifiers. + pub fn remove(self: Modifier, other: Modifier) Modifier { + return .{ + .bold = self.bold and !other.bold, + .dim = self.dim and !other.dim, + .italic = self.italic and !other.italic, + .underlined = self.underlined and !other.underlined, + .slow_blink = self.slow_blink and !other.slow_blink, + .rapid_blink = self.rapid_blink and !other.rapid_blink, + .reversed = self.reversed and !other.reversed, + .hidden = self.hidden and !other.hidden, + .crossed_out = self.crossed_out and !other.crossed_out, + }; + } +}; + +/// Style combines foreground color, background color, and modifiers. +/// +/// Styles can be composed using builder methods: +/// ```zig +/// const my_style = Style{} +/// .fg(Color.red) +/// .bg(Color.black) +/// .add_modifier(.{ .bold = true }); +/// ``` +pub const Style = struct { + foreground: ?Color = null, + background: ?Color = null, + underline_color: ?Color = null, + add_modifiers: Modifier = .{}, + sub_modifiers: Modifier = .{}, + + pub const default: Style = .{}; + + /// Sets foreground color. + pub fn fg(self: Style, color: Color) Style { + var s = self; + s.foreground = color; + return s; + } + + /// Sets background color. + pub fn bg(self: Style, color: Color) Style { + var s = self; + s.background = color; + return s; + } + + /// Adds modifiers to the style. + pub fn add_modifier(self: Style, modifier: Modifier) Style { + var s = self; + s.add_modifiers = s.add_modifiers.insert(modifier); + s.sub_modifiers = s.sub_modifiers.remove(modifier); + return s; + } + + /// Removes modifiers from the style. + pub fn remove_modifier(self: Style, modifier: Modifier) Style { + var s = self; + s.add_modifiers = s.add_modifiers.remove(modifier); + s.sub_modifiers = s.sub_modifiers.insert(modifier); + return s; + } + + /// Convenience: set bold. + pub fn bold(self: Style) Style { + return self.add_modifier(.{ .bold = true }); + } + + /// Convenience: set italic. + pub fn italic(self: Style) Style { + return self.add_modifier(.{ .italic = true }); + } + + /// Convenience: set underlined. + pub fn underlined(self: Style) Style { + return self.add_modifier(.{ .underlined = true }); + } + + /// Convenience: set dim. + pub fn dim(self: Style) Style { + return self.add_modifier(.{ .dim = true }); + } + + /// Convenience: set reversed. + pub fn reversed(self: Style) Style { + return self.add_modifier(.{ .reversed = true }); + } + + /// Patches this style with another, overwriting set values. + pub fn patch(self: Style, other: Style) Style { + return .{ + .foreground = other.foreground orelse self.foreground, + .background = other.background orelse self.background, + .underline_color = other.underline_color orelse self.underline_color, + .add_modifiers = self.add_modifiers.insert(other.add_modifiers), + .sub_modifiers = self.sub_modifiers.insert(other.sub_modifiers), + }; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Color construction" { + const c1 = Color.red; + try std.testing.expectEqual(Color.Ansi.red, c1.ansi); + + const c2 = Color.rgb(255, 128, 0); + try std.testing.expectEqual(@as(u8, 255), c2.true_color.r); + + const c3 = Color.indexed(200); + try std.testing.expectEqual(@as(u8, 200), c3.idx); +} + +test "Style builder" { + const s = Style.default + .fg(Color.red) + .bg(Color.black) + .bold(); + + try std.testing.expectEqual(Color.red, s.foreground.?); + try std.testing.expectEqual(Color.black, s.background.?); + try std.testing.expect(s.add_modifiers.bold); +} + +test "Modifier operations" { + const m1 = Modifier{ .bold = true, .italic = true }; + const m2 = Modifier{ .italic = true, .underlined = true }; + + const combined = m1.insert(m2); + try std.testing.expect(combined.bold); + try std.testing.expect(combined.italic); + try std.testing.expect(combined.underlined); + + const removed = combined.remove(.{ .italic = true }); + try std.testing.expect(removed.bold); + try std.testing.expect(!removed.italic); + try std.testing.expect(removed.underlined); +} diff --git a/src/terminal.zig b/src/terminal.zig new file mode 100644 index 0000000..8719d50 --- /dev/null +++ b/src/terminal.zig @@ -0,0 +1,184 @@ +//! Terminal abstraction for zcatui. +//! +//! The Terminal struct provides the main entry point for TUI applications. +//! It handles initialization, drawing, and cleanup of the terminal state. +//! +//! ## Example +//! +//! ```zig +//! var term = try Terminal.init(allocator); +//! defer term.deinit(); +//! +//! try term.draw(renderFn); +//! ``` + +const std = @import("std"); +const buffer = @import("buffer.zig"); +const Buffer = buffer.Buffer; +const Rect = buffer.Rect; +const backend_mod = @import("backend/backend.zig"); +const AnsiBackend = backend_mod.AnsiBackend; + +/// Terminal provides the main interface for TUI applications. +pub const Terminal = struct { + allocator: std.mem.Allocator, + backend: AnsiBackend, + current_buffer: Buffer, + previous_buffer: Buffer, + + /// Initializes the terminal for TUI mode. + /// + /// Enables raw mode, hides cursor, and clears screen. + pub fn init(allocator: std.mem.Allocator) !Terminal { + var backend = AnsiBackend.init(); + + // Get terminal size + const size = backend.getSize(); + const rect = Rect.init(0, 0, size.width, size.height); + + var current_buffer = try Buffer.init(allocator, rect); + var previous_buffer = try Buffer.init(allocator, rect); + + // Enter alternate screen, hide cursor, enable raw mode + try backend.enterAlternateScreen(); + try backend.hideCursor(); + try backend.enableRawMode(); + try backend.clear(); + + // Mark all cells as needing redraw + current_buffer.markDirty(); + previous_buffer.markClean(); + + return .{ + .allocator = allocator, + .backend = backend, + .current_buffer = current_buffer, + .previous_buffer = previous_buffer, + }; + } + + /// Cleans up terminal state. + /// + /// Shows cursor, exits alternate screen, and restores terminal mode. + pub fn deinit(self: *Terminal) void { + self.backend.disableRawMode() catch {}; + self.backend.showCursor() catch {}; + self.backend.leaveAlternateScreen() catch {}; + + self.current_buffer.deinit(); + self.previous_buffer.deinit(); + } + + /// Returns the current terminal area. + pub fn area(self: *const Terminal) Rect { + return self.current_buffer.area; + } + + /// Returns a pointer to the current buffer for rendering. + pub fn buffer(self: *Terminal) *Buffer { + return &self.current_buffer; + } + + /// Draws the UI by calling the provided render function. + /// + /// The render function receives the terminal area and buffer, + /// and should render all widgets to the buffer. + pub fn draw(self: *Terminal, comptime render_fn: fn (Rect, *Buffer) void) !void { + // Clear buffer + self.current_buffer.clear(); + + // Call user's render function + render_fn(self.area(), &self.current_buffer); + + // Flush changes to terminal + try self.flush(); + } + + /// Draws using a context-aware render function. + pub fn drawWithContext( + self: *Terminal, + context: anytype, + comptime render_fn: fn (@TypeOf(context), Rect, *Buffer) void, + ) !void { + self.current_buffer.clear(); + render_fn(context, self.area(), &self.current_buffer); + try self.flush(); + } + + /// Flushes buffer changes to the terminal. + /// + /// Compares current and previous buffers, only outputting differences. + fn flush(self: *Terminal) !void { + const rect = self.current_buffer.area; + + var y: u16 = rect.y; + while (y < rect.bottom()) : (y += 1) { + var x: u16 = rect.x; + while (x < rect.right()) : (x += 1) { + const current = self.current_buffer.get(x, y) orelse continue; + const previous = self.previous_buffer.get(x, y); + + // Only update if changed + const needs_update = if (previous) |prev| + current.char != prev.char or + !colorEqual(current.fg, prev.fg) or + !colorEqual(current.bg, prev.bg) or + !modifierEqual(current.modifiers, prev.modifiers) + else + true; + + if (needs_update) { + try self.backend.moveCursor(x, y); + try self.backend.setStyle(current.fg, current.bg, current.modifiers); + try self.backend.writeChar(current.char); + + // Update previous buffer + if (self.previous_buffer.getPtr(x, y)) |prev| { + prev.* = current; + } + } + } + } + + try self.backend.flush(); + } + + /// Resizes the terminal buffers. + pub fn resize(self: *Terminal, width: u16, height: u16) !void { + const new_rect = Rect.init(0, 0, width, height); + + self.current_buffer.deinit(); + self.previous_buffer.deinit(); + + self.current_buffer = try Buffer.init(self.allocator, new_rect); + self.previous_buffer = try Buffer.init(self.allocator, new_rect); + + self.current_buffer.markDirty(); + } + + /// Clears the terminal screen. + pub fn clear(self: *Terminal) !void { + try self.backend.clear(); + self.current_buffer.clear(); + self.previous_buffer.clear(); + self.current_buffer.markDirty(); + } +}; + +// Helper functions for comparison +fn colorEqual(a: @import("style.zig").Color, b: @import("style.zig").Color) bool { + return std.meta.eql(a, b); +} + +fn modifierEqual(a: @import("style.zig").Modifier, b: @import("style.zig").Modifier) bool { + return std.meta.eql(a, b); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "Terminal type exists" { + // Basic compilation test - actual terminal tests require real terminal + _ = Terminal; +} diff --git a/src/widgets/block.zig b/src/widgets/block.zig new file mode 100644 index 0000000..b3d408a --- /dev/null +++ b/src/widgets/block.zig @@ -0,0 +1,299 @@ +//! Block widget - a container with borders and title. +//! +//! Block is the most fundamental widget, used to create bordered containers +//! that can hold other widgets. +//! +//! ## Example +//! +//! ```zig +//! var block = Block.init() +//! .title("My Panel") +//! .borders(.all) +//! .style(Style{}.fg(Color.white).bg(Color.blue)); +//! +//! block.render(area, buf); +//! ``` + +const std = @import("std"); +const buffer = @import("../buffer.zig"); +const Buffer = buffer.Buffer; +const Rect = buffer.Rect; +const Margin = buffer.Margin; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; + +/// Border configuration. +pub const Borders = packed struct { + top: bool = false, + right: bool = false, + bottom: bool = false, + left: bool = false, + + pub const none: Borders = .{}; + pub const all: Borders = .{ .top = true, .right = true, .bottom = true, .left = true }; + pub const top_only: Borders = .{ .top = true }; + pub const bottom_only: Borders = .{ .bottom = true }; + pub const left_only: Borders = .{ .left = true }; + pub const right_only: Borders = .{ .right = true }; +}; + +/// Border character set. +pub const BorderSet = struct { + top_left: u21, + top_right: u21, + bottom_left: u21, + bottom_right: u21, + horizontal: u21, + vertical: u21, + + /// ASCII borders (+, -, |). + pub const ascii: BorderSet = .{ + .top_left = '+', + .top_right = '+', + .bottom_left = '+', + .bottom_right = '+', + .horizontal = '-', + .vertical = '|', + }; + + /// Single line Unicode borders (─, │, ┌, ┐, └, ┘). + pub const single: BorderSet = .{ + .top_left = 0x250C, // ┌ + .top_right = 0x2510, // ┐ + .bottom_left = 0x2514, // └ + .bottom_right = 0x2518, // ┘ + .horizontal = 0x2500, // ─ + .vertical = 0x2502, // │ + }; + + /// Double line Unicode borders (═, ║, ╔, ╗, ╚, ╝). + pub const double: BorderSet = .{ + .top_left = 0x2554, // ╔ + .top_right = 0x2557, // ╗ + .bottom_left = 0x255A, // ╚ + .bottom_right = 0x255D, // ╝ + .horizontal = 0x2550, // ═ + .vertical = 0x2551, // ║ + }; + + /// Rounded corners (─, │, ╭, ╮, ╰, ╯). + pub const rounded: BorderSet = .{ + .top_left = 0x256D, // ╭ + .top_right = 0x256E, // ╮ + .bottom_left = 0x2570, // ╰ + .bottom_right = 0x256F, // ╯ + .horizontal = 0x2500, // ─ + .vertical = 0x2502, // │ + }; + + /// Thick borders (━, ┃, ┏, ┓, ┗, ┛). + pub const thick: BorderSet = .{ + .top_left = 0x250F, // ┏ + .top_right = 0x2513, // ┓ + .bottom_left = 0x2517, // ┗ + .bottom_right = 0x251B, // ┛ + .horizontal = 0x2501, // ━ + .vertical = 0x2503, // ┃ + }; +}; + +/// Block widget - a bordered container with optional title. +pub const Block = struct { + title_text: ?[]const u8 = null, + borders: Borders = .none, + border_set: BorderSet = BorderSet.single, + border_style: Style = .{}, + title_style: Style = .{}, + block_style: Style = .{}, + padding: Margin = .{}, + + /// Creates a new Block with default settings. + pub fn init() Block { + return .{}; + } + + /// Sets the title. + pub fn title(self: Block, t: []const u8) Block { + var b = self; + b.title_text = t; + return b; + } + + /// Sets which borders to draw. + pub fn setBorders(self: Block, bord: Borders) Block { + var b = self; + b.borders = bord; + return b; + } + + /// Sets border style. + pub fn borderStyle(self: Block, s: Style) Block { + var b = self; + b.border_style = s; + return b; + } + + /// Sets title style. + pub fn titleStyle(self: Block, s: Style) Block { + var b = self; + b.title_style = s; + return b; + } + + /// Sets the overall block style (fills background). + pub fn style(self: Block, s: Style) Block { + var b = self; + b.block_style = s; + return b; + } + + /// Sets the border character set. + pub fn borderType(self: Block, set: BorderSet) Block { + var b = self; + b.border_set = set; + return b; + } + + /// Sets padding inside the block. + pub fn setPadding(self: Block, p: Margin) Block { + var b = self; + b.padding = p; + return b; + } + + /// Returns the inner area (excluding borders and padding). + pub fn inner(self: Block, area: Rect) Rect { + var margin = self.padding; + + if (self.borders.top) margin.top += 1; + if (self.borders.bottom) margin.bottom += 1; + if (self.borders.left) margin.left += 1; + if (self.borders.right) margin.right += 1; + + return area.inner(margin); + } + + /// Renders the block to the buffer. + pub fn render(self: Block, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + // Fill background + if (self.block_style.background != null) { + buf.fill(area, ' ', self.block_style); + } + + // Draw borders + self.renderBorders(area, buf); + + // Draw title + if (self.title_text) |t| { + if (self.borders.top and area.height > 0) { + const title_x = area.x + 2; + const max_len = if (area.width > 4) area.width - 4 else 0; + const title_len = @min(t.len, max_len); + if (title_len > 0) { + _ = buf.setString(title_x, area.y, t[0..title_len], self.title_style.patch(self.border_style)); + } + } + } + } + + /// Renders just the borders. + fn renderBorders(self: Block, area: Rect, buf: *Buffer) void { + const bs = self.border_set; + const s = self.border_style; + + // Top border + if (self.borders.top) { + var x = area.x; + while (x < area.right()) : (x += 1) { + buf.setChar(x, area.y, bs.horizontal, s); + } + } + + // Bottom border + if (self.borders.bottom) { + var x = area.x; + const y = area.bottom() - 1; + while (x < area.right()) : (x += 1) { + buf.setChar(x, y, bs.horizontal, s); + } + } + + // Left border + if (self.borders.left) { + var y = area.y; + while (y < area.bottom()) : (y += 1) { + buf.setChar(area.x, y, bs.vertical, s); + } + } + + // Right border + if (self.borders.right) { + var y = area.y; + const x = area.right() - 1; + while (y < area.bottom()) : (y += 1) { + buf.setChar(x, y, bs.vertical, s); + } + } + + // Corners + if (self.borders.top and self.borders.left) { + buf.setChar(area.x, area.y, bs.top_left, s); + } + if (self.borders.top and self.borders.right) { + buf.setChar(area.right() - 1, area.y, bs.top_right, s); + } + if (self.borders.bottom and self.borders.left) { + buf.setChar(area.x, area.bottom() - 1, bs.bottom_left, s); + } + if (self.borders.bottom and self.borders.right) { + buf.setChar(area.right() - 1, area.bottom() - 1, bs.bottom_right, s); + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Block inner area calculation" { + const block = Block.init().setBorders(Borders.all); + const area = Rect.init(0, 0, 10, 10); + const inner_area = block.inner(area); + + try std.testing.expectEqual(@as(u16, 1), inner_area.x); + try std.testing.expectEqual(@as(u16, 1), inner_area.y); + try std.testing.expectEqual(@as(u16, 8), inner_area.width); + try std.testing.expectEqual(@as(u16, 8), inner_area.height); +} + +test "Block with padding" { + const block = Block.init() + .setBorders(Borders.all) + .setPadding(Margin.uniform(1)); + const area = Rect.init(0, 0, 10, 10); + const inner_area = block.inner(area); + + try std.testing.expectEqual(@as(u16, 2), inner_area.x); + try std.testing.expectEqual(@as(u16, 2), inner_area.y); + try std.testing.expectEqual(@as(u16, 6), inner_area.width); + try std.testing.expectEqual(@as(u16, 6), inner_area.height); +} + +test "Block render compiles" { + const allocator = std.testing.allocator; + var buf = try Buffer.init(allocator, Rect.init(0, 0, 20, 10)); + defer buf.deinit(); + + const block = Block.init() + .title("Test") + .setBorders(Borders.all); + + block.render(Rect.init(0, 0, 20, 10), &buf); + + // Check corners + try std.testing.expectEqual(BorderSet.single.top_left, buf.get(0, 0).?.char); + try std.testing.expectEqual(BorderSet.single.top_right, buf.get(19, 0).?.char); +} diff --git a/src/widgets/paragraph.zig b/src/widgets/paragraph.zig new file mode 100644 index 0000000..93ee17d --- /dev/null +++ b/src/widgets/paragraph.zig @@ -0,0 +1,265 @@ +//! Paragraph widget - displays text with optional wrapping. +//! +//! ## Example +//! +//! ```zig +//! var para = Paragraph.init("Hello, world!") +//! .style(Style{}.fg(Color.white)) +//! .wrap(.word); +//! +//! para.render(area, buf); +//! ``` + +const std = @import("std"); +const buffer = @import("../buffer.zig"); +const Buffer = buffer.Buffer; +const Rect = buffer.Rect; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Block = @import("block.zig").Block; + +/// Text wrapping mode. +pub const Wrap = enum { + /// No wrapping, text is clipped. + none, + /// Wrap at word boundaries. + word, + /// Wrap at character boundaries. + char, +}; + +/// Text alignment. +pub const Alignment = enum { + left, + center, + right, +}; + +/// Paragraph widget for displaying text. +pub const Paragraph = struct { + text: []const u8, + text_style: Style = .{}, + block: ?Block = null, + wrap: Wrap = .none, + alignment: Alignment = .left, + scroll: struct { x: u16, y: u16 } = .{ .x = 0, .y = 0 }, + + /// Creates a new Paragraph with the given text. + pub fn init(text: []const u8) Paragraph { + return .{ .text = text }; + } + + /// Sets the text style. + pub fn style(self: Paragraph, s: Style) Paragraph { + var p = self; + p.text_style = s; + return p; + } + + /// Wraps the paragraph in a block. + pub fn setBlock(self: Paragraph, b: Block) Paragraph { + var p = self; + p.block = b; + return p; + } + + /// Sets the wrap mode. + pub fn setWrap(self: Paragraph, w: Wrap) Paragraph { + var p = self; + p.wrap = w; + return p; + } + + /// Sets the text alignment. + pub fn setAlignment(self: Paragraph, a: Alignment) Paragraph { + var p = self; + p.alignment = a; + return p; + } + + /// Sets the scroll offset. + pub fn setScroll(self: Paragraph, x: u16, y: u16) Paragraph { + var p = self; + p.scroll = .{ .x = x, .y = y }; + return p; + } + + /// Renders the paragraph to the buffer. + pub fn render(self: Paragraph, area: Rect, buf: *Buffer) void { + // Render block if present + const text_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (text_area.isEmpty()) return; + + // Render text based on wrap mode + switch (self.wrap) { + .none => self.renderNoWrap(text_area, buf), + .word => self.renderWordWrap(text_area, buf), + .char => self.renderCharWrap(text_area, buf), + } + } + + /// Renders text without wrapping (clips to area). + fn renderNoWrap(self: Paragraph, area: Rect, buf: *Buffer) void { + var lines = std.mem.splitScalar(u8, self.text, '\n'); + var y: u16 = 0; + + while (lines.next()) |line| { + if (y >= self.scroll.y) { + const display_y = area.y + (y - self.scroll.y); + if (display_y >= area.bottom()) break; + + const start = @min(self.scroll.x, @as(u16, @intCast(line.len))); + const end = @min(self.scroll.x + area.width, @as(u16, @intCast(line.len))); + + if (start < end) { + const display_text = line[start..end]; + const x = self.alignedX(area, @intCast(display_text.len)); + _ = buf.setString(x, display_y, display_text, self.text_style); + } + } + y += 1; + } + } + + /// Renders text with word wrapping. + fn renderWordWrap(self: Paragraph, area: Rect, buf: *Buffer) void { + var display_y: u16 = area.y; + var lines = std.mem.splitScalar(u8, self.text, '\n'); + var line_num: u16 = 0; + + while (lines.next()) |line| { + if (line.len == 0) { + if (line_num >= self.scroll.y) { + display_y += 1; + if (display_y >= area.bottom()) break; + } + line_num += 1; + continue; + } + + // Word wrap this line + var words = std.mem.splitScalar(u8, line, ' '); + var current_x: u16 = 0; + var first_word = true; + + while (words.next()) |word| { + if (word.len == 0) continue; + + const word_len: u16 = @intCast(word.len); + const space_needed: u16 = if (first_word) word_len else word_len + 1; + + if (current_x + space_needed > area.width and current_x > 0) { + // Wrap to next line + line_num += 1; + if (line_num >= self.scroll.y) { + display_y += 1; + if (display_y >= area.bottom()) return; + } + current_x = 0; + first_word = true; + } + + if (line_num >= self.scroll.y) { + if (!first_word and current_x < area.width) { + current_x += 1; // Space before word + } + const write_x = area.x + current_x; + const max_chars = @min(word_len, area.width - current_x); + if (max_chars > 0) { + _ = buf.setString(write_x, display_y, word[0..max_chars], self.text_style); + } + current_x += word_len; + } + + first_word = false; + } + + line_num += 1; + if (line_num >= self.scroll.y) { + display_y += 1; + if (display_y >= area.bottom()) break; + } + } + } + + /// Renders text with character wrapping. + fn renderCharWrap(self: Paragraph, area: Rect, buf: *Buffer) void { + var x: u16 = 0; + var y: u16 = 0; + var line_num: u16 = 0; + + var iter = std.unicode.Utf8Iterator{ .bytes = self.text, .i = 0 }; + while (iter.nextCodepoint()) |cp| { + if (cp == '\n') { + x = 0; + line_num += 1; + if (line_num >= self.scroll.y) { + y += 1; + if (y >= area.height) break; + } + continue; + } + + if (x >= area.width) { + x = 0; + line_num += 1; + if (line_num >= self.scroll.y) { + y += 1; + if (y >= area.height) break; + } + } + + if (line_num >= self.scroll.y) { + buf.setChar(area.x + x, area.y + y, cp, self.text_style); + } + x += 1; + } + } + + /// Calculates aligned X position. + fn alignedX(self: Paragraph, area: Rect, text_len: u16) u16 { + return switch (self.alignment) { + .left => area.x, + .center => area.x + (area.width -| text_len) / 2, + .right => area.x + (area.width -| text_len), + }; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Paragraph renders text" { + const allocator = std.testing.allocator; + var buf = try Buffer.init(allocator, Rect.init(0, 0, 20, 5)); + defer buf.deinit(); + + const para = Paragraph.init("Hello"); + para.render(Rect.init(0, 0, 20, 5), &buf); + + try std.testing.expectEqual(@as(u21, 'H'), buf.get(0, 0).?.char); + try std.testing.expectEqual(@as(u21, 'e'), buf.get(1, 0).?.char); + try std.testing.expectEqual(@as(u21, 'l'), buf.get(2, 0).?.char); + try std.testing.expectEqual(@as(u21, 'l'), buf.get(3, 0).?.char); + try std.testing.expectEqual(@as(u21, 'o'), buf.get(4, 0).?.char); +} + +test "Paragraph with block" { + const allocator = std.testing.allocator; + var buf = try Buffer.init(allocator, Rect.init(0, 0, 20, 5)); + defer buf.deinit(); + + const para = Paragraph.init("Hi") + .setBlock(Block.init().setBorders(.{ .top = true, .bottom = true, .left = true, .right = true })); + + para.render(Rect.init(0, 0, 20, 5), &buf); + + // Text should be inside the block + try std.testing.expectEqual(@as(u21, 'H'), buf.get(1, 1).?.char); + try std.testing.expectEqual(@as(u21, 'i'), buf.get(2, 1).?.char); +}