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:
commit
2a62c0f60b
12 changed files with 2489 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Zig build artifacts
|
||||||
|
.zig-cache/
|
||||||
|
zig-out/
|
||||||
|
|
||||||
|
# Editor files
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
462
CLAUDE.md
Normal file
462
CLAUDE.md
Normal 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
45
build.zig
Normal 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
93
examples/hello.zig
Normal 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
222
src/backend/backend.zig
Normal 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
337
src/buffer.zig
Normal 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)
|
||||||
|
//! ┌────┬────┬────┬────┐
|
||||||
|
//! │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);
|
||||||
|
}
|
||||||
253
src/layout.zig
Normal file
253
src/layout.zig
Normal 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
73
src/root.zig
Normal 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
247
src/style.zig
Normal 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
184
src/terminal.zig
Normal 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
299
src/widgets/block.zig
Normal 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
265
src/widgets/paragraph.zig
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue