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 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 01:56:44 +01:00
commit 2a62c0f60b
12 changed files with 2489 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
# Zig build artifacts
.zig-cache/
zig-out/
# Editor files
*.swp
*~
.vscode/
.idea/

462
CLAUDE.md Normal file
View file

@ -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

45
build.zig Normal file
View file

@ -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);
}

93
examples/hello.zig Normal file
View file

@ -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);
}

222
src/backend/backend.zig Normal file
View file

@ -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);
}

337
src/buffer.zig Normal file
View file

@ -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)
//!
//! CellCellCellCell row 0
//!
//! CellCellCellCell 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);
}

253
src/layout.zig Normal file
View file

@ -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);
}

73
src/root.zig Normal file
View file

@ -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;
}

247
src/style.zig Normal file
View file

@ -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);
}

184
src/terminal.zig Normal file
View file

@ -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;
}

299
src/widgets/block.zig Normal file
View file

@ -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);
}

265
src/widgets/paragraph.zig Normal file
View file

@ -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);
}