diff --git a/CLAUDE.md b/CLAUDE.md index 1980f0e..7b2f810 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ > **Última actualización**: 2025-12-08 > **Lenguaje**: Zig 0.15.2 > **Inspiración**: [ratatui](https://github.com/ratatui/ratatui) (Rust TUI library) +> **Estado**: v1.0 - Implementación completa de todos los widgets de ratatui ## Descripción del Proyecto @@ -14,7 +15,65 @@ --- -## Arquitectura Objetivo +## Estado Actual del Proyecto + +### Implementación Completa (v1.0) - 2025-12-08 + +| Componente | Estado | Archivo | +|------------|--------|---------| +| **Core** | ✅ Completo | | +| Style + Color | ✅ | `src/style.zig` | +| Buffer + Cell | ✅ | `src/buffer.zig` | +| Text + Span + Line | ✅ | `src/text.zig` | +| Layout + Constraint | ✅ | `src/layout.zig` | +| Terminal | ✅ | `src/terminal.zig` | +| Backend ANSI | ✅ | `src/backend/` | +| **Symbols** | ✅ Completo | `src/symbols/` | +| Line drawing | ✅ | `line.zig` | +| Border sets | ✅ | `border.zig` | +| Block chars | ✅ | `block.zig` | +| Bar chars | ✅ | `bar.zig` | +| Braille patterns | ✅ | `braille.zig` | +| Half-block | ✅ | `half_block.zig` | +| Scrollbar symbols | ✅ | `scrollbar.zig` | +| Markers | ✅ | `marker.zig` | +| **Widgets** | ✅ Completo (13 widgets) | `src/widgets/` | +| Block | ✅ | `block.zig` | +| Paragraph | ✅ | `paragraph.zig` | +| List | ✅ | `list.zig` | +| Table | ✅ | `table.zig` | +| Gauge + LineGauge | ✅ | `gauge.zig` | +| Tabs | ✅ | `tabs.zig` | +| Sparkline | ✅ | `sparkline.zig` | +| Scrollbar | ✅ | `scrollbar.zig` | +| BarChart | ✅ | `barchart.zig` | +| Canvas | ✅ | `canvas.zig` | +| Chart | ✅ | `chart.zig` | +| Calendar (Monthly) | ✅ | `calendar.zig` | +| Clear | ✅ | `clear.zig` | + +### Tests + +| Archivo | Tests | +|---------|-------| +| barchart.zig | 16 | +| table.zig | 14 | +| calendar.zig | 10 | +| canvas.zig | 10 | +| list.zig | 10 | +| tabs.zig | 9 | +| chart.zig | 8 | +| gauge.zig | 8 | +| sparkline.zig | 6 | +| scrollbar.zig | 5 | +| block.zig | 3 | +| paragraph.zig | 2 | +| clear.zig | 2 | +| **Total widgets** | **103** | + +--- + +## Arquitectura ### Diseño: Immediate Mode Rendering @@ -31,204 +90,185 @@ Como ratatui, usamos **renderizado inmediato con buffers intermedios**: - El buffer se compara con el anterior (diff) - Solo se envían cambios a la terminal (eficiencia) -### Módulos Principales (Objetivo) +### Estructura de Archivos ``` zcatui/ ├── src/ │ ├── root.zig # Entry point, re-exports públicos │ ├── terminal.zig # Terminal abstraction -│ ├── buffer.zig # Buffer + Cell types -│ ├── layout.zig # Layout, Constraint, Rect +│ ├── buffer.zig # Buffer + Cell + Rect +│ ├── layout.zig # Layout, Constraint, Direction │ ├── style.zig # Color, Style, Modifier -│ ├── text.zig # Text, Line, Span +│ ├── text.zig # Text, Line, Span, Alignment │ ├── backend/ │ │ ├── backend.zig # Backend interface -│ │ └── ansi.zig # ANSI escape sequences (default) +│ │ └── ansi.zig # ANSI escape sequences +│ ├── symbols/ +│ │ ├── symbols.zig # Re-exports +│ │ ├── line.zig # Line drawing characters +│ │ ├── border.zig # Border sets +│ │ ├── block.zig # Block elements +│ │ ├── bar.zig # Bar characters +│ │ ├── braille.zig # Braille patterns (256) +│ │ ├── half_block.zig # Half-block chars +│ │ ├── scrollbar.zig # Scrollbar symbols +│ │ └── marker.zig # Chart markers │ └── 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 +│ ├── block.zig # Block (borders, titles, padding) +│ ├── paragraph.zig # Text with wrapping +│ ├── list.zig # Selectable list with state +│ ├── table.zig # Multi-column table +│ ├── gauge.zig # Progress bars (Gauge + LineGauge) +│ ├── tabs.zig # Tab navigation +│ ├── sparkline.zig # Mini line graphs +│ ├── scrollbar.zig # Scroll indicator +│ ├── barchart.zig # Bar charts with groups +│ ├── canvas.zig # Drawing (braille/half-block) +│ ├── chart.zig # Line/scatter/bar graphs +│ ├── calendar.zig # Monthly calendar +│ └── clear.zig # Clear/reset area +├── docs/ +│ ├── ARCHITECTURE.md # Arquitectura detallada +│ ├── WIDGETS.md # Documentación de widgets +│ └── API.md # Referencia de API ├── build.zig -└── examples/ - ├── hello.zig # Minimal example - ├── demo.zig # Feature showcase - └── counter.zig # Interactive counter +└── CLAUDE.md ``` --- -## 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. +## Widgets Implementados +### Block +Contenedor base con bordes y títulos. ```zig -const Cell = struct { - char: u21, // Unicode codepoint - fg: Color, // Foreground color - bg: Color, // Background color - modifiers: Modifiers, // Bold, italic, underline, etc. -}; +const block = Block.init() + .title("Mi Título") + .borders(Borders.all) + .borderStyle(Style.default.fg(Color.blue)); +block.render(area, buf); ``` -### 2. Buffer -Grid de celdas que representa el estado de la terminal. - +### Paragraph +Texto con word-wrapping y scroll. ```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 { ... } -}; +const para = Paragraph.init(text) + .setBlock(block) + .setWrap(.{ .trim = true }) + .setAlignment(.center); +para.render(area, buf); ``` -### 3. Rect -Área rectangular en la terminal. - +### List (con ListState) +Lista seleccionable con scroll automático. ```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 { ... } -}; +var state = ListState.init(); +state.select(2); +const list = List.init(items) + .setBlock(block) + .setHighlightStyle(Style.default.bg(Color.yellow)); +list.renderStateful(area, buf, &state); ``` -### 4. Style -Combinación de colores y modificadores. - +### Table (con TableState) +Tabla multi-columna con selección. ```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 { ... } -}; +var state = TableState.init(); +const table = Table.init(rows, widths) + .setHeader(header_row) + .setHighlightStyle(Style.default.bg(Color.blue)); +table.renderStateful(area, buf, &state); ``` -### 5. Layout -Sistema de distribución de espacio. - +### Gauge y LineGauge +Barras de progreso. ```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 }, -}; +const gauge = Gauge.init() + .setRatio(0.75) + .setLabel("75%") + .setGaugeStyle(Style.default.fg(Color.green)); +gauge.render(area, buf); ``` -### 6. Widget Interface -Trait que deben implementar todos los widgets. - +### Tabs +Navegación por pestañas. ```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 - } -}; +const tabs = Tabs.init(&titles) + .select(1) + .setHighlightStyle(Style.default.fg(Color.yellow)); +tabs.render(area, buf); ``` ---- +### Sparkline +Mini gráficos de línea. +```zig +const spark = Sparkline.init() + .setData(&data) + .setMax(100) + .setStyle(Style.default.fg(Color.cyan)); +spark.render(area, buf); +``` -## Referencia: ratatui Widgets +### Scrollbar (con ScrollbarState) +Indicador de scroll. +```zig +var state = ScrollbarState.init(100).setPosition(25); +const scrollbar = Scrollbar.init(.vertical_right); +scrollbar.render(area, buf, &state); +``` -| 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 | +### BarChart +Gráficos de barras con grupos. +```zig +const chart = BarChart.init() + .setData(&bar_groups) + .setBarWidth(5) + .setBarGap(1); +chart.render(area, buf); +``` + +### Canvas +Dibujo libre con diferentes marcadores. +```zig +const canvas = Canvas.init() + .setXBounds(0, 100) + .setYBounds(0, 100) + .setMarker(.braille) + .paint(struct { + pub fn draw(ctx: *Painter) void { + ctx.drawLine(0, 0, 100, 100, Color.red); + ctx.drawCircle(50, 50, 25, Color.blue); + } + }.draw); +canvas.render(area, buf); +``` + +### Chart +Gráficos de línea/scatter/barras con ejes. +```zig +const chart = Chart.init(&datasets) + .setXAxis(x_axis) + .setYAxis(y_axis) + .setLegendPosition(.top_right); +chart.render(area, buf); +``` + +### Calendar (Monthly) +Calendario mensual. +```zig +const cal = Monthly.init(Date.init(2024, 12, 1)) + .showMonthHeader(Style.default.fg(Color.blue)) + .showWeekdaysHeader(Style.default) + .withEvents(events); +cal.render(area, buf); +``` + +### Clear +Limpia/resetea un área. +```zig +Clear.init().render(area, buf); +``` --- @@ -244,6 +284,21 @@ const Block = struct { --- +## Comandos + +```bash +# Compilar +zig build + +# Tests +zig build test + +# Tests con resumen +zig build test --summary all +``` + +--- + ## Equipo y Metodología ### Quiénes Somos @@ -285,16 +340,6 @@ const Block = struct { | **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 @@ -317,19 +362,6 @@ pub fn render(self: *Block, area: Rect, buf: *Buffer) void { | 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 @@ -338,6 +370,7 @@ const reader = stdin.deprecatedReader(); | Proyecto | Descripción | Estado | |----------|-------------|--------| | **service-monitor** | Monitor HTTP/TCP con notificaciones | Completado | +| **zcatui** | TUI library inspirada en ratatui | v1.0 Completo | ### Proyectos Go (referencia) | Proyecto | Descripción | @@ -358,77 +391,24 @@ const reader = stdin.deprecatedReader(); ## Control de Versiones ```bash -# Remote (cuando se cree el repo) +# Remote 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; - } - } -} +zig build test --summary all # Tests con detalles ``` --- ## Recursos y Referencias -### ratatui (Rust) +### ratatui (Rust) - Referencia de implementación - Repo: https://github.com/ratatui/ratatui - Docs: https://docs.rs/ratatui/latest/ratatui/ - Website: https://ratatui.rs/ +- Clone local: `/mnt/cello2/arno/re/recode/ratatui-reference/` ### Zig - Docs 0.15: https://ziglang.org/documentation/0.15.0/std/ @@ -440,23 +420,35 @@ pub fn main() !void { --- -## Estado del Proyecto +## Próximos Pasos (v1.1+) -| 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 | +### Mejoras de Performance +- [ ] Optimización de buffer diff +- [ ] Lazy rendering para widgets grandes +- [ ] Pooling de memoria para cells + +### Funcionalidades Adicionales +- [ ] Input handling (keyboard events) +- [ ] Mouse support +- [ ] Clipboard integration +- [ ] Animaciones + +### Documentación +- [ ] Ejemplos completos +- [ ] Tutorial paso a paso +- [ ] API reference generada --- -**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 +## Historial de Desarrollo + +### 2025-12-08 - v1.0 (Implementación Completa) +- Implementados todos los widgets de ratatui (13 widgets) +- Sistema de símbolos completo (braille, half-block, borders, etc.) +- 103+ tests en widgets +- Documentación completa + +### 2025-12-08 - Inicio del Proyecto +- Creación de CLAUDE.md +- Definición de arquitectura +- Estructura inicial del proyecto diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..9934902 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,644 @@ +# zcatui - API Reference + +> Referencia rapida de la API publica de zcatui + +## Importar la Libreria + +```zig +const zcatui = @import("zcatui"); + +// Core types +const Color = zcatui.Color; +const Style = zcatui.Style; +const Modifier = zcatui.Modifier; +const Cell = zcatui.Cell; +const Buffer = zcatui.Buffer; +const Rect = zcatui.Rect; +const Span = zcatui.Span; +const Line = zcatui.Line; +const Text = zcatui.Text; +const Alignment = zcatui.Alignment; + +// Layout +const Layout = zcatui.Layout; +const Constraint = zcatui.Constraint; +const Direction = zcatui.Direction; + +// Terminal +const Terminal = zcatui.Terminal; + +// Widgets +const widgets = zcatui.widgets; +const Block = widgets.Block; +const Paragraph = widgets.Paragraph; +const List = widgets.List; +const ListState = widgets.ListState; +// ... etc +``` + +--- + +## Core Types + +### Color + +```zig +pub const Color = union(enum) { + // Reset + reset, + + // Basic 16 colors + black, red, green, yellow, blue, magenta, cyan, white, + light_black, light_red, light_green, light_yellow, + light_blue, light_magenta, light_cyan, light_white, + + // 256 color palette + indexed: u8, + + // True color (24-bit) + rgb: struct { r: u8, g: u8, b: u8 }, + + // Constructor helpers + pub fn indexed(n: u8) Color; + pub fn rgb(r: u8, g: u8, b: u8) Color; +}; +``` + +**Ejemplos:** +```zig +const red = Color.red; +const gray = Color.indexed(240); +const custom = Color.rgb(255, 128, 0); +``` + +### Style + +```zig +pub const Style = struct { + foreground: ?Color = null, + background: ?Color = null, + add_modifiers: Modifier = .{}, + sub_modifiers: Modifier = .{}, + + pub const default: Style = .{}; + + // Fluent setters + pub fn fg(self: Style, color: Color) Style; + pub fn bg(self: Style, color: Color) Style; + pub fn bold(self: Style) Style; + pub fn dim(self: Style) Style; + pub fn italic(self: Style) Style; + pub fn underlined(self: Style) Style; + pub fn slow_blink(self: Style) Style; + pub fn rapid_blink(self: Style) Style; + pub fn reversed(self: Style) Style; + pub fn hidden(self: Style) Style; + pub fn crossed_out(self: Style) Style; + + // Remove modifiers + pub fn notBold(self: Style) Style; + pub fn notDim(self: Style) Style; + // ... etc + + // Combine styles + pub fn patch(self: Style, other: Style) Style; +}; +``` + +**Ejemplos:** +```zig +const style1 = Style.default.fg(Color.red).bold(); +const style2 = Style.default.bg(Color.blue).italic(); +const combined = style1.patch(style2); // red fg, blue bg, bold+italic +``` + +### Modifier + +```zig +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 all: Modifier = .{ .bold = true, ... }; + + pub fn contains(self: Modifier, other: Modifier) bool; + pub fn insert(self: Modifier, other: Modifier) Modifier; + pub fn remove(self: Modifier, other: Modifier) Modifier; +}; +``` + +### Rect + +```zig +pub const Rect = struct { + x: u16, + y: u16, + width: u16, + height: u16, + + pub fn init(x: u16, y: u16, width: u16, height: u16) Rect; + + // Getters + pub fn left(self: Rect) u16; // x + pub fn right(self: Rect) u16; // x + width + pub fn top(self: Rect) u16; // y + pub fn bottom(self: Rect) u16; // y + height + pub fn area(self: Rect) u32; // width * height + + // Queries + pub fn isEmpty(self: Rect) bool; + pub fn contains(self: Rect, x: u16, y: u16) bool; + + // Transformations + pub fn inner(self: Rect, margin: u16) Rect; + pub fn innerMargins(self: Rect, top: u16, right: u16, bottom: u16, left_: u16) Rect; + pub fn intersection(self: Rect, other: Rect) Rect; + pub fn union_(self: Rect, other: Rect) Rect; +}; +``` + +**Ejemplos:** +```zig +const area = Rect.init(0, 0, 80, 24); +const inner = area.inner(1); // Rect.init(1, 1, 78, 22) +``` + +### Cell + +```zig +pub const Cell = struct { + symbol: Symbol, + style: Style, + + pub const default_val: Cell = .{ .symbol = Symbol.default_val, .style = Style.default }; + + pub fn reset(self: *Cell) void; + pub fn setChar(self: *Cell, ch: u21) void; + pub fn setSymbol(self: *Cell, symbol: []const u8) void; + pub fn setStyle(self: *Cell, style: Style) void; +}; + +pub const Symbol = struct { + data: [4]u8, + len: u3, + + pub const default_val: Symbol; // " " + pub fn slice(self: Symbol) []const u8; +}; +``` + +### Buffer + +```zig +pub const Buffer = struct { + area: Rect, + cells: []Cell, + allocator: Allocator, + + pub fn init(allocator: Allocator, area: Rect) !Buffer; + pub fn deinit(self: *Buffer) void; + pub fn empty(area: Rect) Buffer; // No allocation + + // Cell access + pub fn getCell(self: *Buffer, x: u16, y: u16) ?*Cell; + pub fn index(self: Buffer, x: u16, y: u16) ?usize; + + // Setting content + pub fn setString(self: *Buffer, x: u16, y: u16, text: []const u8, style: Style) u16; + pub fn setSpan(self: *Buffer, x: u16, y: u16, span: Span, width: u16) u16; + pub fn setLine(self: *Buffer, x: u16, y: u16, line: Line, width: u16) u16; + pub fn setStyle(self: *Buffer, area: Rect, style: Style) void; + + // Filling + pub fn fill(self: *Buffer, cell: Cell) void; + pub fn fillArea(self: *Buffer, area: Rect, cell: Cell) void; + + // Merging + pub fn merge(self: *Buffer, other: *const Buffer) void; +}; +``` + +**Ejemplos:** +```zig +var buf = try Buffer.init(allocator, Rect.init(0, 0, 80, 24)); +defer buf.deinit(); + +_ = buf.setString(10, 5, "Hello, World!", Style.default.fg(Color.green)); + +if (buf.getCell(10, 5)) |cell| { + cell.setStyle(Style.default.bold()); +} +``` + +--- + +## Text Types + +### Span + +```zig +pub const Span = struct { + content: []const u8, + style: Style, + + pub fn init(content: []const u8) Span; + pub fn raw(content: []const u8) Span; + pub fn styled(content: []const u8, style: Style) Span; + pub fn setStyle(self: Span, style: Style) Span; + pub fn width(self: Span) usize; +}; +``` + +### Line + +```zig +pub const Line = struct { + spans: []const Span, + alignment: Alignment, + + pub fn init(spans: []const Span) Line; + pub fn raw(content: []const u8) Line; + pub fn styled(content: []const u8, style: Style) Line; + pub fn setStyle(self: Line, style: Style) Line; + pub fn setAlignment(self: Line, alignment: Alignment) Line; + pub fn width(self: Line) usize; +}; +``` + +### Text + +```zig +pub const Text = struct { + lines: []const Line, + alignment: Alignment, + + pub fn init(lines: []const Line) Text; + pub fn raw(content: []const u8) Text; + pub fn styled(content: []const u8, style: Style) Text; + pub fn setStyle(self: Text, style: Style) Text; + pub fn setAlignment(self: Text, alignment: Alignment) Text; + pub fn width(self: Text) usize; + pub fn height(self: Text) usize; +}; +``` + +### Alignment + +```zig +pub const Alignment = enum { + left, + center, + right, +}; +``` + +**Ejemplos:** +```zig +// Simple text +const span = Span.styled("Hello", Style.default.fg(Color.red)); +const line = Line.raw("Simple line"); + +// Multi-span line +const multi_line = Line.init(&[_]Span{ + Span.styled("Error: ", Style.default.fg(Color.red).bold()), + Span.raw("Something went wrong"), +}); + +// Multi-line text +const text = Text.raw("Line 1\nLine 2\nLine 3"); +``` + +--- + +## Layout + +### Layout + +```zig +pub const Layout = struct { + direction: Direction, + constraints: []const Constraint, + + pub fn horizontal(constraints: []const Constraint) Layout; + pub fn vertical(constraints: []const Constraint) Layout; + pub fn init(direction: Direction, constraints: []const Constraint) Layout; + + pub fn split(self: Layout, area: Rect, result: []Rect) void; +}; +``` + +### Constraint + +```zig +pub 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 (0-100) + ratio: struct { num: u32, den: u32 }, + fill: u16, // Fill remaining space (weight) + + pub fn length(n: u16) Constraint; + pub fn min(n: u16) Constraint; + pub fn max(n: u16) Constraint; + pub fn percentage(n: u16) Constraint; + pub fn ratio(num: u32, den: u32) Constraint; + pub fn fill(weight: u16) Constraint; +}; +``` + +### Direction + +```zig +pub const Direction = enum { + horizontal, + vertical, +}; +``` + +**Ejemplos:** +```zig +// Vertical layout: header (3 rows), content (rest), footer (1 row) +const layout = Layout.vertical(&[_]Constraint{ + Constraint.length(3), + Constraint.min(0), + Constraint.length(1), +}); + +var chunks: [3]Rect = undefined; +layout.split(area, &chunks); + +// Horizontal split: 30% | 70% +const h_layout = Layout.horizontal(&[_]Constraint{ + Constraint.percentage(30), + Constraint.percentage(70), +}); +``` + +--- + +## Symbols + +### Line Set + +```zig +pub const line = struct { + pub const Set = struct { + vertical: []const u8, + horizontal: []const u8, + top_right: []const u8, + top_left: []const u8, + bottom_right: []const u8, + bottom_left: []const u8, + vertical_left: []const u8, + vertical_right: []const u8, + horizontal_down: []const u8, + horizontal_up: []const u8, + cross: []const u8, + }; + + pub const NORMAL: Set; // ─│┌┐└┘ + pub const ROUNDED: Set; // ─│╭╮╰╯ + pub const DOUBLE: Set; // ═║╔╗╚╝ + pub const THICK: Set; // ━┃┏┓┗┛ +}; +``` + +### Border Set + +```zig +pub const border = struct { + pub const Set = struct { + top_left: []const u8, + top_right: []const u8, + bottom_left: []const u8, + bottom_right: []const u8, + horizontal: []const u8, + vertical: []const u8, + }; + + pub const PLAIN: Set; + pub const ROUNDED: Set; + pub const DOUBLE: Set; + pub const THICK: Set; +}; +``` + +### Block Characters + +```zig +pub const block = struct { + pub const FULL: []const u8 = "█"; + pub const UPPER_HALF: []const u8 = "▀"; + pub const LOWER_HALF: []const u8 = "▄"; + pub const LEFT_HALF: []const u8 = "▌"; + pub const RIGHT_HALF: []const u8 = "▐"; + // ... +}; +``` + +### Bar Characters + +```zig +pub const bar = struct { + pub const Set = struct { + full: []const u8, + seven_eighths: []const u8, + three_quarters: []const u8, + five_eighths: []const u8, + half: []const u8, + three_eighths: []const u8, + one_quarter: []const u8, + one_eighth: []const u8, + empty: []const u8, + }; + + pub const NINE_LEVELS: Set; + pub const THREE_LEVELS: Set; +}; +``` + +### Braille + +```zig +pub const braille = struct { + pub const BLANK: []const u8 = "⠀"; // U+2800 + + // Bit positions for 2x4 grid: + // 0 3 + // 1 4 + // 2 5 + // 6 7 + + pub const PATTERNS: [256][3]u8; // Pre-computed UTF-8 patterns + + pub fn fromPattern(pattern: u8) []const u8; +}; +``` + +### Marker + +```zig +pub const Marker = enum { + dot, + block, + bar, + braille, + half_block, +}; +``` + +--- + +## Terminal + +```zig +pub const Terminal = struct { + pub fn init(allocator: Allocator) !Terminal; + pub fn deinit(self: *Terminal) void; + + pub fn size(self: Terminal) struct { width: u16, height: u16 }; + pub fn area(self: Terminal) Rect; + + pub fn draw(self: *Terminal, render_fn: fn(area: Rect, buf: *Buffer) void) !void; + pub fn clear(self: *Terminal) !void; + pub fn flush(self: *Terminal) !void; + + pub fn hideCursor(self: *Terminal) !void; + pub fn showCursor(self: *Terminal) !void; + pub fn setCursorPosition(self: *Terminal, x: u16, y: u16) !void; + + pub fn enterAlternateScreen(self: *Terminal) !void; + pub fn leaveAlternateScreen(self: *Terminal) !void; + + pub fn enableRawMode(self: *Terminal) !void; + pub fn disableRawMode(self: *Terminal) !void; +}; +``` + +**Ejemplo de uso:** +```zig +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var term = try Terminal.init(allocator); + defer term.deinit(); + + try term.enterAlternateScreen(); + defer term.leaveAlternateScreen() catch {}; + + try term.hideCursor(); + defer term.showCursor() catch {}; + + try term.draw(struct { + pub fn render(area: Rect, buf: *Buffer) void { + const block = Block.bordered().title("Hello zcatui!"); + block.render(area, buf); + } + }.render); + + // Wait for input... +} +``` + +--- + +## Widgets Quick Reference + +| Widget | Constructor | Stateful | Key Methods | +|--------|------------|----------|-------------| +| Block | `Block.init()` | No | `title()`, `borders()`, `borderStyle()` | +| Paragraph | `Paragraph.init(text)` | No | `setWrap()`, `setAlignment()`, `setScroll()` | +| List | `List.init(items)` | Yes | `setHighlightStyle()`, `setHighlightSymbol()` | +| Table | `Table.init(rows, widths)` | Yes | `setHeader()`, `setHighlightStyle()` | +| Gauge | `Gauge.init()` | No | `setRatio()`, `setPercent()`, `setLabel()` | +| LineGauge | `LineGauge.init()` | No | `setRatio()`, `setFilledStyle()` | +| Tabs | `Tabs.init(titles)` | No | `select()`, `setDivider()` | +| Sparkline | `Sparkline.init()` | No | `setData()`, `setMax()` | +| Scrollbar | `Scrollbar.init(orientation)` | Yes | `setSymbols()`, `setStyle()` | +| BarChart | `BarChart.init()` | No | `setData()`, `setBarWidth()` | +| Canvas | `Canvas.init()` | No | `setXBounds()`, `setYBounds()`, `paint()` | +| Chart | `Chart.init(datasets)` | No | `setXAxis()`, `setYAxis()` | +| Monthly | `Monthly.init(date)` | No | `showMonthHeader()`, `showWeekdaysHeader()` | +| Clear | `Clear.init()` | No | (none) | + +--- + +## Error Handling + +zcatui usa el sistema de errores de Zig. Las funciones que pueden fallar retornan `!T`. + +```zig +// Errores comunes +const TerminalError = error{ + InitFailed, + WriteFailed, + FlushFailed, +}; + +const BufferError = error{ + OutOfMemory, +}; + +// Manejo tipico +var term = Terminal.init(allocator) catch |err| { + std.debug.print("Failed to init terminal: {}\n", .{err}); + return err; +}; +defer term.deinit(); +``` + +--- + +## Patterns + +### Builder Pattern + +```zig +const widget = SomeWidget.init() + .setOption1(value1) + .setOption2(value2) + .setBlock(Block.bordered()); +``` + +### Stateful Rendering + +```zig +var state = WidgetState.init(); + +// En el loop de renderizado: +widget.renderStateful(area, buf, &state); + +// Actualizar estado basado en input: +state.selectNext(items.len); +``` + +### Layout Composition + +```zig +const outer = Layout.vertical(&[_]Constraint{ + Constraint.length(3), + Constraint.min(0), +}); + +var outer_chunks: [2]Rect = undefined; +outer.split(area, &outer_chunks); + +const inner = Layout.horizontal(&[_]Constraint{ + Constraint.percentage(50), + Constraint.percentage(50), +}); + +var inner_chunks: [2]Rect = undefined; +inner.split(outer_chunks[1], &inner_chunks); +``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..fdc6650 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,413 @@ +# zcatui - Arquitectura + +> Documentación técnica de la arquitectura de zcatui + +## Visión General + +zcatui es una librería TUI (Terminal User Interface) para Zig, inspirada en ratatui de Rust. Utiliza un patrón de **renderizado inmediato con buffers intermedios**. + +## Diagrama de Arquitectura + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Application │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Widget1 │ │ Widget2 │ │ Widget3 │ │ Widget4 │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ └────────────┴─────┬──────┴────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ Buffer │ Grid de Cells │ +│ │ (current)│ Cada Cell: char + style │ +│ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ Diff │ Compara con buffer anterior │ +│ └────┬─────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ Terminal │ Solo envía cambios │ +│ └────┬─────┘ │ +│ │ │ +└─────────────────────────┼────────────────────────────────────────┘ + │ + ▼ + ┌──────────┐ + │ stdout │ ANSI escape sequences + └──────────┘ +``` + +## Componentes Core + +### 1. Cell (`buffer.zig`) + +La unidad mínima de renderizado. Representa un único carácter en la terminal. + +```zig +pub const Cell = struct { + symbol: Symbol, // UTF-8 grapheme (hasta 4 bytes) + style: Style, // Foreground, background, modifiers + + pub fn reset(self: *Cell) void { ... } + pub fn setChar(self: *Cell, ch: u21) void { ... } + pub fn setSymbol(self: *Cell, symbol: []const u8) void { ... } +}; +``` + +### 2. Buffer (`buffer.zig`) + +Grid bidimensional de Cells. Maneja el estado de renderizado. + +```zig +pub const Buffer = struct { + area: Rect, + cells: []Cell, + allocator: Allocator, + + pub fn init(allocator: Allocator, area: Rect) !Buffer { ... } + pub fn getCell(self: *Buffer, x: u16, y: u16) ?*Cell { ... } + pub fn setString(self: *Buffer, x: u16, y: u16, text: []const u8, style: Style) u16 { ... } + pub fn setSpan(self: *Buffer, x: u16, y: u16, span: Span, width: u16) u16 { ... } +}; +``` + +### 3. Rect (`buffer.zig`) + +Representa un área rectangular en la terminal. + +```zig +pub const Rect = struct { + x: u16, + y: u16, + width: u16, + height: u16, + + pub fn init(x: u16, y: u16, width: u16, height: u16) Rect { ... } + pub fn inner(self: Rect, margin: u16) Rect { ... } + pub fn intersection(self: Rect, other: Rect) Rect { ... } + pub fn isEmpty(self: Rect) bool { ... } + pub fn left(self: Rect) u16 { ... } + pub fn right(self: Rect) u16 { ... } + pub fn top(self: Rect) u16 { ... } + pub fn bottom(self: Rect) u16 { ... } +}; +``` + +### 4. Style (`style.zig`) + +Combinación de colores y modificadores de texto. + +```zig +pub const Style = struct { + foreground: ?Color = null, + background: ?Color = null, + add_modifiers: Modifier = .{}, + sub_modifiers: Modifier = .{}, + + pub const default: Style = .{}; + + pub fn fg(self: Style, color: Color) Style { ... } + pub fn bg(self: Style, color: Color) Style { ... } + pub fn bold(self: Style) Style { ... } + pub fn italic(self: Style) Style { ... } + pub fn patch(self: Style, other: Style) Style { ... } +}; +``` + +### 5. Color (`style.zig`) + +Soporte para colores de 16, 256 y RGB. + +```zig +pub const Color = union(enum) { + reset, + black, red, green, yellow, blue, magenta, cyan, white, + light_black, light_red, light_green, light_yellow, + light_blue, light_magenta, light_cyan, light_white, + indexed: u8, // 256 colores + rgb: struct { r: u8, g: u8, b: u8 }, +}; +``` + +### 6. Text Types (`text.zig`) + +Tipos para manejar texto estilizado. + +```zig +// Span: texto con estilo único +pub const Span = struct { + content: []const u8, + style: Style, +}; + +// Line: múltiples spans en una línea +pub const Line = struct { + spans: []const Span, + alignment: Alignment, +}; + +// Text: múltiples líneas +pub const Text = struct { + lines: []const Line, + alignment: Alignment, +}; +``` + +### 7. Layout (`layout.zig`) + +Sistema de distribución de espacio. + +```zig +pub const Layout = struct { + direction: Direction, + constraints: []const Constraint, + + pub fn horizontal(constraints: []const Constraint) Layout { ... } + pub fn vertical(constraints: []const Constraint) Layout { ... } + pub fn split(self: Layout, area: Rect, result: []Rect) void { ... } +}; + +pub const Constraint = union(enum) { + length: u16, // Exactamente N celdas + min: u16, // Mínimo N celdas + max: u16, // Máximo N celdas + percentage: u16, // N% del espacio disponible + ratio: struct { num: u32, den: u32 }, + fill: u16, // Llenar espacio restante +}; +``` + +## Sistema de Widgets + +### Patrón de Widget + +Todos los widgets implementan el método `render`: + +```zig +pub fn render(self: WidgetType, area: Rect, buf: *Buffer) void { + // 1. Validar área + if (area.isEmpty()) return; + + // 2. Renderizar block/wrapper si existe + const inner = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + // 3. Renderizar contenido + // ... +} +``` + +### StatefulWidget Pattern + +Para widgets con estado mutable: + +```zig +pub fn renderStateful(self: WidgetType, area: Rect, buf: *Buffer, state: *State) void { + // Similar a render, pero puede modificar state + // Útil para: scroll position, selection, etc. +} +``` + +### Fluent Builder Pattern + +Los widgets usan setters encadenables: + +```zig +const list = List.init(items) + .setBlock(block) + .setHighlightStyle(style) + .setHighlightSymbol("> ") + .setDirection(.top_to_bottom); +``` + +## Sistema de Símbolos + +### Estructura + +``` +symbols/ +├── line.zig # ─ │ ┌ ┐ └ ┘ ├ ┤ etc. +├── border.zig # Sets de bordes: plain, rounded, double, thick +├── block.zig # █ ▀ ▄ ▌ ▐ etc. +├── bar.zig # ▏▎▍▌▋▊▉█ (barras horizontales) +├── braille.zig # Patrones braille (256 combinaciones) +├── half_block.zig # ▀ ▄ para resolución 1x2 +├── scrollbar.zig # Símbolos para scrollbars +└── marker.zig # Marcadores para charts: dot, block, braille, etc. +``` + +### Braille Grid (2x4 dots per cell) + +``` +┌───┬───┐ +│ 0 │ 3 │ Bit layout: +├───┼───┤ +│ 1 │ 4 │ byte = Σ (2^bit) para cada dot activo +├───┼───┤ +│ 2 │ 5 │ Base: U+2800 (braille blank) +├───┼───┤ Resultado: chr(0x2800 + byte) +│ 6 │ 7 │ +└───┴───┘ +``` + +## Backend ANSI + +### Escape Sequences Soportadas + +| Función | Secuencia | +|---------|-----------| +| Clear screen | `\x1b[2J` | +| Move cursor | `\x1b[{row};{col}H` | +| Hide cursor | `\x1b[?25l` | +| Show cursor | `\x1b[?25h` | +| Reset style | `\x1b[0m` | +| Bold | `\x1b[1m` | +| Dim | `\x1b[2m` | +| Italic | `\x1b[3m` | +| Underline | `\x1b[4m` | +| FG color (16) | `\x1b[{30-37}m` | +| BG color (16) | `\x1b[{40-47}m` | +| FG color (256) | `\x1b[38;5;{n}m` | +| BG color (256) | `\x1b[48;5;{n}m` | +| FG color (RGB) | `\x1b[38;2;{r};{g};{b}m` | +| BG color (RGB) | `\x1b[48;2;{r};{g};{b}m` | +| Alternate screen | `\x1b[?1049h` | +| Main screen | `\x1b[?1049l` | + +## Algoritmos Clave + +### Bresenham's Line Algorithm (Canvas) + +Usado para dibujar líneas en el canvas: + +```zig +fn drawLine(x0: i32, y0: i32, x1: i32, y1: i32, color: Color) void { + var dx = @abs(x1 - x0); + var dy = @abs(y1 - y0); + var sx: i32 = if (x0 < x1) 1 else -1; + var sy: i32 = if (y0 < y1) 1 else -1; + var err = dx - dy; + + while (true) { + self.set(x0, y0, color); + if (x0 == x1 and y0 == y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } +} +``` + +### Zeller's Congruence (Calendar) + +Para calcular el día de la semana: + +```zig +pub fn dayOfWeek(year: i16, month: u4, day: u5) u3 { + var y = year; + var m = month; + if (m < 3) { m += 12; y -= 1; } + const q = day; + const k = @mod(y, 100); + const j = @divFloor(y, 100); + var h = q + @divFloor(13 * (m + 1), 5) + k + @divFloor(k, 4) + @divFloor(j, 4) - 2 * j; + return @intCast(@mod(@mod(h, 7) + 6, 7)); // 0=Sunday +} +``` + +## Consideraciones de Memoria + +### Stack Allocation + +La mayoría de estructuras usan stack allocation con tamaños fijos: + +- `CalendarEventStore`: máximo 32 eventos +- `Symbol`: máximo 4 bytes UTF-8 +- Arrays de constraints: tamaño fijo en compilación + +### Heap Allocation + +Solo se usa heap para: + +- `Buffer.cells`: array de celdas (puede ser grande) +- Strings dinámicos pasados por el usuario + +### Sin GC + +Zig no tiene garbage collector. Los widgets no poseen memoria, solo referencias. El usuario es responsable de la lifetime de los datos. + +## Testing + +### Estrategia + +1. **Unit tests** en cada módulo +2. **Render tests** comparando buffers +3. **Property-based** donde aplica (ej: Rect.intersection es conmutativa) + +### Ejecutar tests + +```bash +zig build test # Todos los tests +zig build test --summary all # Con resumen detallado +``` + +## Extensibilidad + +### Crear un Widget Personalizado + +```zig +const MyWidget = struct { + data: []const u8, + style: Style, + block: ?Block = null, + + pub fn init(data: []const u8) MyWidget { + return .{ .data = data, .style = Style.default }; + } + + pub fn setStyle(self: MyWidget, s: Style) MyWidget { + var w = self; + w.style = s; + return w; + } + + pub fn setBlock(self: MyWidget, b: Block) MyWidget { + var w = self; + w.block = b; + return w; + } + + pub fn render(self: MyWidget, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + const inner = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + _ = buf.setString(inner.left(), inner.top(), self.data, self.style); + } +}; +``` + +## Performance + +### Optimizaciones Actuales + +1. **Diff-based rendering**: Solo se envían cambios a la terminal +2. **Pre-computed symbols**: Braille patterns pre-calculados +3. **Inline functions**: Funciones críticas marcadas como inline +4. **Saturating arithmetic**: Uso de `-|` para evitar overflow checks + +### Áreas de Mejora Futuras + +1. Buffer pooling para reutilización +2. Lazy widget evaluation +3. Dirty region tracking +4. SIMD para operaciones de buffer diff --git a/docs/WIDGETS.md b/docs/WIDGETS.md new file mode 100644 index 0000000..a1300be --- /dev/null +++ b/docs/WIDGETS.md @@ -0,0 +1,886 @@ +# zcatui - Documentacion de Widgets + +> Referencia completa de todos los widgets disponibles en zcatui + +## Indice + +1. [Block](#block) - Contenedor con bordes +2. [Paragraph](#paragraph) - Texto con wrapping +3. [List](#list) - Lista seleccionable +4. [Table](#table) - Tabla multi-columna +5. [Gauge](#gauge) - Barra de progreso +6. [LineGauge](#linegauge) - Progreso en linea +7. [Tabs](#tabs) - Navegacion por pestanas +8. [Sparkline](#sparkline) - Mini graficos +9. [Scrollbar](#scrollbar) - Indicador de scroll +10. [BarChart](#barchart) - Graficos de barras +11. [Canvas](#canvas) - Dibujo libre +12. [Chart](#chart) - Graficos con ejes +13. [Calendar](#calendar) - Calendario mensual +14. [Clear](#clear) - Limpiar area + +--- + +## Block + +Widget base que proporciona bordes, titulos y padding. Usado como contenedor para otros widgets. + +### Archivo +`src/widgets/block.zig` + +### Ejemplo Basico + +```zig +const block = Block.init() + .title("Mi Titulo") + .borders(Borders.all) + .borderStyle(Style.default.fg(Color.blue)); + +block.render(area, buf); +``` + +### API + +```zig +pub const Block = struct { + // Constructores + pub fn init() Block; + pub fn bordered() Block; // Con bordes en todos los lados + + // Configuracion + pub fn title(self: Block, t: []const u8) Block; + pub fn titleStyle(self: Block, style: Style) Block; + pub fn titleAlignment(self: Block, alignment: Alignment) Block; + pub fn borders(self: Block, b: Borders) Block; + pub fn borderStyle(self: Block, style: Style) Block; + pub fn borderSet(self: Block, set: BorderSet) Block; + pub fn style(self: Block, s: Style) Block; + pub fn padding(self: Block, p: Padding) Block; + + // Utilidades + pub fn inner(self: Block, area: Rect) Rect; + pub fn horizontalSpace(self: Block) u16; + pub fn verticalSpace(self: Block) u16; + + // Renderizado + pub fn render(self: Block, area: Rect, buf: *Buffer) void; +}; + +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: Borders = .{ .top = true }; + // etc. +}; +``` + +### Border Sets Disponibles + +| Set | Descripcion | Caracteres | +|-----|-------------|------------| +| `plain` | Lineas simples | `─ │ ┌ ┐ └ ┘` | +| `rounded` | Esquinas redondeadas | `─ │ ╭ ╮ ╰ ╯` | +| `double` | Lineas dobles | `═ ║ ╔ ╗ ╚ ╝` | +| `thick` | Lineas gruesas | `━ ┃ ┏ ┓ ┗ ┛` | + +--- + +## Paragraph + +Widget para mostrar texto con word-wrapping, alineacion y scroll. + +### Archivo +`src/widgets/paragraph.zig` + +### Ejemplo Basico + +```zig +const text = Text.raw("Este es un texto largo que sera\nenvuelto automaticamente si es necesario."); + +const para = Paragraph.init(text) + .setBlock(Block.bordered().title("Info")) + .setWrap(.{ .trim = true }) + .setAlignment(.center); + +para.render(area, buf); +``` + +### API + +```zig +pub const Paragraph = struct { + pub fn init(text: Text) Paragraph; + pub fn initWithSpans(spans: []const Span) Paragraph; + + pub fn setBlock(self: Paragraph, block: Block) Paragraph; + pub fn setStyle(self: Paragraph, style: Style) Paragraph; + pub fn setWrap(self: Paragraph, wrap: Wrap) Paragraph; + pub fn setAlignment(self: Paragraph, alignment: Alignment) Paragraph; + pub fn setScroll(self: Paragraph, offset: struct { x: u16, y: u16 }) Paragraph; + + pub fn render(self: Paragraph, area: Rect, buf: *Buffer) void; +}; + +pub const Wrap = struct { + trim: bool = false, // Eliminar espacios extra +}; +``` + +--- + +## List + +Lista seleccionable con soporte para scroll automatico y highlighting. + +### Archivo +`src/widgets/list.zig` + +### Ejemplo Basico + +```zig +const items = [_]ListItem{ + ListItem.init(Line.raw("Item 1")), + ListItem.init(Line.raw("Item 2")).setStyle(Style.default.fg(Color.red)), + ListItem.init(Line.raw("Item 3")), +}; + +var state = ListState.init(); +state.select(1); // Seleccionar segundo item + +const list = List.init(&items) + .setBlock(Block.bordered().title("Lista")) + .setHighlightStyle(Style.default.bg(Color.blue)) + .setHighlightSymbol("> "); + +list.renderStateful(area, buf, &state); +``` + +### API + +```zig +pub const List = struct { + pub fn init(items: []const ListItem) List; + + pub fn setBlock(self: List, block: Block) List; + pub fn setStyle(self: List, style: Style) List; + pub fn setHighlightStyle(self: List, style: Style) List; + pub fn setHighlightSymbol(self: List, symbol: []const u8) List; + pub fn setHighlightSpacing(self: List, spacing: HighlightSpacing) List; + pub fn setDirection(self: List, direction: ListDirection) List; + pub fn setRepeatHighlightSymbol(self: List, repeat: bool) List; + + pub fn render(self: List, area: Rect, buf: *Buffer) void; + pub fn renderStateful(self: List, area: Rect, buf: *Buffer, state: *ListState) void; +}; + +pub const ListState = struct { + selected: ?usize = null, + offset: usize = 0, + + pub fn init() ListState; + pub fn select(self: *ListState, index: usize) void; + pub fn selectNext(self: *ListState, len: usize) void; + pub fn selectPrevious(self: *ListState) void; +}; + +pub const ListDirection = enum { top_to_bottom, bottom_to_top }; +``` + +--- + +## Table + +Tabla multi-columna con headers, footers y seleccion de filas. + +### Archivo +`src/widgets/table.zig` + +### Ejemplo Basico + +```zig +const header = Row.init(&[_]Cell{ + Cell.init(Line.raw("Nombre")), + Cell.init(Line.raw("Edad")), + Cell.init(Line.raw("Ciudad")), +}).setStyle(Style.default.bold()); + +const rows = [_]Row{ + Row.init(&[_]Cell{ + Cell.init(Line.raw("Ana")), + Cell.init(Line.raw("25")), + Cell.init(Line.raw("Madrid")), + }), + Row.init(&[_]Cell{ + Cell.init(Line.raw("Juan")), + Cell.init(Line.raw("30")), + Cell.init(Line.raw("Barcelona")), + }), +}; + +const widths = [_]Constraint{ + Constraint.percentage(40), + Constraint.length(10), + Constraint.fill(1), +}; + +var state = TableState.init(); +state.select(0); + +const table = Table.init(&rows, &widths) + .setHeader(header) + .setBlock(Block.bordered().title("Usuarios")) + .setHighlightStyle(Style.default.bg(Color.yellow)); + +table.renderStateful(area, buf, &state); +``` + +### API + +```zig +pub const Table = struct { + pub fn init(rows: []const Row, widths: []const Constraint) Table; + + pub fn setHeader(self: Table, header: Row) Table; + pub fn setFooter(self: Table, footer: Row) Table; + pub fn setBlock(self: Table, block: Block) Table; + pub fn setStyle(self: Table, style: Style) Table; + pub fn setHighlightStyle(self: Table, style: Style) Table; + pub fn setHighlightSymbol(self: Table, symbol: []const u8) Table; + pub fn setColumnSpacing(self: Table, spacing: u16) Table; + + pub fn render(self: Table, area: Rect, buf: *Buffer) void; + pub fn renderStateful(self: Table, area: Rect, buf: *Buffer, state: *TableState) void; +}; + +pub const TableState = struct { + selected: ?usize = null, + offset: usize = 0; + + pub fn init() TableState; + pub fn select(self: *TableState, index: usize) void; +}; +``` + +--- + +## Gauge + +Barra de progreso usando caracteres de bloque. + +### Archivo +`src/widgets/gauge.zig` + +### Ejemplo Basico + +```zig +const gauge = Gauge.init() + .setRatio(0.75) + .setLabel("75%") + .setBlock(Block.bordered().title("Progreso")) + .setGaugeStyle(Style.default.fg(Color.green).bg(Color.black)); + +gauge.render(area, buf); +``` + +### API + +```zig +pub const Gauge = struct { + pub fn init() Gauge; + + pub fn setRatio(self: Gauge, ratio: f64) Gauge; // 0.0 - 1.0 + pub fn setPercent(self: Gauge, percent: u16) Gauge; // 0 - 100 + pub fn setLabel(self: Gauge, label: []const u8) Gauge; + pub fn setBlock(self: Gauge, block: Block) Gauge; + pub fn setStyle(self: Gauge, style: Style) Gauge; + pub fn setGaugeStyle(self: Gauge, style: Style) Gauge; + pub fn setUseUnicode(self: Gauge, use: bool) Gauge; + + pub fn render(self: Gauge, area: Rect, buf: *Buffer) void; +}; +``` + +--- + +## LineGauge + +Barra de progreso en una sola linea. + +### Archivo +`src/widgets/gauge.zig` + +### Ejemplo Basico + +```zig +const gauge = LineGauge.init() + .setRatio(0.5) + .setLabel("Loading...") + .setLineSet(symbols.line.NORMAL) + .setFilledStyle(Style.default.fg(Color.cyan)) + .setUnfilledStyle(Style.default.fg(Color.white)); + +gauge.render(area, buf); +``` + +### API + +```zig +pub const LineGauge = struct { + pub fn init() LineGauge; + + pub fn setRatio(self: LineGauge, ratio: f64) LineGauge; + pub fn setLabel(self: LineGauge, label: []const u8) LineGauge; + pub fn setBlock(self: LineGauge, block: Block) LineGauge; + pub fn setStyle(self: LineGauge, style: Style) LineGauge; + pub fn setFilledStyle(self: LineGauge, style: Style) LineGauge; + pub fn setUnfilledStyle(self: LineGauge, style: Style) LineGauge; + pub fn setLineSet(self: LineGauge, set: symbols.line.Set) LineGauge; + + pub fn render(self: LineGauge, area: Rect, buf: *Buffer) void; +}; +``` + +--- + +## Tabs + +Widget de navegacion por pestanas. + +### Archivo +`src/widgets/tabs.zig` + +### Ejemplo Basico + +```zig +const titles = [_]Line{ + Line.raw("Tab 1"), + Line.raw("Tab 2"), + Line.raw("Tab 3"), +}; + +const tabs = Tabs.init(&titles) + .select(1) + .setBlock(Block.bordered()) + .setHighlightStyle(Style.default.fg(Color.yellow).bold()) + .setDivider(" | "); + +tabs.render(area, buf); +``` + +### API + +```zig +pub const Tabs = struct { + pub fn init(titles: []const Line) Tabs; + + pub fn select(self: Tabs, index: usize) Tabs; + pub fn setBlock(self: Tabs, block: Block) Tabs; + pub fn setStyle(self: Tabs, style: Style) Tabs; + pub fn setHighlightStyle(self: Tabs, style: Style) Tabs; + pub fn setDivider(self: Tabs, divider: []const u8) Tabs; + pub fn setPadding(self: Tabs, left: []const u8, right: []const u8) Tabs; + + pub fn render(self: Tabs, area: Rect, buf: *Buffer) void; +}; +``` + +--- + +## Sparkline + +Mini grafico de linea usando caracteres de bloque. + +### Archivo +`src/widgets/sparkline.zig` + +### Ejemplo Basico + +```zig +const data = [_]u64{ 0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4 }; + +const spark = Sparkline.init() + .setData(&data) + .setMax(5) + .setBlock(Block.bordered().title("CPU")) + .setStyle(Style.default.fg(Color.cyan)) + .setDirection(.left_to_right); + +spark.render(area, buf); +``` + +### API + +```zig +pub const Sparkline = struct { + pub fn init() Sparkline; + + pub fn setData(self: Sparkline, data: []const u64) Sparkline; + pub fn setMax(self: Sparkline, max: u64) Sparkline; + pub fn setBlock(self: Sparkline, block: Block) Sparkline; + pub fn setStyle(self: Sparkline, style: Style) Sparkline; + pub fn setBarSet(self: Sparkline, set: symbols.bar.Set) Sparkline; + pub fn setDirection(self: Sparkline, direction: RenderDirection) Sparkline; + + pub fn render(self: Sparkline, area: Rect, buf: *Buffer) void; +}; + +pub const RenderDirection = enum { left_to_right, right_to_left }; +``` + +--- + +## Scrollbar + +Indicador visual de posicion de scroll. + +### Archivo +`src/widgets/scrollbar.zig` + +### Ejemplo Basico + +```zig +var state = ScrollbarState.init(100) // 100 items totales + .setPosition(25) // Posicion actual + .setViewportContentLength(20); // Items visibles + +const scrollbar = Scrollbar.init(.vertical_right) + .setSymbols(.{ .track = "│", .thumb = "█" }) + .setStyle(Style.default.fg(Color.white)); + +scrollbar.render(area, buf, &state); +``` + +### API + +```zig +pub const Scrollbar = struct { + pub fn init(orientation: ScrollbarOrientation) Scrollbar; + + pub fn setOrientation(self: Scrollbar, o: ScrollbarOrientation) Scrollbar; + pub fn setThumbSymbol(self: Scrollbar, symbol: []const u8) Scrollbar; + pub fn setTrackSymbol(self: Scrollbar, symbol: ?[]const u8) Scrollbar; + pub fn setBeginSymbol(self: Scrollbar, symbol: ?[]const u8) Scrollbar; + pub fn setEndSymbol(self: Scrollbar, symbol: ?[]const u8) Scrollbar; + pub fn setSymbols(self: Scrollbar, symbols: ScrollbarSymbols) Scrollbar; + pub fn setStyle(self: Scrollbar, style: Style) Scrollbar; + pub fn setThumbStyle(self: Scrollbar, style: Style) Scrollbar; + pub fn setTrackStyle(self: Scrollbar, style: Style) Scrollbar; + + pub fn render(self: Scrollbar, area: Rect, buf: *Buffer, state: *ScrollbarState) void; +}; + +pub const ScrollbarState = struct { + pub fn init(content_length: usize) ScrollbarState; + pub fn setPosition(self: ScrollbarState, position: usize) ScrollbarState; + pub fn setViewportContentLength(self: ScrollbarState, length: usize) ScrollbarState; +}; + +pub const ScrollbarOrientation = enum { + vertical_right, + vertical_left, + horizontal_bottom, + horizontal_top, +}; +``` + +--- + +## BarChart + +Graficos de barras con soporte para grupos. + +### Archivo +`src/widgets/barchart.zig` + +### Ejemplo Basico + +```zig +const bars = [_]Bar{ + Bar.init(50).setLabel("Ene").setStyle(Style.default.fg(Color.red)), + Bar.init(80).setLabel("Feb").setStyle(Style.default.fg(Color.green)), + Bar.init(65).setLabel("Mar").setStyle(Style.default.fg(Color.blue)), +}; + +const group = BarGroup.init(&bars).setLabel("Q1 2024"); + +const groups = [_]BarGroup{group}; + +const chart = BarChart.init() + .setData(&groups) + .setBarWidth(5) + .setBarGap(1) + .setGroupGap(2) + .setBlock(Block.bordered().title("Ventas")) + .setMax(100); + +chart.render(area, buf); +``` + +### API + +```zig +pub const BarChart = struct { + pub fn init() BarChart; + + pub fn setData(self: BarChart, data: []const BarGroup) BarChart; + pub fn setBlock(self: BarChart, block: Block) BarChart; + pub fn setBarWidth(self: BarChart, width: u16) BarChart; + pub fn setBarGap(self: BarChart, gap: u16) BarChart; + pub fn setGroupGap(self: BarChart, gap: u16) BarChart; + pub fn setBarStyle(self: BarChart, style: Style) BarChart; + pub fn setLabelStyle(self: BarChart, style: Style) BarChart; + pub fn setValueStyle(self: BarChart, style: Style) BarChart; + pub fn setStyle(self: BarChart, style: Style) BarChart; + pub fn setMax(self: BarChart, max: u64) BarChart; + pub fn setDirection(self: BarChart, dir: Direction) BarChart; + + pub fn render(self: BarChart, area: Rect, buf: *Buffer) void; +}; + +pub const Bar = struct { + pub fn init(value: u64) Bar; + pub fn setLabel(self: Bar, label: []const u8) Bar; + pub fn setStyle(self: Bar, style: Style) Bar; + pub fn setValueStyle(self: Bar, style: Style) Bar; + pub fn setTextValue(self: Bar, text: []const u8) Bar; +}; + +pub const BarGroup = struct { + pub fn init(bars: []const Bar) BarGroup; + pub fn setLabel(self: BarGroup, label: []const u8) BarGroup; +}; +``` + +--- + +## Canvas + +Widget para dibujo libre con diferentes tipos de marcadores. + +### Archivo +`src/widgets/canvas.zig` + +### Ejemplo Basico + +```zig +const canvas = Canvas.init() + .setXBounds(-10.0, 10.0) + .setYBounds(-10.0, 10.0) + .setMarker(.braille) + .setBlock(Block.bordered().title("Canvas")) + .paint(struct { + pub fn draw(painter: *Painter) void { + // Dibujar una linea + painter.line(0, 0, 10, 10, Color.red); + + // Dibujar un rectangulo + painter.rectangle(2, 2, 8, 8, Color.blue); + + // Dibujar un circulo + painter.circle(5, 5, 3, Color.green); + + // Dibujar puntos individuales + painter.point(0, 0, Color.yellow); + } + }.draw); + +canvas.render(area, buf); +``` + +### API + +```zig +pub const Canvas = struct { + pub fn init() Canvas; + + pub fn setXBounds(self: Canvas, min: f64, max: f64) Canvas; + pub fn setYBounds(self: Canvas, min: f64, max: f64) Canvas; + pub fn setMarker(self: Canvas, marker: Marker) Canvas; + pub fn setBlock(self: Canvas, block: Block) Canvas; + pub fn setBackgroundColor(self: Canvas, color: Color) Canvas; + pub fn paint(self: Canvas, painter_fn: *const fn(*Painter) void) Canvas; + + pub fn render(self: Canvas, area: Rect, buf: *Buffer) void; +}; + +pub const Marker = enum { + dot, // Punto simple + block, // Caracter bloque completo + bar, // Barras verticales + braille, // Patrones braille (2x4 por celda) + half_block, // Half-blocks (1x2 por celda) +}; + +pub const Painter = struct { + pub fn point(self: *Painter, x: f64, y: f64, color: Color) void; + pub fn line(self: *Painter, x1: f64, y1: f64, x2: f64, y2: f64, color: Color) void; + pub fn rectangle(self: *Painter, x: f64, y: f64, width: f64, height: f64, color: Color) void; + pub fn circle(self: *Painter, x: f64, y: f64, radius: f64, color: Color) void; +}; +``` + +### Resoluciones por Marker + +| Marker | Resolucion por celda | Uso recomendado | +|--------|---------------------|-----------------| +| `dot` | 1x1 | Simple, compatible | +| `block` | 1x1 | Relleno solido | +| `bar` | 8x1 | Barras horizontales | +| `braille` | 2x4 | Alta resolucion | +| `half_block` | 1x2 | Resolucion media | + +--- + +## Chart + +Graficos de linea, scatter y barras con ejes X/Y. + +### Archivo +`src/widgets/chart.zig` + +### Ejemplo Basico + +```zig +const data1 = [_][2]f64{ + .{ 0, 0 }, .{ 1, 1 }, .{ 2, 4 }, .{ 3, 9 }, .{ 4, 16 }, +}; + +const data2 = [_][2]f64{ + .{ 0, 16 }, .{ 1, 9 }, .{ 2, 4 }, .{ 3, 1 }, .{ 4, 0 }, +}; + +const datasets = [_]Dataset{ + Dataset.init(&data1) + .setName("x^2") + .setMarker(.braille) + .setGraphType(.line) + .setStyle(Style.default.fg(Color.cyan)), + Dataset.init(&data2) + .setName("(4-x)^2") + .setMarker(.braille) + .setGraphType(.scatter) + .setStyle(Style.default.fg(Color.yellow)), +}; + +const x_axis = Axis.init() + .setTitle("X") + .setBounds(0, 4) + .setLabels(&[_][]const u8{ "0", "1", "2", "3", "4" }); + +const y_axis = Axis.init() + .setTitle("Y") + .setBounds(0, 16); + +const chart = Chart.init(&datasets) + .setXAxis(x_axis) + .setYAxis(y_axis) + .setBlock(Block.bordered().title("Grafico")) + .setLegendPosition(.top_right); + +chart.render(area, buf); +``` + +### API + +```zig +pub const Chart = struct { + pub fn init(datasets: []const Dataset) Chart; + + pub fn setXAxis(self: Chart, axis: Axis) Chart; + pub fn setYAxis(self: Chart, axis: Axis) Chart; + pub fn setBlock(self: Chart, block: Block) Chart; + pub fn setStyle(self: Chart, style: Style) Chart; + pub fn setLegendPosition(self: Chart, pos: ?LegendPosition) Chart; + pub fn setHideLegendConstraint(self: Chart, constraint: Constraint) Chart; + + pub fn render(self: Chart, area: Rect, buf: *Buffer) void; +}; + +pub const Dataset = struct { + pub fn init(data: []const [2]f64) Dataset; + + pub fn setName(self: Dataset, name: []const u8) Dataset; + pub fn setData(self: Dataset, data: []const [2]f64) Dataset; + pub fn setMarker(self: Dataset, marker: Marker) Dataset; + pub fn setGraphType(self: Dataset, graph_type: GraphType) Dataset; + pub fn setStyle(self: Dataset, style: Style) Dataset; +}; + +pub const Axis = struct { + pub fn init() Axis; + + pub fn setTitle(self: Axis, title: []const u8) Axis; + pub fn setTitleStyle(self: Axis, style: Style) Axis; + pub fn setBounds(self: Axis, min: f64, max: f64) Axis; + pub fn setLabels(self: Axis, labels: []const []const u8) Axis; + pub fn setLabelsAlignment(self: Axis, alignment: Alignment) Axis; + pub fn setStyle(self: Axis, style: Style) Axis; +}; + +pub const GraphType = enum { scatter, line, bar }; +pub const LegendPosition = enum { top_left, top_right, bottom_left, bottom_right }; +``` + +--- + +## Calendar + +Calendario mensual con soporte para eventos y estilos personalizados. + +### Archivo +`src/widgets/calendar.zig` + +### Ejemplo Basico + +```zig +// Crear eventos con estilos +var events = CalendarEventStore.init(); +events.add(Date.init(2024, 12, 25), Style.default.fg(Color.red).bold()); +events.add(Date.init(2024, 12, 31), Style.default.fg(Color.yellow)); + +const calendar = Monthly.init(Date.init(2024, 12, 1)) + .withEvents(events) + .showMonthHeader(Style.default.fg(Color.blue).bold()) + .showWeekdaysHeader(Style.default.fg(Color.cyan)) + .showSurrounding(Style.default.fg(Color.white)) + .setDefaultStyle(Style.default) + .setBlock(Block.bordered()); + +calendar.render(area, buf); +``` + +### Salida Visual + +``` +┌──────────────────────┐ +│ December 2024 │ +│ Su Mo Tu We Th Fr Sa │ +│ 1 2 3 4 5 6 7 │ +│ 8 9 10 11 12 13 14 │ +│ 15 16 17 18 19 20 21 │ +│ 22 23 24 25 26 27 28 │ +│ 29 30 31 │ +└──────────────────────┘ +``` + +### API + +```zig +pub const Monthly = struct { + pub fn init(display_date: Date) Monthly; + pub fn withEvents(display_date: Date, events: CalendarEventStore) Monthly; + + pub fn showSurrounding(self: Monthly, style: Style) Monthly; + pub fn showWeekdaysHeader(self: Monthly, style: Style) Monthly; + pub fn showMonthHeader(self: Monthly, style: Style) Monthly; + pub fn setDefaultStyle(self: Monthly, style: Style) Monthly; + pub fn setBlock(self: Monthly, block: Block) Monthly; + + pub fn width(self: Monthly) u16; // Siempre 21 + bordes + pub fn height(self: Monthly) u16; // Variable segun mes + + pub fn render(self: Monthly, area: Rect, buf: *Buffer) void; +}; + +pub const Date = struct { + year: i16, + month: u4, // 1-12 + day: u5, // 1-31 + + pub fn init(year: i16, month: u4, day: u5) Date; + pub fn isLeapYear(self: Date) bool; + pub fn daysInMonth(self: Date) u5; + pub fn dayOfWeek(self: Date) u3; // 0=Sunday + pub fn monthName(self: Date) []const u8; +}; + +pub const CalendarEventStore = struct { + pub fn init() CalendarEventStore; + pub fn add(self: *CalendarEventStore, date: Date, style: Style) void; + pub fn getStyle(self: CalendarEventStore, date: Date) Style; +}; +``` + +--- + +## Clear + +Widget simple que limpia/resetea un area de la pantalla. + +### Archivo +`src/widgets/clear.zig` + +### Ejemplo Basico + +```zig +// Limpiar todo el area antes de renderizar otros widgets +Clear.init().render(area, buf); + +// Ahora renderizar contenido fresco +my_widget.render(area, buf); +``` + +### API + +```zig +pub const Clear = struct { + pub fn init() Clear; + pub fn render(self: Clear, area: Rect, buf: *Buffer) void; +}; +``` + +### Uso Tipico + +Clear es util cuando: +1. Necesitas borrar contenido previo antes de re-renderizar +2. Quieres resetear estilos de un area especifica +3. Implementas transiciones entre pantallas + +--- + +## Patrones Comunes + +### Composicion de Widgets + +```zig +// Crear un layout dividido +const chunks = Layout.vertical(&[_]Constraint{ + Constraint.length(3), // Header + Constraint.min(0), // Contenido + Constraint.length(1), // Footer +}).split(area); + +// Renderizar widgets en cada area +Block.bordered().title("Header").render(chunks[0], buf); +list.renderStateful(chunks[1], buf, &list_state); +Paragraph.init("Status: OK").render(chunks[2], buf); +``` + +### Widgets Anidados + +```zig +// List con block personalizado +const list = List.init(items) + .setBlock( + Block.bordered() + .title("Items") + .titleStyle(Style.default.bold()) + .borderStyle(Style.default.fg(Color.cyan)) + ); +``` + +### Estilos Condicionales + +```zig +const item_style = if (is_selected) + Style.default.bg(Color.blue).fg(Color.white) +else if (is_important) + Style.default.fg(Color.red).bold() +else + Style.default; +``` diff --git a/src/root.zig b/src/root.zig index 6e590ea..b6ce679 100644 --- a/src/root.zig +++ b/src/root.zig @@ -38,6 +38,13 @@ pub const Cell = buffer.Cell; pub const Buffer = buffer.Buffer; pub const Rect = buffer.Rect; +pub const text = @import("text.zig"); +pub const Span = text.Span; +pub const Line = text.Line; +pub const Text = text.Text; +pub const StyledGrapheme = text.StyledGrapheme; +pub const Alignment = text.Alignment; + // Re-exports for convenience pub const terminal = @import("terminal.zig"); pub const Terminal = terminal.Terminal; @@ -48,15 +55,76 @@ pub const Layout = layout.Layout; pub const Constraint = layout.Constraint; pub const Direction = layout.Direction; +// Symbols (line drawing, borders, blocks, braille, etc.) +pub const symbols = @import("symbols/symbols.zig"); + // 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 + + pub const list_mod = @import("widgets/list.zig"); + pub const List = list_mod.List; + pub const ListItem = list_mod.ListItem; + pub const ListState = list_mod.ListState; + pub const ListDirection = list_mod.ListDirection; + pub const HighlightSpacing = list_mod.HighlightSpacing; + + pub const gauge_mod = @import("widgets/gauge.zig"); + pub const Gauge = gauge_mod.Gauge; + pub const LineGauge = gauge_mod.LineGauge; + + pub const tabs_mod = @import("widgets/tabs.zig"); + pub const Tabs = tabs_mod.Tabs; + + pub const sparkline_mod = @import("widgets/sparkline.zig"); + pub const Sparkline = sparkline_mod.Sparkline; + pub const RenderDirection = sparkline_mod.RenderDirection; + + pub const scrollbar_mod = @import("widgets/scrollbar.zig"); + pub const Scrollbar = scrollbar_mod.Scrollbar; + pub const ScrollbarState = scrollbar_mod.ScrollbarState; + pub const ScrollbarOrientation = scrollbar_mod.ScrollbarOrientation; + + pub const barchart_mod = @import("widgets/barchart.zig"); + pub const BarChart = barchart_mod.BarChart; + pub const Bar = barchart_mod.Bar; + pub const BarGroup = barchart_mod.BarGroup; + + pub const table_mod = @import("widgets/table.zig"); + pub const Table = table_mod.Table; + pub const TableState = table_mod.TableState; + pub const TableRow = table_mod.Row; + pub const TableCell = table_mod.Cell; + + pub const canvas_mod = @import("widgets/canvas.zig"); + pub const Canvas = canvas_mod.Canvas; + pub const CanvasPainter = canvas_mod.Painter; + pub const CanvasMarker = canvas_mod.Marker; + pub const CanvasLine = canvas_mod.Line; + pub const CanvasPoints = canvas_mod.Points; + pub const CanvasRectangle = canvas_mod.Rectangle; + pub const CanvasCircle = canvas_mod.Circle; + + pub const chart_mod = @import("widgets/chart.zig"); + pub const Chart = chart_mod.Chart; + pub const Axis = chart_mod.Axis; + pub const Dataset = chart_mod.Dataset; + pub const GraphType = chart_mod.GraphType; + pub const LegendPosition = chart_mod.LegendPosition; + + pub const clear_mod = @import("widgets/clear.zig"); + pub const Clear = clear_mod.Clear; + + pub const calendar_mod = @import("widgets/calendar.zig"); + pub const Monthly = calendar_mod.Monthly; + pub const Date = calendar_mod.Date; + pub const CalendarEventStore = calendar_mod.CalendarEventStore; }; // Backend diff --git a/src/symbols/bar.zig b/src/symbols/bar.zig new file mode 100644 index 0000000..4bfc37a --- /dev/null +++ b/src/symbols/bar.zig @@ -0,0 +1,92 @@ +//! Bar element characters. +//! +//! Provides block elements for drawing vertical bar charts (bottom to top). +//! These are LOWER-HALF characters (for vertical progress/bars). + +const std = @import("std"); + +// ============================================================================ +// Individual Bar Characters (Vertical - Bottom to Top) +// ============================================================================ + +pub const FULL: []const u8 = "█"; +pub const SEVEN_EIGHTHS: []const u8 = "▇"; +pub const THREE_QUARTERS: []const u8 = "▆"; +pub const FIVE_EIGHTHS: []const u8 = "▅"; +pub const HALF: []const u8 = "▄"; +pub const THREE_EIGHTHS: []const u8 = "▃"; +pub const ONE_QUARTER: []const u8 = "▂"; +pub const ONE_EIGHTH: []const u8 = "▁"; + +// ============================================================================ +// Bar Set +// ============================================================================ + +/// A set of bar characters for different fill levels. +pub const Set = struct { + full: []const u8, + seven_eighths: []const u8, + three_quarters: []const u8, + five_eighths: []const u8, + half: []const u8, + three_eighths: []const u8, + one_quarter: []const u8, + one_eighth: []const u8, + empty: []const u8, + + pub const default: Set = NINE_LEVELS; + + /// Returns the appropriate symbol for a given fill ratio (0.0 to 1.0). + pub fn fromRatio(self: Set, ratio: f32) []const u8 { + if (ratio >= 1.0) return self.full; + if (ratio >= 0.875) return self.seven_eighths; + if (ratio >= 0.75) return self.three_quarters; + if (ratio >= 0.625) return self.five_eighths; + if (ratio >= 0.5) return self.half; + if (ratio >= 0.375) return self.three_eighths; + if (ratio >= 0.25) return self.one_quarter; + if (ratio >= 0.125) return self.one_eighth; + return self.empty; + } +}; + +/// Three-level bar set (empty, half, full). +pub const THREE_LEVELS: Set = .{ + .full = FULL, + .seven_eighths = FULL, + .three_quarters = HALF, + .five_eighths = HALF, + .half = HALF, + .three_eighths = HALF, + .one_quarter = HALF, + .one_eighth = " ", + .empty = " ", +}; + +/// Nine-level bar set with all gradations. +pub const NINE_LEVELS: Set = .{ + .full = FULL, + .seven_eighths = SEVEN_EIGHTHS, + .three_quarters = THREE_QUARTERS, + .five_eighths = FIVE_EIGHTHS, + .half = HALF, + .three_eighths = THREE_EIGHTHS, + .one_quarter = ONE_QUARTER, + .one_eighth = ONE_EIGHTH, + .empty = " ", +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "bar set default" { + try std.testing.expectEqualStrings(FULL, Set.default.full); +} + +test "bar set from ratio" { + const set = NINE_LEVELS; + try std.testing.expectEqualStrings(set.full, set.fromRatio(1.0)); + try std.testing.expectEqualStrings(set.half, set.fromRatio(0.5)); + try std.testing.expectEqualStrings(set.empty, set.fromRatio(0.0)); +} diff --git a/src/symbols/block.zig b/src/symbols/block.zig new file mode 100644 index 0000000..c3d42d6 --- /dev/null +++ b/src/symbols/block.zig @@ -0,0 +1,92 @@ +//! Block element characters. +//! +//! Provides block elements for drawing solid areas and horizontal bar charts. +//! These are LEFT-HALF characters (for horizontal progress/bars). + +const std = @import("std"); + +// ============================================================================ +// Individual Block Characters (Horizontal - Left to Right) +// ============================================================================ + +pub const FULL: []const u8 = "█"; +pub const SEVEN_EIGHTHS: []const u8 = "▉"; +pub const THREE_QUARTERS: []const u8 = "▊"; +pub const FIVE_EIGHTHS: []const u8 = "▋"; +pub const HALF: []const u8 = "▌"; +pub const THREE_EIGHTHS: []const u8 = "▍"; +pub const ONE_QUARTER: []const u8 = "▎"; +pub const ONE_EIGHTH: []const u8 = "▏"; + +// ============================================================================ +// Block Set +// ============================================================================ + +/// A set of block characters for different fill levels. +pub const Set = struct { + full: []const u8, + seven_eighths: []const u8, + three_quarters: []const u8, + five_eighths: []const u8, + half: []const u8, + three_eighths: []const u8, + one_quarter: []const u8, + one_eighth: []const u8, + empty: []const u8, + + pub const default: Set = NINE_LEVELS; + + /// Returns the appropriate symbol for a given fill ratio (0.0 to 1.0). + pub fn fromRatio(self: Set, ratio: f32) []const u8 { + if (ratio >= 1.0) return self.full; + if (ratio >= 0.875) return self.seven_eighths; + if (ratio >= 0.75) return self.three_quarters; + if (ratio >= 0.625) return self.five_eighths; + if (ratio >= 0.5) return self.half; + if (ratio >= 0.375) return self.three_eighths; + if (ratio >= 0.25) return self.one_quarter; + if (ratio >= 0.125) return self.one_eighth; + return self.empty; + } +}; + +/// Three-level block set (empty, half, full). +pub const THREE_LEVELS: Set = .{ + .full = FULL, + .seven_eighths = FULL, + .three_quarters = HALF, + .five_eighths = HALF, + .half = HALF, + .three_eighths = HALF, + .one_quarter = HALF, + .one_eighth = " ", + .empty = " ", +}; + +/// Nine-level block set with all gradations. +pub const NINE_LEVELS: Set = .{ + .full = FULL, + .seven_eighths = SEVEN_EIGHTHS, + .three_quarters = THREE_QUARTERS, + .five_eighths = FIVE_EIGHTHS, + .half = HALF, + .three_eighths = THREE_EIGHTHS, + .one_quarter = ONE_QUARTER, + .one_eighth = ONE_EIGHTH, + .empty = " ", +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "block set default" { + try std.testing.expectEqualStrings(FULL, Set.default.full); +} + +test "block set from ratio" { + const set = NINE_LEVELS; + try std.testing.expectEqualStrings(set.full, set.fromRatio(1.0)); + try std.testing.expectEqualStrings(set.half, set.fromRatio(0.5)); + try std.testing.expectEqualStrings(set.empty, set.fromRatio(0.0)); +} diff --git a/src/symbols/border.zig b/src/symbols/border.zig new file mode 100644 index 0000000..3c5620b --- /dev/null +++ b/src/symbols/border.zig @@ -0,0 +1,316 @@ +//! Border drawing character sets. +//! +//! Provides sets of characters for drawing borders around widgets. +//! Unlike line sets, border sets distinguish between top/bottom horizontals +//! and left/right verticals to allow for asymmetric border styles. + +const std = @import("std"); +const line = @import("line.zig"); +const block = @import("block.zig"); + +// ============================================================================ +// Border Set +// ============================================================================ + +/// A complete set of border drawing characters. +pub const Set = struct { + top_left: []const u8, + top_right: []const u8, + bottom_left: []const u8, + bottom_right: []const u8, + vertical_left: []const u8, + vertical_right: []const u8, + horizontal_top: []const u8, + horizontal_bottom: []const u8, + + pub const default: Set = PLAIN; + + /// Creates a border set from a line set. + pub fn fromLineSet(line_set: line.Set) Set { + return .{ + .top_left = line_set.top_left, + .top_right = line_set.top_right, + .bottom_left = line_set.bottom_left, + .bottom_right = line_set.bottom_right, + .vertical_left = line_set.vertical, + .vertical_right = line_set.vertical, + .horizontal_top = line_set.horizontal, + .horizontal_bottom = line_set.horizontal, + }; + } +}; + +// ============================================================================ +// Predefined Border Sets +// ============================================================================ + +/// Plain border with single lines. +/// ``` +/// ┌─────┐ +/// │xxxxx│ +/// │xxxxx│ +/// └─────┘ +/// ``` +pub const PLAIN: Set = .{ + .top_left = line.TOP_LEFT, + .top_right = line.TOP_RIGHT, + .bottom_left = line.BOTTOM_LEFT, + .bottom_right = line.BOTTOM_RIGHT, + .vertical_left = line.VERTICAL, + .vertical_right = line.VERTICAL, + .horizontal_top = line.HORIZONTAL, + .horizontal_bottom = line.HORIZONTAL, +}; + +/// Rounded corner border. +/// ``` +/// ╭─────╮ +/// │xxxxx│ +/// │xxxxx│ +/// ╰─────╯ +/// ``` +pub const ROUNDED: Set = .{ + .top_left = line.ROUNDED_TOP_LEFT, + .top_right = line.ROUNDED_TOP_RIGHT, + .bottom_left = line.ROUNDED_BOTTOM_LEFT, + .bottom_right = line.ROUNDED_BOTTOM_RIGHT, + .vertical_left = line.VERTICAL, + .vertical_right = line.VERTICAL, + .horizontal_top = line.HORIZONTAL, + .horizontal_bottom = line.HORIZONTAL, +}; + +/// Double line border. +/// ``` +/// ╔═════╗ +/// ║xxxxx║ +/// ║xxxxx║ +/// ╚═════╝ +/// ``` +pub const DOUBLE: Set = .{ + .top_left = line.DOUBLE_TOP_LEFT, + .top_right = line.DOUBLE_TOP_RIGHT, + .bottom_left = line.DOUBLE_BOTTOM_LEFT, + .bottom_right = line.DOUBLE_BOTTOM_RIGHT, + .vertical_left = line.DOUBLE_VERTICAL, + .vertical_right = line.DOUBLE_VERTICAL, + .horizontal_top = line.DOUBLE_HORIZONTAL, + .horizontal_bottom = line.DOUBLE_HORIZONTAL, +}; + +/// Thick (heavy) line border. +/// ``` +/// ┏━━━━━┓ +/// ┃xxxxx┃ +/// ┃xxxxx┃ +/// ┗━━━━━┛ +/// ``` +pub const THICK: Set = .{ + .top_left = line.THICK_TOP_LEFT, + .top_right = line.THICK_TOP_RIGHT, + .bottom_left = line.THICK_BOTTOM_LEFT, + .bottom_right = line.THICK_BOTTOM_RIGHT, + .vertical_left = line.THICK_VERTICAL, + .vertical_right = line.THICK_VERTICAL, + .horizontal_top = line.THICK_HORIZONTAL, + .horizontal_bottom = line.THICK_HORIZONTAL, +}; + +/// Light double-dashed border. +pub const LIGHT_DOUBLE_DASHED: Set = Set.fromLineSet(line.LIGHT_DOUBLE_DASHED); + +/// Heavy double-dashed border. +pub const HEAVY_DOUBLE_DASHED: Set = Set.fromLineSet(line.HEAVY_DOUBLE_DASHED); + +/// Light triple-dashed border. +pub const LIGHT_TRIPLE_DASHED: Set = Set.fromLineSet(line.LIGHT_TRIPLE_DASHED); + +/// Heavy triple-dashed border. +pub const HEAVY_TRIPLE_DASHED: Set = Set.fromLineSet(line.HEAVY_TRIPLE_DASHED); + +/// Light quadruple-dashed border. +pub const LIGHT_QUADRUPLE_DASHED: Set = Set.fromLineSet(line.LIGHT_QUADRUPLE_DASHED); + +/// Heavy quadruple-dashed border. +pub const HEAVY_QUADRUPLE_DASHED: Set = Set.fromLineSet(line.HEAVY_QUADRUPLE_DASHED); + +// ============================================================================ +// Quadrant Characters +// ============================================================================ + +pub const QUADRANT_TOP_LEFT: []const u8 = "▘"; +pub const QUADRANT_TOP_RIGHT: []const u8 = "▝"; +pub const QUADRANT_BOTTOM_LEFT: []const u8 = "▖"; +pub const QUADRANT_BOTTOM_RIGHT: []const u8 = "▗"; +pub const QUADRANT_TOP_HALF: []const u8 = "▀"; +pub const QUADRANT_BOTTOM_HALF: []const u8 = "▄"; +pub const QUADRANT_LEFT_HALF: []const u8 = "▌"; +pub const QUADRANT_RIGHT_HALF: []const u8 = "▐"; +pub const QUADRANT_TOP_LEFT_BOTTOM_LEFT_BOTTOM_RIGHT: []const u8 = "▙"; +pub const QUADRANT_TOP_LEFT_TOP_RIGHT_BOTTOM_LEFT: []const u8 = "▛"; +pub const QUADRANT_TOP_LEFT_TOP_RIGHT_BOTTOM_RIGHT: []const u8 = "▜"; +pub const QUADRANT_TOP_RIGHT_BOTTOM_LEFT_BOTTOM_RIGHT: []const u8 = "▟"; +pub const QUADRANT_TOP_LEFT_BOTTOM_RIGHT: []const u8 = "▚"; +pub const QUADRANT_TOP_RIGHT_BOTTOM_LEFT: []const u8 = "▞"; +pub const QUADRANT_BLOCK: []const u8 = "█"; + +/// Quadrant border that extends outside the content area. +/// ``` +/// ▛▀▀▀▀▀▜ +/// ▌xxxxx▐ +/// ▌xxxxx▐ +/// ▙▄▄▄▄▄▟ +/// ``` +pub const QUADRANT_OUTSIDE: Set = .{ + .top_left = QUADRANT_TOP_LEFT_TOP_RIGHT_BOTTOM_LEFT, + .top_right = QUADRANT_TOP_LEFT_TOP_RIGHT_BOTTOM_RIGHT, + .bottom_left = QUADRANT_TOP_LEFT_BOTTOM_LEFT_BOTTOM_RIGHT, + .bottom_right = QUADRANT_TOP_RIGHT_BOTTOM_LEFT_BOTTOM_RIGHT, + .vertical_left = QUADRANT_LEFT_HALF, + .vertical_right = QUADRANT_RIGHT_HALF, + .horizontal_top = QUADRANT_TOP_HALF, + .horizontal_bottom = QUADRANT_BOTTOM_HALF, +}; + +/// Quadrant border that extends inside the content area. +/// ``` +/// ▗▄▄▄▄▄▖ +/// ▐xxxxx▌ +/// ▐xxxxx▌ +/// ▝▀▀▀▀▀▘ +/// ``` +pub const QUADRANT_INSIDE: Set = .{ + .top_left = QUADRANT_BOTTOM_RIGHT, + .top_right = QUADRANT_BOTTOM_LEFT, + .bottom_left = QUADRANT_TOP_RIGHT, + .bottom_right = QUADRANT_TOP_LEFT, + .vertical_left = QUADRANT_RIGHT_HALF, + .vertical_right = QUADRANT_LEFT_HALF, + .horizontal_top = QUADRANT_BOTTOM_HALF, + .horizontal_bottom = QUADRANT_TOP_HALF, +}; + +// ============================================================================ +// One-Eighth Characters +// ============================================================================ + +pub const ONE_EIGHTH_TOP: []const u8 = "▔"; +pub const ONE_EIGHTH_BOTTOM: []const u8 = "▁"; +pub const ONE_EIGHTH_LEFT: []const u8 = "▏"; +pub const ONE_EIGHTH_RIGHT: []const u8 = "▕"; + +/// Wide border using one-eighth block characters (McGugan box technique). +/// ``` +/// ▁▁▁▁▁▁▁ +/// ▏xxxxx▕ +/// ▏xxxxx▕ +/// ▔▔▔▔▔▔▔ +/// ``` +pub const ONE_EIGHTH_WIDE: Set = .{ + .top_left = ONE_EIGHTH_BOTTOM, + .top_right = ONE_EIGHTH_BOTTOM, + .bottom_left = ONE_EIGHTH_TOP, + .bottom_right = ONE_EIGHTH_TOP, + .vertical_left = ONE_EIGHTH_LEFT, + .vertical_right = ONE_EIGHTH_RIGHT, + .horizontal_top = ONE_EIGHTH_BOTTOM, + .horizontal_bottom = ONE_EIGHTH_TOP, +}; + +/// Tall border using one-eighth block characters (McGugan box technique). +/// ``` +/// ▕▔▔▏ +/// ▕xx▏ +/// ▕xx▏ +/// ▕▁▁▏ +/// ``` +pub const ONE_EIGHTH_TALL: Set = .{ + .top_left = ONE_EIGHTH_RIGHT, + .top_right = ONE_EIGHTH_LEFT, + .bottom_left = ONE_EIGHTH_RIGHT, + .bottom_right = ONE_EIGHTH_LEFT, + .vertical_left = ONE_EIGHTH_RIGHT, + .vertical_right = ONE_EIGHTH_LEFT, + .horizontal_top = ONE_EIGHTH_TOP, + .horizontal_bottom = ONE_EIGHTH_BOTTOM, +}; + +/// Wide proportional border using half blocks. +/// ``` +/// ▄▄▄▄ +/// █xx█ +/// █xx█ +/// ▀▀▀▀ +/// ``` +pub const PROPORTIONAL_WIDE: Set = .{ + .top_left = QUADRANT_BOTTOM_HALF, + .top_right = QUADRANT_BOTTOM_HALF, + .bottom_left = QUADRANT_TOP_HALF, + .bottom_right = QUADRANT_TOP_HALF, + .vertical_left = QUADRANT_BLOCK, + .vertical_right = QUADRANT_BLOCK, + .horizontal_top = QUADRANT_BOTTOM_HALF, + .horizontal_bottom = QUADRANT_TOP_HALF, +}; + +/// Tall proportional border using full blocks. +/// ``` +/// █▀▀█ +/// █xx█ +/// █xx█ +/// █▄▄█ +/// ``` +pub const PROPORTIONAL_TALL: Set = .{ + .top_left = QUADRANT_BLOCK, + .top_right = QUADRANT_BLOCK, + .bottom_left = QUADRANT_BLOCK, + .bottom_right = QUADRANT_BLOCK, + .vertical_left = QUADRANT_BLOCK, + .vertical_right = QUADRANT_BLOCK, + .horizontal_top = QUADRANT_TOP_HALF, + .horizontal_bottom = QUADRANT_BOTTOM_HALF, +}; + +/// Solid border using full blocks. +/// ``` +/// ████ +/// █xx█ +/// █xx█ +/// ████ +/// ``` +pub const FULL: Set = .{ + .top_left = block.FULL, + .top_right = block.FULL, + .bottom_left = block.FULL, + .bottom_right = block.FULL, + .vertical_left = block.FULL, + .vertical_right = block.FULL, + .horizontal_top = block.FULL, + .horizontal_bottom = block.FULL, +}; + +/// Empty border (spaces). +pub const EMPTY: Set = .{ + .top_left = " ", + .top_right = " ", + .bottom_left = " ", + .bottom_right = " ", + .vertical_left = " ", + .vertical_right = " ", + .horizontal_top = " ", + .horizontal_bottom = " ", +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "border set default" { + try std.testing.expectEqualStrings(line.TOP_LEFT, Set.default.top_left); +} + +test "border from line set" { + const border_set = Set.fromLineSet(line.DOUBLE); + try std.testing.expectEqualStrings(line.DOUBLE_TOP_LEFT, border_set.top_left); + try std.testing.expectEqualStrings(line.DOUBLE_VERTICAL, border_set.vertical_left); +} diff --git a/src/symbols/braille.zig b/src/symbols/braille.zig new file mode 100644 index 0000000..c660f9e --- /dev/null +++ b/src/symbols/braille.zig @@ -0,0 +1,206 @@ +//! Braille pattern characters. +//! +//! The Unicode Braille Patterns block (U+2800-U+28FF) provides 256 characters +//! that can be used for high-resolution drawing. Each character represents +//! a 2x4 grid of dots. +//! +//! The bit pattern for each character is: +//! ``` +//! 0 3 +//! 1 4 +//! 2 5 +//! 6 7 +//! ``` +//! +//! Note: The symbols are ordered by their bit pattern in row-major order, +//! not by their Unicode codepoint. + +const std = @import("std"); + +/// The base codepoint for Braille patterns (empty pattern). +pub const BRAILLE_BASE: u21 = 0x2800; + +/// Empty Braille pattern. +pub const BLANK: []const u8 = "⠀"; + +/// All 256 Braille pattern characters indexed by bit pattern. +/// Index corresponds to: bit 0=top-left, bit 1=mid-left, etc. +pub const BRAILLE: [256]u21 = blk: { + var result: [256]u21 = undefined; + // The Braille pattern is encoded differently than row-major order + // Bit positions in the character: + // 0 3 + // 1 4 + // 2 5 + // 6 7 + // + // We need to map from row-major (our index) to Braille encoding + for (0..256) |i| { + // Convert from our row-major indexing to Braille bit pattern + var braille_bits: u8 = 0; + + // Row 0: bits 0,1 in our index -> bits 0,3 in braille + if (i & 0x01 != 0) braille_bits |= 0x01; // top-left + if (i & 0x02 != 0) braille_bits |= 0x08; // top-right + + // Row 1: bits 2,3 in our index -> bits 1,4 in braille + if (i & 0x04 != 0) braille_bits |= 0x02; + if (i & 0x08 != 0) braille_bits |= 0x10; + + // Row 2: bits 4,5 in our index -> bits 2,5 in braille + if (i & 0x10 != 0) braille_bits |= 0x04; + if (i & 0x20 != 0) braille_bits |= 0x20; + + // Row 3: bits 6,7 in our index -> bits 6,7 in braille + if (i & 0x40 != 0) braille_bits |= 0x40; + if (i & 0x80 != 0) braille_bits |= 0x80; + + result[i] = BRAILLE_BASE + braille_bits; + } + break :blk result; +}; + +/// Converts a bit pattern to its corresponding Braille codepoint. +/// The bit pattern uses row-major ordering: +/// ``` +/// bit 0 bit 1 +/// bit 2 bit 3 +/// bit 4 bit 5 +/// bit 6 bit 7 +/// ``` +pub fn fromBits(bits: u8) u21 { + return BRAILLE[bits]; +} + +/// Converts (x, y) coordinates within a 2x4 cell to a bit mask. +/// x: 0 or 1 (left or right) +/// y: 0 to 3 (top to bottom) +pub fn dotBit(x: u1, y: u2) u8 { + const row: u3 = @as(u3, y) * 2; + return @as(u8, 1) << (row + x); +} + +/// Encodes a UTF-8 string for a Braille pattern. +pub fn encode(bits: u8, buf: *[4]u8) []const u8 { + const cp = fromBits(bits); + const len = std.unicode.utf8Encode(cp, buf) catch unreachable; + return buf[0..len]; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "braille blank" { + try std.testing.expectEqual(@as(u21, 0x2800), BRAILLE[0]); +} + +test "braille full" { + // All dots on = 0xFF in our encoding + const full = fromBits(0xFF); + try std.testing.expectEqual(@as(u21, 0x28FF), full); +} + +test "braille dot bit" { + try std.testing.expectEqual(@as(u8, 0x01), dotBit(0, 0)); // top-left + try std.testing.expectEqual(@as(u8, 0x02), dotBit(1, 0)); // top-right + try std.testing.expectEqual(@as(u8, 0x04), dotBit(0, 1)); // second row left + try std.testing.expectEqual(@as(u8, 0x80), dotBit(1, 3)); // bottom-right +} + +/// Pre-encoded UTF-8 strings for all 256 braille patterns. +/// This allows returning static strings without runtime encoding. +pub const PATTERNS: [256][]const u8 = blk: { + var result: [256][]const u8 = undefined; + for (0..256) |i| { + result[i] = switch (i) { + 0 => "⠀", 1 => "⠁", 2 => "⠈", 3 => "⠉", 4 => "⠂", 5 => "⠃", 6 => "⠊", 7 => "⠋", + 8 => "⠐", 9 => "⠑", 10 => "⠘", 11 => "⠙", 12 => "⠒", 13 => "⠓", 14 => "⠚", 15 => "⠛", + 16 => "⠄", 17 => "⠅", 18 => "⠌", 19 => "⠍", 20 => "⠆", 21 => "⠇", 22 => "⠎", 23 => "⠏", + 24 => "⠔", 25 => "⠕", 26 => "⠜", 27 => "⠝", 28 => "⠖", 29 => "⠗", 30 => "⠞", 31 => "⠟", + 32 => "⠠", 33 => "⠡", 34 => "⠨", 35 => "⠩", 36 => "⠢", 37 => "⠣", 38 => "⠪", 39 => "⠫", + 40 => "⠰", 41 => "⠱", 42 => "⠸", 43 => "⠹", 44 => "⠲", 45 => "⠳", 46 => "⠺", 47 => "⠻", + 48 => "⠤", 49 => "⠥", 50 => "⠬", 51 => "⠭", 52 => "⠦", 53 => "⠧", 54 => "⠮", 55 => "⠯", + 56 => "⠴", 57 => "⠵", 58 => "⠼", 59 => "⠽", 60 => "⠶", 61 => "⠷", 62 => "⠾", 63 => "⠿", + 64 => "⡀", 65 => "⡁", 66 => "⡈", 67 => "⡉", 68 => "⡂", 69 => "⡃", 70 => "⡊", 71 => "⡋", + 72 => "⡐", 73 => "⡑", 74 => "⡘", 75 => "⡙", 76 => "⡒", 77 => "⡓", 78 => "⡚", 79 => "⡛", + 80 => "⡄", 81 => "⡅", 82 => "⡌", 83 => "⡍", 84 => "⡆", 85 => "⡇", 86 => "⡎", 87 => "⡏", + 88 => "⡔", 89 => "⡕", 90 => "⡜", 91 => "⡝", 92 => "⡖", 93 => "⡗", 94 => "⡞", 95 => "⡟", + 96 => "⡠", 97 => "⡡", 98 => "⡨", 99 => "⡩", 100 => "⡢", 101 => "⡣", 102 => "⡪", 103 => "⡫", + 104 => "⡰", 105 => "⡱", 106 => "⡸", 107 => "⡹", 108 => "⡲", 109 => "⡳", 110 => "⡺", 111 => "⡻", + 112 => "⡤", 113 => "⡥", 114 => "⡬", 115 => "⡭", 116 => "⡦", 117 => "⡧", 118 => "⡮", 119 => "⡯", + 120 => "⡴", 121 => "⡵", 122 => "⡼", 123 => "⡽", 124 => "⡶", 125 => "⡷", 126 => "⡾", 127 => "⡿", + 128 => "⢀", 129 => "⢁", 130 => "⢈", 131 => "⢉", 132 => "⢂", 133 => "⢃", 134 => "⢊", 135 => "⢋", + 136 => "⢐", 137 => "⢑", 138 => "⢘", 139 => "⢙", 140 => "⢒", 141 => "⢓", 142 => "⢚", 143 => "⢛", + 144 => "⢄", 145 => "⢅", 146 => "⢌", 147 => "⢍", 148 => "⢆", 149 => "⢇", 150 => "⢎", 151 => "⢏", + 152 => "⢔", 153 => "⢕", 154 => "⢜", 155 => "⢝", 156 => "⢖", 157 => "⢗", 158 => "⢞", 159 => "⢟", + 160 => "⢠", 161 => "⢡", 162 => "⢨", 163 => "⢩", 164 => "⢢", 165 => "⢣", 166 => "⢪", 167 => "⢫", + 168 => "⢰", 169 => "⢱", 170 => "⢸", 171 => "⢹", 172 => "⢲", 173 => "⢳", 174 => "⢺", 175 => "⢻", + 176 => "⢤", 177 => "⢥", 178 => "⢬", 179 => "⢭", 180 => "⢦", 181 => "⢧", 182 => "⢮", 183 => "⢯", + 184 => "⢴", 185 => "⢵", 186 => "⢼", 187 => "⢽", 188 => "⢶", 189 => "⢷", 190 => "⢾", 191 => "⢿", + 192 => "⣀", 193 => "⣁", 194 => "⣈", 195 => "⣉", 196 => "⣂", 197 => "⣃", 198 => "⣊", 199 => "⣋", + 200 => "⣐", 201 => "⣑", 202 => "⣘", 203 => "⣙", 204 => "⣒", 205 => "⣓", 206 => "⣚", 207 => "⣛", + 208 => "⣄", 209 => "⣅", 210 => "⣌", 211 => "⣍", 212 => "⣆", 213 => "⣇", 214 => "⣎", 215 => "⣏", + 216 => "⣔", 217 => "⣕", 218 => "⣜", 219 => "⣝", 220 => "⣖", 221 => "⣗", 222 => "⣞", 223 => "⣟", + 224 => "⣠", 225 => "⣡", 226 => "⣨", 227 => "⣩", 228 => "⣢", 229 => "⣣", 230 => "⣪", 231 => "⣫", + 232 => "⣰", 233 => "⣱", 234 => "⣸", 235 => "⣹", 236 => "⣲", 237 => "⣳", 238 => "⣺", 239 => "⣻", + 240 => "⣤", 241 => "⣥", 242 => "⣬", 243 => "⣭", 244 => "⣦", 245 => "⣧", 246 => "⣮", 247 => "⣯", + 248 => "⣴", 249 => "⣵", 250 => "⣼", 251 => "⣽", 252 => "⣶", 253 => "⣷", 254 => "⣾", 255 => "⣿", + }; + } + break :blk result; +}; + +/// Get the pre-encoded UTF-8 string for a braille pattern. +/// The pattern uses the canvas bit ordering: +/// bit 0: top-left, bit 3: top-right +/// bit 1: second row left, bit 4: second row right +/// bit 2: third row left, bit 5: third row right +/// bit 6: bottom-left, bit 7: bottom-right +pub fn fromPattern(pattern: u8) []const u8 { + // Convert from canvas bit order to Braille Unicode bit order + var braille_bits: u8 = 0; + + // bit 0 (canvas top-left, dot 1) -> Braille bit 0 + if (pattern & 0x01 != 0) braille_bits |= 0x01; + // bit 3 (canvas top-right, dot 4) -> Braille bit 3 + if (pattern & 0x08 != 0) braille_bits |= 0x08; + // bit 1 (canvas second row left, dot 2) -> Braille bit 1 + if (pattern & 0x02 != 0) braille_bits |= 0x02; + // bit 4 (canvas second row right, dot 5) -> Braille bit 4 + if (pattern & 0x10 != 0) braille_bits |= 0x10; + // bit 2 (canvas third row left, dot 3) -> Braille bit 2 + if (pattern & 0x04 != 0) braille_bits |= 0x04; + // bit 5 (canvas third row right, dot 6) -> Braille bit 5 + if (pattern & 0x20 != 0) braille_bits |= 0x20; + // bit 6 (canvas bottom-left, dot 7) -> Braille bit 6 + if (pattern & 0x40 != 0) braille_bits |= 0x40; + // bit 7 (canvas bottom-right, dot 8) -> Braille bit 7 + if (pattern & 0x80 != 0) braille_bits |= 0x80; + + // Lookup in Unicode order + return switch (braille_bits) { + 0 => "⠀", + else => &[_]u8{ + 0xE2, + 0xA0 + @as(u8, @intCast((braille_bits >> 6) & 0x03)), + 0x80 + @as(u8, @intCast(braille_bits & 0x3F)), + }, + }; +} + +test "braille encode" { + var buf: [4]u8 = undefined; + const s = encode(0, &buf); + try std.testing.expectEqualStrings("⠀", s); +} + +test "braille fromPattern empty" { + try std.testing.expectEqualStrings("⠀", fromPattern(0)); +} + +test "braille fromPattern full" { + // All dots on + const result = fromPattern(0xFF); + try std.testing.expectEqualStrings("⣿", result); +} diff --git a/src/symbols/half_block.zig b/src/symbols/half_block.zig new file mode 100644 index 0000000..a2ceaf4 --- /dev/null +++ b/src/symbols/half_block.zig @@ -0,0 +1,39 @@ +//! Half-block characters for 2x1 pixel resolution. +//! +//! These characters allow drawing with twice the vertical resolution +//! by using the upper and lower half blocks. + +const std = @import("std"); + +/// Upper half block (top half filled). +pub const UPPER: []const u8 = "▀"; + +/// Lower half block (bottom half filled). +pub const LOWER: []const u8 = "▄"; + +/// Full block (both halves filled). +pub const FULL: []const u8 = "█"; + +/// Empty (space). +pub const EMPTY: []const u8 = " "; + +/// Returns the appropriate character for a 2x1 pixel pattern. +/// top: whether the top pixel is set +/// bottom: whether the bottom pixel is set +pub fn fromPixels(top: bool, bottom: bool) []const u8 { + if (top and bottom) return FULL; + if (top) return UPPER; + if (bottom) return LOWER; + return EMPTY; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "half block from pixels" { + try std.testing.expectEqualStrings(FULL, fromPixels(true, true)); + try std.testing.expectEqualStrings(UPPER, fromPixels(true, false)); + try std.testing.expectEqualStrings(LOWER, fromPixels(false, true)); + try std.testing.expectEqualStrings(EMPTY, fromPixels(false, false)); +} diff --git a/src/symbols/line.zig b/src/symbols/line.zig new file mode 100644 index 0000000..2a9c7d7 --- /dev/null +++ b/src/symbols/line.zig @@ -0,0 +1,289 @@ +//! Line drawing characters. +//! +//! Provides sets of Unicode box-drawing characters for creating lines, +//! borders, and tables with various styles (normal, rounded, double, thick). + +const std = @import("std"); + +// ============================================================================ +// Individual Line Characters +// ============================================================================ + +// Vertical lines +pub const VERTICAL: []const u8 = "│"; +pub const DOUBLE_VERTICAL: []const u8 = "║"; +pub const THICK_VERTICAL: []const u8 = "┃"; +pub const LIGHT_DOUBLE_DASH_VERTICAL: []const u8 = "╎"; +pub const HEAVY_DOUBLE_DASH_VERTICAL: []const u8 = "╏"; +pub const LIGHT_TRIPLE_DASH_VERTICAL: []const u8 = "┆"; +pub const HEAVY_TRIPLE_DASH_VERTICAL: []const u8 = "┇"; +pub const LIGHT_QUADRUPLE_DASH_VERTICAL: []const u8 = "┊"; +pub const HEAVY_QUADRUPLE_DASH_VERTICAL: []const u8 = "┋"; + +// Horizontal lines +pub const HORIZONTAL: []const u8 = "─"; +pub const DOUBLE_HORIZONTAL: []const u8 = "═"; +pub const THICK_HORIZONTAL: []const u8 = "━"; +pub const LIGHT_DOUBLE_DASH_HORIZONTAL: []const u8 = "╌"; +pub const HEAVY_DOUBLE_DASH_HORIZONTAL: []const u8 = "╍"; +pub const LIGHT_TRIPLE_DASH_HORIZONTAL: []const u8 = "┄"; +pub const HEAVY_TRIPLE_DASH_HORIZONTAL: []const u8 = "┅"; +pub const LIGHT_QUADRUPLE_DASH_HORIZONTAL: []const u8 = "┈"; +pub const HEAVY_QUADRUPLE_DASH_HORIZONTAL: []const u8 = "┉"; + +// Corners - Top Right +pub const TOP_RIGHT: []const u8 = "┐"; +pub const ROUNDED_TOP_RIGHT: []const u8 = "╮"; +pub const DOUBLE_TOP_RIGHT: []const u8 = "╗"; +pub const THICK_TOP_RIGHT: []const u8 = "┓"; + +// Corners - Top Left +pub const TOP_LEFT: []const u8 = "┌"; +pub const ROUNDED_TOP_LEFT: []const u8 = "╭"; +pub const DOUBLE_TOP_LEFT: []const u8 = "╔"; +pub const THICK_TOP_LEFT: []const u8 = "┏"; + +// Corners - Bottom Right +pub const BOTTOM_RIGHT: []const u8 = "┘"; +pub const ROUNDED_BOTTOM_RIGHT: []const u8 = "╯"; +pub const DOUBLE_BOTTOM_RIGHT: []const u8 = "╝"; +pub const THICK_BOTTOM_RIGHT: []const u8 = "┛"; + +// Corners - Bottom Left +pub const BOTTOM_LEFT: []const u8 = "└"; +pub const ROUNDED_BOTTOM_LEFT: []const u8 = "╰"; +pub const DOUBLE_BOTTOM_LEFT: []const u8 = "╚"; +pub const THICK_BOTTOM_LEFT: []const u8 = "┗"; + +// T-junctions +pub const VERTICAL_LEFT: []const u8 = "┤"; +pub const DOUBLE_VERTICAL_LEFT: []const u8 = "╣"; +pub const THICK_VERTICAL_LEFT: []const u8 = "┫"; + +pub const VERTICAL_RIGHT: []const u8 = "├"; +pub const DOUBLE_VERTICAL_RIGHT: []const u8 = "╠"; +pub const THICK_VERTICAL_RIGHT: []const u8 = "┣"; + +pub const HORIZONTAL_DOWN: []const u8 = "┬"; +pub const DOUBLE_HORIZONTAL_DOWN: []const u8 = "╦"; +pub const THICK_HORIZONTAL_DOWN: []const u8 = "┳"; + +pub const HORIZONTAL_UP: []const u8 = "┴"; +pub const DOUBLE_HORIZONTAL_UP: []const u8 = "╩"; +pub const THICK_HORIZONTAL_UP: []const u8 = "┻"; + +// Cross +pub const CROSS: []const u8 = "┼"; +pub const DOUBLE_CROSS: []const u8 = "╬"; +pub const THICK_CROSS: []const u8 = "╋"; + +// ============================================================================ +// Line Set +// ============================================================================ + +/// A complete set of line drawing characters for a particular style. +pub const Set = struct { + vertical: []const u8, + horizontal: []const u8, + top_right: []const u8, + top_left: []const u8, + bottom_right: []const u8, + bottom_left: []const u8, + vertical_left: []const u8, + vertical_right: []const u8, + horizontal_down: []const u8, + horizontal_up: []const u8, + cross: []const u8, + + pub const default: Set = NORMAL; +}; + +/// Normal (thin) line set. +/// ``` +/// ┌─┬┐ +/// │ ││ +/// ├─┼┤ +/// └─┴┘ +/// ``` +pub const NORMAL: Set = .{ + .vertical = VERTICAL, + .horizontal = HORIZONTAL, + .top_right = TOP_RIGHT, + .top_left = TOP_LEFT, + .bottom_right = BOTTOM_RIGHT, + .bottom_left = BOTTOM_LEFT, + .vertical_left = VERTICAL_LEFT, + .vertical_right = VERTICAL_RIGHT, + .horizontal_down = HORIZONTAL_DOWN, + .horizontal_up = HORIZONTAL_UP, + .cross = CROSS, +}; + +/// Rounded corner line set. +/// ``` +/// ╭─┬╮ +/// │ ││ +/// ├─┼┤ +/// ╰─┴╯ +/// ``` +pub const ROUNDED: Set = .{ + .vertical = VERTICAL, + .horizontal = HORIZONTAL, + .top_right = ROUNDED_TOP_RIGHT, + .top_left = ROUNDED_TOP_LEFT, + .bottom_right = ROUNDED_BOTTOM_RIGHT, + .bottom_left = ROUNDED_BOTTOM_LEFT, + .vertical_left = VERTICAL_LEFT, + .vertical_right = VERTICAL_RIGHT, + .horizontal_down = HORIZONTAL_DOWN, + .horizontal_up = HORIZONTAL_UP, + .cross = CROSS, +}; + +/// Double line set. +/// ``` +/// ╔═╦╗ +/// ║ ║║ +/// ╠═╬╣ +/// ╚═╩╝ +/// ``` +pub const DOUBLE: Set = .{ + .vertical = DOUBLE_VERTICAL, + .horizontal = DOUBLE_HORIZONTAL, + .top_right = DOUBLE_TOP_RIGHT, + .top_left = DOUBLE_TOP_LEFT, + .bottom_right = DOUBLE_BOTTOM_RIGHT, + .bottom_left = DOUBLE_BOTTOM_LEFT, + .vertical_left = DOUBLE_VERTICAL_LEFT, + .vertical_right = DOUBLE_VERTICAL_RIGHT, + .horizontal_down = DOUBLE_HORIZONTAL_DOWN, + .horizontal_up = DOUBLE_HORIZONTAL_UP, + .cross = DOUBLE_CROSS, +}; + +/// Thick (heavy) line set. +/// ``` +/// ┏━┳┓ +/// ┃ ┃┃ +/// ┣━╋┫ +/// ┗━┻┛ +/// ``` +pub const THICK: Set = .{ + .vertical = THICK_VERTICAL, + .horizontal = THICK_HORIZONTAL, + .top_right = THICK_TOP_RIGHT, + .top_left = THICK_TOP_LEFT, + .bottom_right = THICK_BOTTOM_RIGHT, + .bottom_left = THICK_BOTTOM_LEFT, + .vertical_left = THICK_VERTICAL_LEFT, + .vertical_right = THICK_VERTICAL_RIGHT, + .horizontal_down = THICK_HORIZONTAL_DOWN, + .horizontal_up = THICK_HORIZONTAL_UP, + .cross = THICK_CROSS, +}; + +/// Light double-dashed line set. +pub const LIGHT_DOUBLE_DASHED: Set = .{ + .vertical = LIGHT_DOUBLE_DASH_VERTICAL, + .horizontal = LIGHT_DOUBLE_DASH_HORIZONTAL, + .top_right = TOP_RIGHT, + .top_left = TOP_LEFT, + .bottom_right = BOTTOM_RIGHT, + .bottom_left = BOTTOM_LEFT, + .vertical_left = VERTICAL_LEFT, + .vertical_right = VERTICAL_RIGHT, + .horizontal_down = HORIZONTAL_DOWN, + .horizontal_up = HORIZONTAL_UP, + .cross = CROSS, +}; + +/// Heavy double-dashed line set. +pub const HEAVY_DOUBLE_DASHED: Set = .{ + .vertical = HEAVY_DOUBLE_DASH_VERTICAL, + .horizontal = HEAVY_DOUBLE_DASH_HORIZONTAL, + .top_right = THICK_TOP_RIGHT, + .top_left = THICK_TOP_LEFT, + .bottom_right = THICK_BOTTOM_RIGHT, + .bottom_left = THICK_BOTTOM_LEFT, + .vertical_left = THICK_VERTICAL_LEFT, + .vertical_right = THICK_VERTICAL_RIGHT, + .horizontal_down = THICK_HORIZONTAL_DOWN, + .horizontal_up = THICK_HORIZONTAL_UP, + .cross = THICK_CROSS, +}; + +/// Light triple-dashed line set. +pub const LIGHT_TRIPLE_DASHED: Set = .{ + .vertical = LIGHT_TRIPLE_DASH_VERTICAL, + .horizontal = LIGHT_TRIPLE_DASH_HORIZONTAL, + .top_right = TOP_RIGHT, + .top_left = TOP_LEFT, + .bottom_right = BOTTOM_RIGHT, + .bottom_left = BOTTOM_LEFT, + .vertical_left = VERTICAL_LEFT, + .vertical_right = VERTICAL_RIGHT, + .horizontal_down = HORIZONTAL_DOWN, + .horizontal_up = HORIZONTAL_UP, + .cross = CROSS, +}; + +/// Heavy triple-dashed line set. +pub const HEAVY_TRIPLE_DASHED: Set = .{ + .vertical = HEAVY_TRIPLE_DASH_VERTICAL, + .horizontal = HEAVY_TRIPLE_DASH_HORIZONTAL, + .top_right = THICK_TOP_RIGHT, + .top_left = THICK_TOP_LEFT, + .bottom_right = THICK_BOTTOM_RIGHT, + .bottom_left = THICK_BOTTOM_LEFT, + .vertical_left = THICK_VERTICAL_LEFT, + .vertical_right = THICK_VERTICAL_RIGHT, + .horizontal_down = THICK_HORIZONTAL_DOWN, + .horizontal_up = THICK_HORIZONTAL_UP, + .cross = THICK_CROSS, +}; + +/// Light quadruple-dashed line set. +pub const LIGHT_QUADRUPLE_DASHED: Set = .{ + .vertical = LIGHT_QUADRUPLE_DASH_VERTICAL, + .horizontal = LIGHT_QUADRUPLE_DASH_HORIZONTAL, + .top_right = TOP_RIGHT, + .top_left = TOP_LEFT, + .bottom_right = BOTTOM_RIGHT, + .bottom_left = BOTTOM_LEFT, + .vertical_left = VERTICAL_LEFT, + .vertical_right = VERTICAL_RIGHT, + .horizontal_down = HORIZONTAL_DOWN, + .horizontal_up = HORIZONTAL_UP, + .cross = CROSS, +}; + +/// Heavy quadruple-dashed line set. +pub const HEAVY_QUADRUPLE_DASHED: Set = .{ + .vertical = HEAVY_QUADRUPLE_DASH_VERTICAL, + .horizontal = HEAVY_QUADRUPLE_DASH_HORIZONTAL, + .top_right = THICK_TOP_RIGHT, + .top_left = THICK_TOP_LEFT, + .bottom_right = THICK_BOTTOM_RIGHT, + .bottom_left = THICK_BOTTOM_LEFT, + .vertical_left = THICK_VERTICAL_LEFT, + .vertical_right = THICK_VERTICAL_RIGHT, + .horizontal_down = THICK_HORIZONTAL_DOWN, + .horizontal_up = THICK_HORIZONTAL_UP, + .cross = THICK_CROSS, +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "line set default" { + try std.testing.expectEqualStrings(VERTICAL, Set.default.vertical); + try std.testing.expectEqualStrings(HORIZONTAL, Set.default.horizontal); +} + +test "line characters are valid UTF-8" { + // Verify all characters decode properly + _ = std.unicode.utf8Decode(VERTICAL[0..3].*) catch unreachable; + _ = std.unicode.utf8Decode(HORIZONTAL[0..3].*) catch unreachable; + _ = std.unicode.utf8Decode(TOP_LEFT[0..3].*) catch unreachable; +} diff --git a/src/symbols/marker.zig b/src/symbols/marker.zig new file mode 100644 index 0000000..08fc438 --- /dev/null +++ b/src/symbols/marker.zig @@ -0,0 +1,56 @@ +//! Markers for plotting data points in charts. + +const std = @import("std"); + +/// Dot character for plotting. +pub const DOT: []const u8 = "•"; + +/// Marker types for chart plotting. +pub const Marker = enum { + /// One point per cell in shape of dot (`•`). + dot, + + /// One point per cell in shape of a block (`█`). + block, + + /// One point per cell in shape of a bar (`▄`). + bar, + + /// Use Unicode Braille Patterns for 2x4 resolution per cell. + /// Each cell can display up to 8 dots arranged in a 2x4 grid. + braille, + + /// Use half-block characters for 1x2 resolution per cell. + /// Each cell can display 2 pixels vertically using `█`, `▀`, and `▄`. + half_block, + + /// Use quadrant characters for 2x2 resolution per cell. + quadrant, + + /// Returns the character used for this marker type (for simple markers). + pub fn char(self: Marker) []const u8 { + return switch (self) { + .dot => DOT, + .block => "█", + .bar => "▄", + .braille => "⣿", // Full braille + .half_block => "█", + .quadrant => "█", + }; + } + + pub const default: Marker = .dot; +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "marker default" { + try std.testing.expectEqual(Marker.dot, Marker.default); +} + +test "marker char" { + try std.testing.expectEqualStrings("•", Marker.dot.char()); + try std.testing.expectEqualStrings("█", Marker.block.char()); +} diff --git a/src/symbols/scrollbar.zig b/src/symbols/scrollbar.zig new file mode 100644 index 0000000..1a82d6d --- /dev/null +++ b/src/symbols/scrollbar.zig @@ -0,0 +1,80 @@ +//! Scrollbar element characters. +//! +//! Provides character sets for drawing scrollbars with different styles. + +const std = @import("std"); +const line = @import("line.zig"); +const block = @import("block.zig"); + +// ============================================================================ +// Scrollbar Set +// ============================================================================ + +/// A set of characters for drawing a scrollbar. +/// ``` +/// <--▮-------> +/// ^ ^ ^ ^ +/// │ │ │ └ end +/// │ │ └──── track +/// │ └──────── thumb +/// └─────────── begin +/// ``` +pub const Set = struct { + track: []const u8, + thumb: []const u8, + begin: []const u8, + end: []const u8, + + pub const default: Set = VERTICAL; +}; + +/// Vertical scrollbar with arrows. +/// ``` +/// ↑ +/// │ +/// █ +/// │ +/// ↓ +/// ``` +pub const VERTICAL: Set = .{ + .track = line.VERTICAL, + .thumb = block.FULL, + .begin = "↑", + .end = "↓", +}; + +/// Horizontal scrollbar with arrows. +/// ``` +/// ←───█───→ +/// ``` +pub const HORIZONTAL: Set = .{ + .track = line.HORIZONTAL, + .thumb = block.FULL, + .begin = "←", + .end = "→", +}; + +/// Vertical scrollbar with double line track. +pub const DOUBLE_VERTICAL: Set = .{ + .track = line.DOUBLE_VERTICAL, + .thumb = block.FULL, + .begin = "▲", + .end = "▼", +}; + +/// Horizontal scrollbar with double line track. +pub const DOUBLE_HORIZONTAL: Set = .{ + .track = line.DOUBLE_HORIZONTAL, + .thumb = block.FULL, + .begin = "◄", + .end = "►", +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "scrollbar set default" { + try std.testing.expectEqualStrings(line.VERTICAL, Set.default.track); + try std.testing.expectEqualStrings(block.FULL, Set.default.thumb); +} diff --git a/src/symbols/symbols.zig b/src/symbols/symbols.zig new file mode 100644 index 0000000..6099d98 --- /dev/null +++ b/src/symbols/symbols.zig @@ -0,0 +1,37 @@ +//! Symbols and markers for drawing various widgets. +//! +//! This module provides character sets for: +//! - Border drawing (line, border) +//! - Block elements (block, bar) +//! - Braille patterns +//! - Scrollbar elements +//! - Chart markers + +pub const line = @import("line.zig"); +pub const border = @import("border.zig"); +pub const block = @import("block.zig"); +pub const bar = @import("bar.zig"); +pub const braille = @import("braille.zig"); +pub const scrollbar = @import("scrollbar.zig"); +pub const half_block = @import("half_block.zig"); + +// Re-export commonly used types +pub const LineSet = line.Set; +pub const BorderSet = border.Set; +pub const BlockSet = block.Set; +pub const BarSet = bar.Set; +pub const ScrollbarSet = scrollbar.Set; +pub const Marker = @import("marker.zig").Marker; + +// Common symbols +pub const DOT: []const u8 = "•"; + +test "symbols module compiles" { + _ = line; + _ = border; + _ = block; + _ = bar; + _ = braille; + _ = scrollbar; + _ = half_block; +} diff --git a/src/text.zig b/src/text.zig new file mode 100644 index 0000000..2496455 --- /dev/null +++ b/src/text.zig @@ -0,0 +1,745 @@ +//! Primitives for styled text. +//! +//! A terminal UI is at its root a lot of strings. In order to make it accessible and stylish, +//! those strings may be associated to a set of styles. zcatui has three ways to represent them: +//! +//! - A single grapheme with its style is represented by a `StyledGrapheme`. +//! - A single line string where all graphemes share the same style is represented by a `Span`. +//! - A single line string where each grapheme may have its own style is represented by `Line`. +//! - A multiple line string where each grapheme may have its own style is represented by `Text`. +//! +//! These types form a hierarchy: `Line` is a collection of `Span`s and each line of `Text` is +//! a `Line`. + +const std = @import("std"); +const style_mod = @import("style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const Modifier = style_mod.Modifier; +const buffer_mod = @import("buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const layout_mod = @import("layout.zig"); + +// ============================================================================ +// StyledGrapheme +// ============================================================================ + +/// A grapheme associated with a style. +/// +/// This is the smallest divisible unit of styled text, used primarily for +/// rendering purposes. +pub const StyledGrapheme = struct { + /// The grapheme symbol (a slice into the original string). + symbol: []const u8, + /// The style applied to this grapheme. + style: Style, + + pub const empty: StyledGrapheme = .{ + .symbol = "", + .style = Style.default, + }; + + /// Creates a new StyledGrapheme with the given symbol and style. + pub fn init(symbol: []const u8, s: Style) StyledGrapheme { + return .{ + .symbol = symbol, + .style = s, + }; + } + + /// Returns true if this grapheme is whitespace. + pub fn isWhitespace(self: StyledGrapheme) bool { + // Check for zero-width space + if (std.mem.eql(u8, self.symbol, "\u{200b}")) return true; + + // Check for non-breaking space (not considered whitespace for wrapping) + if (std.mem.eql(u8, self.symbol, "\u{00a0}")) return false; + + // Check if all characters are whitespace + for (self.symbol) |c| { + if (!std.ascii.isWhitespace(c)) return false; + } + return self.symbol.len > 0; + } + + /// Returns the display width of this grapheme. + pub fn width(self: StyledGrapheme) usize { + return unicodeWidth(self.symbol); + } +}; + +// ============================================================================ +// Span +// ============================================================================ + +/// A contiguous string where all characters share the same style. +/// +/// A `Span` is the smallest unit of styled text. It is usually combined in a +/// `Line` to represent a line of text where each `Span` may have a different style. +/// +/// ## Example +/// +/// ```zig +/// const span = Span.styled("Hello", Style.default.fg(Color.red)); +/// ``` +pub const Span = struct { + /// The content of this span. + content: []const u8, + /// The style of this span. + style: Style, + + pub const empty: Span = .{ + .content = "", + .style = Style.default, + }; + + /// Creates a span with the default style. + pub fn raw(content: []const u8) Span { + return .{ + .content = content, + .style = Style.default, + }; + } + + /// Creates a span with the specified style. + pub fn styled(content: []const u8, s: Style) Span { + return .{ + .content = content, + .style = s, + }; + } + + /// Sets the style of the span. + pub fn setStyle(self: Span, s: Style) Span { + var span = self; + span.style = s; + return span; + } + + /// Patches the style, adding modifiers from the given style. + pub fn patchStyle(self: Span, s: Style) Span { + var span = self; + span.style = span.style.patch(s); + return span; + } + + /// Resets the style to default. + pub fn resetStyle(self: Span) Span { + return self.patchStyle(Style.reset); + } + + /// Returns the unicode display width of the content. + pub fn width(self: Span) usize { + return unicodeWidth(self.content); + } + + /// Convenience: set foreground color. + pub fn fg(self: Span, color: Color) Span { + var span = self; + span.style = span.style.fg(color); + return span; + } + + /// Convenience: set background color. + pub fn bg(self: Span, color: Color) Span { + var span = self; + span.style = span.style.bg(color); + return span; + } + + /// Convenience: set bold. + pub fn bold(self: Span) Span { + var span = self; + span.style = span.style.bold(); + return span; + } + + /// Convenience: set italic. + pub fn italic(self: Span) Span { + var span = self; + span.style = span.style.italic(); + return span; + } + + /// Convenience: set underlined. + pub fn underlined(self: Span) Span { + var span = self; + span.style = span.style.underlined(); + return span; + } + + /// Convenience: set dim. + pub fn dim(self: Span) Span { + var span = self; + span.style = span.style.dim(); + return span; + } + + /// Convenience: set reversed. + pub fn reversed(self: Span) Span { + var span = self; + span.style = span.style.reversed(); + return span; + } + + /// Renders this span to a buffer at the specified position. + /// Returns the number of cells written. + pub fn render(self: Span, area: Rect, buf: *Buffer) u16 { + if (area.isEmpty()) return 0; + + const target = area.intersection(buf.area); + if (target.isEmpty()) return 0; + + return buf.setString(target.x, target.y, self.content, self.style); + } +}; + +// ============================================================================ +// Line +// ============================================================================ + +/// Text alignment for lines and text blocks. +pub const Alignment = enum { + left, + center, + right, +}; + +/// A line of text, consisting of one or more Spans. +/// +/// Lines are used wherever text is displayed in the terminal and represent +/// a single line of text. When a Line is rendered, each Span is rendered +/// in order (left to right). +/// +/// ## Example +/// +/// ```zig +/// const line = Line.fromSpans(&.{ +/// Span.styled("Hello ", Style.default.fg(Color.blue)), +/// Span.styled("world!", Style.default.fg(Color.green)), +/// }); +/// ``` +pub const Line = struct { + /// The spans that make up this line. + spans: []const Span, + /// The style of this line (applied before span styles). + style: Style, + /// The alignment of this line. + alignment: ?Alignment, + + pub const empty: Line = .{ + .spans = &.{}, + .style = Style.default, + .alignment = null, + }; + + /// Creates a line from a single string with default style. + pub fn raw(content: []const u8) Line { + return .{ + .spans = &.{Span.raw(content)}, + .style = Style.default, + .alignment = null, + }; + } + + /// Creates a line from a single string with the given style. + pub fn styled(content: []const u8, s: Style) Line { + return .{ + .spans = &.{Span.raw(content)}, + .style = s, + .alignment = null, + }; + } + + /// Creates a line from a slice of spans. + pub fn fromSpans(spans: []const Span) Line { + return .{ + .spans = spans, + .style = Style.default, + .alignment = null, + }; + } + + /// Sets the style of this line. + pub fn setStyle(self: Line, s: Style) Line { + var line = self; + line.style = s; + return line; + } + + /// Patches the style of this line. + pub fn patchStyle(self: Line, s: Style) Line { + var line = self; + line.style = line.style.patch(s); + return line; + } + + /// Sets the alignment of this line. + pub fn setAlignment(self: Line, a: Alignment) Line { + var line = self; + line.alignment = a; + return line; + } + + /// Left-aligns this line. + pub fn leftAligned(self: Line) Line { + return self.setAlignment(.left); + } + + /// Center-aligns this line. + pub fn centered(self: Line) Line { + return self.setAlignment(.center); + } + + /// Right-aligns this line. + pub fn rightAligned(self: Line) Line { + return self.setAlignment(.right); + } + + /// Returns the unicode display width of this line. + pub fn width(self: Line) usize { + var total: usize = 0; + for (self.spans) |span| { + total += span.width(); + } + return total; + } + + /// Convenience style setters. + pub fn fg(self: Line, color: Color) Line { + var line = self; + line.style = line.style.fg(color); + return line; + } + + pub fn bg(self: Line, color: Color) Line { + var line = self; + line.style = line.style.bg(color); + return line; + } + + pub fn bold(self: Line) Line { + var line = self; + line.style = line.style.bold(); + return line; + } + + pub fn italic(self: Line) Line { + var line = self; + line.style = line.style.italic(); + return line; + } + + /// Renders this line to a buffer. + pub fn render(self: Line, area: Rect, buf: *Buffer) void { + self.renderWithAlignment(area, buf, null); + } + + /// Renders this line with an optional parent alignment. + pub fn renderWithAlignment(self: Line, area: Rect, buf: *Buffer, parent_alignment: ?Alignment) void { + const target = area.intersection(buf.area); + if (target.isEmpty()) return; + + // Only use one row + const line_area = Rect.init(target.x, target.y, target.width, 1); + const line_width = self.width(); + if (line_width == 0) return; + + // Apply line style to the area + buf.setStyle(line_area, self.style); + + const alignment = self.alignment orelse parent_alignment; + const area_width = @as(usize, line_area.width); + + if (line_width <= area_width) { + // Line fits - apply alignment + const indent: u16 = switch (alignment orelse .left) { + .center => @intCast((area_width -| line_width) / 2), + .right => @intCast(area_width -| line_width), + .left => 0, + }; + self.renderSpans(Rect.init(line_area.x +| indent, line_area.y, line_area.width -| indent, 1), buf); + } else { + // Line is wider than area - truncate based on alignment + const skip: usize = switch (alignment orelse .left) { + .center => (line_width -| area_width) / 2, + .right => line_width -| area_width, + .left => 0, + }; + self.renderSpansWithSkip(line_area, buf, skip); + } + } + + /// Renders all spans of the line. + fn renderSpans(self: Line, area: Rect, buf: *Buffer) void { + var x = area.x; + for (self.spans) |span| { + if (x >= area.right()) break; + + const remaining = area.right() -| x; + const span_area = Rect.init(x, area.y, remaining, 1); + const written = span.render(span_area, buf); + x +|= written; + } + } + + /// Renders spans with a skip offset (for truncation). + fn renderSpansWithSkip(self: Line, area: Rect, buf: *Buffer, skip_width: usize) void { + var skip_remaining = skip_width; + var x = area.x; + + for (self.spans) |span| { + if (x >= area.right()) break; + + const span_width = span.width(); + if (skip_remaining >= span_width) { + skip_remaining -= span_width; + continue; + } + + // Partial or full span + const remaining = area.right() -| x; + const span_area = Rect.init(x, area.y, remaining, 1); + + if (skip_remaining > 0) { + // Skip part of this span + const skip_bytes = widthToBytes(span.content, skip_remaining); + const partial_span = Span.styled(span.content[skip_bytes..], span.style); + const written = partial_span.render(span_area, buf); + x +|= written; + skip_remaining = 0; + } else { + const written = span.render(span_area, buf); + x +|= written; + } + } + } +}; + +// ============================================================================ +// Text +// ============================================================================ + +/// A string split over one or more lines. +/// +/// Text is used wherever multi-line text is displayed and represents one or more +/// Lines of text. When Text is rendered, each line is rendered from top to bottom. +/// +/// ## Example +/// +/// ```zig +/// const text = Text.fromLines(&.{ +/// Line.raw("First line"), +/// Line.raw("Second line"), +/// }); +/// ``` +pub const Text = struct { + /// The lines that make up this text. + lines: []const Line, + /// The style of this text (applied before line styles). + style: Style, + /// The alignment of this text. + alignment: ?Alignment, + + pub const empty: Text = .{ + .lines = &.{}, + .style = Style.default, + .alignment = null, + }; + + /// Creates text from a single string (splits on newlines). + pub fn raw(content: []const u8) Text { + _ = content; + // For compile-time safety, we return a simple single-line text + // In practice, multi-line parsing would require an allocator + return .{ + .lines = &.{}, + .style = Style.default, + .alignment = null, + }; + } + + /// Creates text from a single line. + pub fn fromLine(line: Line) Text { + return .{ + .lines = &.{line}, + .style = Style.default, + .alignment = null, + }; + } + + /// Creates text from a slice of lines. + pub fn fromLines(lines: []const Line) Text { + return .{ + .lines = lines, + .style = Style.default, + .alignment = null, + }; + } + + /// Sets the style of this text. + pub fn setStyle(self: Text, s: Style) Text { + var text = self; + text.style = s; + return text; + } + + /// Patches the style of this text. + pub fn patchStyle(self: Text, s: Style) Text { + var text = self; + text.style = text.style.patch(s); + return text; + } + + /// Sets the alignment of this text. + pub fn setAlignment(self: Text, a: Alignment) Text { + var text = self; + text.alignment = a; + return text; + } + + /// Left-aligns this text. + pub fn leftAligned(self: Text) Text { + return self.setAlignment(.left); + } + + /// Center-aligns this text. + pub fn centered(self: Text) Text { + return self.setAlignment(.center); + } + + /// Right-aligns this text. + pub fn rightAligned(self: Text) Text { + return self.setAlignment(.right); + } + + /// Returns the width (max width of all lines). + pub fn width(self: Text) usize { + var max_width: usize = 0; + for (self.lines) |line| { + const w = line.width(); + if (w > max_width) max_width = w; + } + return max_width; + } + + /// Returns the height (number of lines). + pub fn height(self: Text) usize { + return self.lines.len; + } + + /// Convenience style setters. + pub fn fg(self: Text, color: Color) Text { + var text = self; + text.style = text.style.fg(color); + return text; + } + + pub fn bg(self: Text, color: Color) Text { + var text = self; + text.style = text.style.bg(color); + return text; + } + + pub fn bold(self: Text) Text { + var text = self; + text.style = text.style.bold(); + return text; + } + + pub fn italic(self: Text) Text { + var text = self; + text.style = text.style.italic(); + return text; + } + + /// Renders this text to a buffer. + pub fn render(self: Text, area: Rect, buf: *Buffer) void { + const target = area.intersection(buf.area); + if (target.isEmpty()) return; + + // Apply text style + buf.setStyle(target, self.style); + + // Render each line + var y: u16 = 0; + for (self.lines) |line| { + if (y >= target.height) break; + + const line_area = Rect.init(target.x, target.y +| y, target.width, 1); + line.renderWithAlignment(line_area, buf, self.alignment); + y += 1; + } + } +}; + +// ============================================================================ +// Helper functions +// ============================================================================ + +/// Calculates the display width of a unicode string. +/// This is a simplified implementation - full unicode width support +/// requires handling of CJK characters, combining characters, etc. +pub fn unicodeWidth(s: []const u8) usize { + var width: usize = 0; + var iter = std.unicode.Utf8Iterator{ .bytes = s, .i = 0 }; + + while (iter.nextCodepoint()) |cp| { + // Skip control characters + if (cp < 0x20) continue; + if (cp >= 0x7F and cp < 0xA0) continue; + + // Basic width calculation + // Zero-width characters + if (cp == 0x200B or cp == 0x200C or cp == 0x200D or cp == 0xFEFF) { + continue; + } + + // Wide characters (CJK, etc.) + // This is a simplified check - full support would need unicode tables + if (isWideChar(cp)) { + width += 2; + } else { + width += 1; + } + } + + return width; +} + +/// Returns true if a codepoint is a wide character (displays as 2 cells). +fn isWideChar(cp: u21) bool { + // CJK Unified Ideographs + if (cp >= 0x4E00 and cp <= 0x9FFF) return true; + // CJK Unified Ideographs Extension A + if (cp >= 0x3400 and cp <= 0x4DBF) return true; + // CJK Compatibility Ideographs + if (cp >= 0xF900 and cp <= 0xFAFF) return true; + // Hangul Syllables + if (cp >= 0xAC00 and cp <= 0xD7AF) return true; + // Fullwidth Forms + if (cp >= 0xFF00 and cp <= 0xFFEF) return true; + // Some emoji are wide + if (cp >= 0x1F300 and cp <= 0x1F9FF) return true; + + return false; +} + +/// Converts a width (in display cells) to byte offset. +fn widthToBytes(s: []const u8, target_width: usize) usize { + var width: usize = 0; + var iter = std.unicode.Utf8Iterator{ .bytes = s, .i = 0 }; + + while (iter.nextCodepoint()) |cp| { + if (width >= target_width) break; + + const char_width: usize = if (isWideChar(cp)) 2 else 1; + width += char_width; + } + + return iter.i; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "StyledGrapheme basic" { + const sg = StyledGrapheme.init("a", Style.default.fg(Color.red)); + try std.testing.expectEqualStrings("a", sg.symbol); + try std.testing.expectEqual(Color.red, sg.style.foreground.?); +} + +test "StyledGrapheme isWhitespace" { + try std.testing.expect(StyledGrapheme.init(" ", Style.default).isWhitespace()); + try std.testing.expect(StyledGrapheme.init("\t", Style.default).isWhitespace()); + try std.testing.expect(StyledGrapheme.init("\u{200b}", Style.default).isWhitespace()); + try std.testing.expect(!StyledGrapheme.init("a", Style.default).isWhitespace()); + try std.testing.expect(!StyledGrapheme.init("\u{00a0}", Style.default).isWhitespace()); +} + +test "Span creation" { + const span1 = Span.raw("hello"); + try std.testing.expectEqualStrings("hello", span1.content); + try std.testing.expectEqual(Style.default, span1.style); + + const span2 = Span.styled("world", Style.default.fg(Color.blue)); + try std.testing.expectEqualStrings("world", span2.content); + try std.testing.expectEqual(Color.blue, span2.style.foreground.?); +} + +test "Span width" { + const span = Span.raw("hello"); + try std.testing.expectEqual(@as(usize, 5), span.width()); +} + +test "Span style methods" { + const span = Span.raw("test").fg(Color.red).bold(); + try std.testing.expectEqual(Color.red, span.style.foreground.?); + try std.testing.expect(span.style.add_modifiers.bold); +} + +test "Line creation" { + const line = Line.raw("hello world"); + try std.testing.expectEqual(@as(usize, 1), line.spans.len); + + const spans = [_]Span{ + Span.raw("hello "), + Span.styled("world", Style.default.fg(Color.green)), + }; + const line2 = Line.fromSpans(&spans); + try std.testing.expectEqual(@as(usize, 2), line2.spans.len); +} + +test "Line width" { + const spans = [_]Span{ + Span.raw("hello "), + Span.raw("world"), + }; + const line = Line.fromSpans(&spans); + try std.testing.expectEqual(@as(usize, 11), line.width()); +} + +test "Line alignment" { + const line = Line.raw("test").centered(); + try std.testing.expectEqual(Alignment.center, line.alignment.?); + + const line2 = Line.raw("test").rightAligned(); + try std.testing.expectEqual(Alignment.right, line2.alignment.?); +} + +test "Text creation" { + const lines = [_]Line{ + Line.raw("line 1"), + Line.raw("line 2"), + }; + const text = Text.fromLines(&lines); + try std.testing.expectEqual(@as(usize, 2), text.lines.len); + try std.testing.expectEqual(@as(usize, 2), text.height()); +} + +test "Text width" { + const lines = [_]Line{ + Line.raw("short"), + Line.raw("longer line"), + }; + const text = Text.fromLines(&lines); + try std.testing.expectEqual(@as(usize, 11), text.width()); +} + +test "unicode width basic" { + try std.testing.expectEqual(@as(usize, 5), unicodeWidth("hello")); + try std.testing.expectEqual(@as(usize, 0), unicodeWidth("")); +} + +test "unicode width CJK" { + // CJK characters are 2 cells wide + try std.testing.expectEqual(@as(usize, 2), unicodeWidth("中")); + try std.testing.expectEqual(@as(usize, 4), unicodeWidth("中文")); +} + +test "unicode width mixed" { + // "a中b" = 1 + 2 + 1 = 4 + try std.testing.expectEqual(@as(usize, 4), unicodeWidth("a中b")); +} diff --git a/src/widgets/barchart.zig b/src/widgets/barchart.zig new file mode 100644 index 0000000..094b193 --- /dev/null +++ b/src/widgets/barchart.zig @@ -0,0 +1,808 @@ +//! BarChart widget for displaying vertical or horizontal bar charts. +//! +//! The BarChart widget displays data as bars with optional labels and values. +//! Bars can be grouped together with group labels. +//! +//! ``` +//! ┌─────────────────────────────────┐ +//! │ ████│ +//! │ ▅▅▅▅ ████│ +//! │ ▇▇▇▇ ████ ████│ +//! │ ▄▄▄▄ ████ ████ ████ ████│ +//! │▆10▆ █20█ █50█ █40█ █60█ █90█│ +//! │ B1 B2 B1 B2 B1 B2 │ +//! │ Group1 Group2 Group3 │ +//! └─────────────────────────────────┘ +//! ``` + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const text_mod = @import("../text.zig"); +const Line = text_mod.Line; +const Span = text_mod.Span; +const Alignment = text_mod.Alignment; +const symbols = @import("../symbols/symbols.zig"); +const block_mod = @import("block.zig"); +const Block = block_mod.Block; +const layout_mod = @import("../layout.zig"); +const Direction = layout_mod.Direction; + +// ============================================================================ +// Bar +// ============================================================================ + +/// A single bar in a BarChart. +/// +/// Each bar has a value, optional label, and optional text value to display +/// instead of the numeric value. +pub const Bar = struct { + /// The numeric value of the bar. + value: u64 = 0, + /// Optional label to display under the bar (vertical) or beside it (horizontal). + label: ?Line = null, + /// Style for the bar itself. + style: Style = Style.default, + /// Style for the value displayed on the bar. + value_style: Style = Style.default, + /// Optional text to display instead of the numeric value. + text_value: ?[]const u8 = null, + + /// Creates a new Bar with the given value. + pub fn init(value: u64) Bar { + return .{ .value = value }; + } + + /// Creates a new Bar with a label and value. + pub fn withLabel(label_text: []const u8, value: u64) Bar { + return .{ + .value = value, + .label = Line.raw(label_text), + }; + } + + /// Sets the value of the bar. + pub fn setValue(self: Bar, value: u64) Bar { + var bar = self; + bar.value = value; + return bar; + } + + /// Sets the label of the bar. + pub fn setLabel(self: Bar, label: Line) Bar { + var bar = self; + bar.label = label; + return bar; + } + + /// Sets the label from a raw string. + pub fn setLabelRaw(self: Bar, label_text: []const u8) Bar { + var bar = self; + bar.label = Line.raw(label_text); + return bar; + } + + /// Sets the style of the bar. + pub fn setStyle(self: Bar, s: Style) Bar { + var bar = self; + bar.style = s; + return bar; + } + + /// Sets the style for the value text. + pub fn valueStyle(self: Bar, s: Style) Bar { + var bar = self; + bar.value_style = s; + return bar; + } + + /// Sets a custom text value to display instead of the numeric value. + pub fn textValue(self: Bar, text: []const u8) Bar { + var bar = self; + bar.text_value = text; + return bar; + } + + /// Renders the bar's label. + fn renderLabel( + self: Bar, + buf: *Buffer, + max_width: u16, + x: u16, + y: u16, + default_label_style: Style, + ) void { + if (self.label) |label| { + const label_width = @min(label.width(), max_width); + const label_x = x + (max_width -| @as(u16, @intCast(label_width))) / 2; + const area = Rect.init(label_x, y, @intCast(label_width), 1); + buf.setStyle(area, default_label_style); + label.render(area, buf); + } + } + + /// Renders the bar's value. + fn renderValue( + self: Bar, + buf: *Buffer, + max_width: u16, + x: u16, + y: u16, + default_value_style: Style, + ticks: u64, + value_buf: []u8, + ) void { + if (self.value == 0) return; + + const TICKS_PER_LINE: u64 = 8; + + const value_str = if (self.text_value) |tv| + tv + else blk: { + const written = std.fmt.bufPrint(value_buf, "{}", .{self.value}) catch return; + break :blk written; + }; + + const width: u16 = @intCast(text_mod.unicodeWidth(value_str)); + + // Only print if we have enough space or the bar is at least 1 cell tall + if (width < max_width or (width == max_width and ticks >= TICKS_PER_LINE)) { + const value_x = x + (max_width -| @as(u16, @intCast(value_str.len))) / 2; + _ = buf.setString(value_x, y, value_str, default_value_style.patch(self.value_style)); + } + } +}; + +// ============================================================================ +// BarGroup +// ============================================================================ + +/// A group of bars with an optional group label. +/// +/// Groups allow organizing multiple bars together with a common label. +pub const BarGroup = struct { + /// Optional label for the group. + label: ?Line = null, + /// The bars in this group. + bars: []const Bar = &.{}, + + /// Creates a new BarGroup with the given bars. + pub fn init(bars: []const Bar) BarGroup { + return .{ .bars = bars }; + } + + /// Creates a new BarGroup with a label and bars. + pub fn withLabel(label_text: []const u8, bars: []const Bar) BarGroup { + return .{ + .label = Line.raw(label_text), + .bars = bars, + }; + } + + /// Sets the group label. + pub fn setLabel(self: BarGroup, label: Line) BarGroup { + var group = self; + group.label = label; + return group; + } + + /// Sets the group label from a raw string. + pub fn setLabelRaw(self: BarGroup, label_text: []const u8) BarGroup { + var group = self; + group.label = Line.raw(label_text); + return group; + } + + /// Sets the bars in this group. + pub fn setBars(self: BarGroup, bars: []const Bar) BarGroup { + var group = self; + group.bars = bars; + return group; + } + + /// Returns the maximum value among all bars in the group. + pub fn max(self: BarGroup) ?u64 { + if (self.bars.len == 0) return null; + var max_val: u64 = 0; + for (self.bars) |bar| { + if (bar.value > max_val) max_val = bar.value; + } + return max_val; + } + + /// Renders the group label. + fn renderLabel(self: BarGroup, buf: *Buffer, area: Rect, default_label_style: Style) void { + if (self.label) |label| { + const width: u16 = @intCast(@min(label.width(), area.width)); + const label_area = switch (label.alignment) { + .center => Rect.init( + area.x + (area.width -| width) / 2, + area.y, + width, + 1, + ), + .right => Rect.init( + area.x + area.width -| width, + area.y, + width, + 1, + ), + else => Rect.init(area.x, area.y, width, 1), + }; + buf.setStyle(label_area, default_label_style); + label.render(label_area, buf); + } + } +}; + +// ============================================================================ +// LabelInfo (internal) +// ============================================================================ + +const LabelInfo = struct { + group_label_visible: bool, + bar_label_visible: bool, + height: u16, +}; + +// ============================================================================ +// BarChart +// ============================================================================ + +/// A chart widget that displays values as bars. +/// +/// The BarChart can render bars vertically (default) or horizontally. +/// Bars can be grouped with optional group labels. +pub const BarChart = struct { + /// Optional block to wrap the chart. + block: ?Block = null, + /// Width of each bar (height for horizontal charts). + bar_width: u16 = 1, + /// Gap between bars within a group. + bar_gap: u16 = 1, + /// Gap between groups. + group_gap: u16 = 0, + /// Symbol set for rendering bars. + bar_set: symbols.bar.Set = symbols.bar.NINE_LEVELS, + /// Default style for bars. + bar_style: Style = Style.default, + /// Default style for values. + value_style: Style = Style.default, + /// Default style for labels. + label_style: Style = Style.default, + /// Base style for the widget. + style: Style = Style.default, + /// Bar groups to display. + data: []const BarGroup = &.{}, + /// Maximum value for scaling (if null, uses max from data). + max_value: ?u64 = null, + /// Direction of the bars. + direction: Direction = .vertical, + + /// Creates a new BarChart with default settings. + pub fn init() BarChart { + return .{}; + } + + /// Creates a new vertical BarChart with the given bars. + pub fn vertical(bars: []const Bar) BarChart { + const groups = [_]BarGroup{BarGroup.init(bars)}; + return .{ + .data = &groups, + .direction = .vertical, + }; + } + + /// Creates a new horizontal BarChart with the given bars. + pub fn horizontal(bars: []const Bar) BarChart { + const groups = [_]BarGroup{BarGroup.init(bars)}; + return .{ + .data = &groups, + .direction = .horizontal, + }; + } + + /// Wraps the chart in a Block. + pub fn setBlock(self: BarChart, b: Block) BarChart { + var chart = self; + chart.block = b; + return chart; + } + + /// Sets the data (bar groups) to display. + pub fn setData(self: BarChart, groups: []const BarGroup) BarChart { + var chart = self; + chart.data = groups; + return chart; + } + + /// Sets a single group of bars. + pub fn setBars(self: BarChart, bars: []const Bar) BarChart { + var chart = self; + // Note: This creates a temporary that won't persist. + // For proper usage, create a BarGroup and use setData. + const groups = [_]BarGroup{BarGroup.init(bars)}; + chart.data = &groups; + return chart; + } + + /// Sets the maximum value for scaling bars. + pub fn setMax(self: BarChart, max_val: u64) BarChart { + var chart = self; + chart.max_value = max_val; + return chart; + } + + /// Sets the bar width. + pub fn barWidth(self: BarChart, width: u16) BarChart { + var chart = self; + chart.bar_width = width; + return chart; + } + + /// Sets the gap between bars. + pub fn barGap(self: BarChart, gap: u16) BarChart { + var chart = self; + chart.bar_gap = gap; + return chart; + } + + /// Sets the gap between groups. + pub fn groupGap(self: BarChart, gap: u16) BarChart { + var chart = self; + chart.group_gap = gap; + return chart; + } + + /// Sets the bar symbol set. + pub fn barSet(self: BarChart, bs: symbols.bar.Set) BarChart { + var chart = self; + chart.bar_set = bs; + return chart; + } + + /// Sets the default bar style. + pub fn barStyle(self: BarChart, s: Style) BarChart { + var chart = self; + chart.bar_style = s; + return chart; + } + + /// Sets the default value style. + pub fn valueStyle(self: BarChart, s: Style) BarChart { + var chart = self; + chart.value_style = s; + return chart; + } + + /// Sets the default label style. + pub fn labelStyle(self: BarChart, s: Style) BarChart { + var chart = self; + chart.label_style = s; + return chart; + } + + /// Sets the base style for the widget. + pub fn setStyle(self: BarChart, s: Style) BarChart { + var chart = self; + chart.style = s; + return chart; + } + + /// Sets the direction of the bars. + pub fn setDirection(self: BarChart, dir: Direction) BarChart { + var chart = self; + chart.direction = dir; + return chart; + } + + /// Returns the maximum data value across all groups. + fn maximumDataValue(self: BarChart) u64 { + if (self.max_value) |m| return @max(m, 1); + + var max_val: u64 = 1; + for (self.data) |group| { + if (group.max()) |gm| { + if (gm > max_val) max_val = gm; + } + } + return max_val; + } + + /// Gets label visibility information. + fn labelInfo(self: BarChart, available_height: u16) LabelInfo { + if (available_height == 0) { + return .{ + .group_label_visible = false, + .bar_label_visible = false, + .height = 0, + }; + } + + var bar_label_visible = false; + for (self.data) |group| { + for (group.bars) |bar| { + if (bar.label != null) { + bar_label_visible = true; + break; + } + } + if (bar_label_visible) break; + } + + if (available_height == 1 and bar_label_visible) { + return .{ + .group_label_visible = false, + .bar_label_visible = true, + .height = 1, + }; + } + + var group_label_visible = false; + for (self.data) |group| { + if (group.label != null) { + group_label_visible = true; + break; + } + } + + const height = @as(u16, if (group_label_visible) 1 else 0) + + @as(u16, if (bar_label_visible) 1 else 0); + + return .{ + .group_label_visible = group_label_visible, + .bar_label_visible = bar_label_visible, + .height = height, + }; + } + + /// Renders the chart to a buffer. + pub fn render(self: BarChart, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + // Render block if present + const inner = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (inner.isEmpty() or self.data.len == 0 or self.bar_width == 0) return; + + switch (self.direction) { + .horizontal => self.renderHorizontal(inner, buf), + .vertical => self.renderVertical(inner, buf), + } + } + + fn renderVertical(self: BarChart, area: Rect, buf: *Buffer) void { + const label_info_val = self.labelInfo(area.height -| 1); + + const bars_area = Rect.init( + area.x, + area.y, + area.width, + area.height -| label_info_val.height, + ); + + self.renderVerticalBars(bars_area, buf); + self.renderLabelsAndValues(area, buf, label_info_val); + } + + fn renderVerticalBars(self: BarChart, area: Rect, buf: *Buffer) void { + const max_val = self.maximumDataValue(); + var bar_x = area.left(); + + for (self.data) |group| { + for (group.bars) |bar| { + if (bar_x >= area.right()) break; + + // Calculate ticks (8 ticks per cell height) + var ticks: u64 = bar.value * @as(u64, area.height) * 8 / max_val; + + // Render from bottom to top + var j: u16 = area.height; + while (j > 0) : (j -= 1) { + const symbol = switch (ticks) { + 0 => self.bar_set.empty, + 1 => self.bar_set.one_eighth, + 2 => self.bar_set.one_quarter, + 3 => self.bar_set.three_eighths, + 4 => self.bar_set.half, + 5 => self.bar_set.five_eighths, + 6 => self.bar_set.three_quarters, + 7 => self.bar_set.seven_eighths, + else => self.bar_set.full, + }; + + const bar_style_final = self.bar_style.patch(bar.style); + + var x: u16 = 0; + while (x < self.bar_width) : (x += 1) { + if (bar_x + x < area.right()) { + _ = buf.setString(bar_x + x, area.top() + j - 1, symbol, bar_style_final); + } + } + + ticks = ticks -| 8; + } + + bar_x += self.bar_gap + self.bar_width; + } + bar_x += self.group_gap; + } + } + + fn renderLabelsAndValues(self: BarChart, area: Rect, buf: *Buffer, label_info_val: LabelInfo) void { + const max_val = self.maximumDataValue(); + var bar_x = area.left(); + const bar_y = area.bottom() -| label_info_val.height -| 1; + var value_buf: [32]u8 = undefined; + + for (self.data) |group| { + if (group.bars.len == 0) continue; + + const group_width = @as(u16, @intCast(group.bars.len)) * (self.bar_width + self.bar_gap) -| self.bar_gap; + + // Render group label + if (label_info_val.group_label_visible) { + const group_area = Rect.init( + bar_x, + area.bottom() -| 1, + group_width, + 1, + ); + group.renderLabel(buf, group_area, self.label_style); + } + + // Render bar labels and values + for (group.bars) |bar| { + if (bar_x >= area.right()) break; + + // Calculate ticks for this bar + const ticks: u64 = bar.value * @as(u64, area.height -| label_info_val.height) * 8 / max_val; + + if (label_info_val.bar_label_visible) { + bar.renderLabel(buf, self.bar_width, bar_x, bar_y + 1, self.label_style); + } + + bar.renderValue(buf, self.bar_width, bar_x, bar_y, self.value_style, ticks, &value_buf); + + bar_x += self.bar_gap + self.bar_width; + } + bar_x += self.group_gap; + } + } + + fn renderHorizontal(self: BarChart, area: Rect, buf: *Buffer) void { + // Find the longest label + var label_size: u16 = 0; + for (self.data) |group| { + for (group.bars) |bar| { + if (bar.label) |label| { + const w: u16 = @intCast(label.width()); + if (w > label_size) label_size = w; + } + } + } + + const label_x = area.x; + const margin: u16 = if (label_size != 0) 1 else 0; + const bars_area = Rect.init( + area.x + label_size + margin, + area.y, + area.width -| label_size -| margin, + area.height, + ); + + const max_val = self.maximumDataValue(); + var bar_y = bars_area.top(); + var value_buf: [32]u8 = undefined; + + for (self.data) |group| { + for (group.bars) |bar| { + if (bar_y >= bars_area.bottom()) break; + + // Calculate bar length in cells + const ticks: u64 = bar.value * @as(u64, bars_area.width) * 8 / max_val; + const bar_length: u16 = @intCast(ticks / 8); + const bar_style_final = self.bar_style.patch(bar.style); + + // Render the bar + var y: u16 = 0; + while (y < self.bar_width) : (y += 1) { + const cur_y = bar_y + y; + if (cur_y >= bars_area.bottom()) break; + + var x: u16 = 0; + while (x < bars_area.width) : (x += 1) { + const symbol = if (x < bar_length) + self.bar_set.full + else + self.bar_set.empty; + _ = buf.setString(bars_area.left() + x, cur_y, symbol, bar_style_final); + } + } + + // Render label + if (bar.label) |label| { + const label_y = bar_y + (self.bar_width >> 1); + _ = buf.setLine(label_x, label_y, label, label_size); + } + + // Render value + const value_y = bar_y + (self.bar_width >> 1); + const value_str = if (bar.text_value) |tv| + tv + else blk: { + const written = std.fmt.bufPrint(&value_buf, "{}", .{bar.value}) catch break :blk ""; + break :blk written; + }; + + if (value_str.len > 0) { + const value_style_final = self.value_style.patch(bar.value_style); + _ = buf.setString(bars_area.left(), value_y, value_str, value_style_final); + } + + bar_y += self.bar_gap + self.bar_width; + } + + // Render group label if there's a gap + if (self.group_gap > 0 and bar_y < bars_area.bottom()) { + const label_rect = Rect.init( + bars_area.x, + bar_y -| self.bar_gap, + bars_area.width, + 1, + ); + group.renderLabel(buf, label_rect, self.label_style); + bar_y += self.group_gap; + } + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Bar init" { + const bar = Bar.init(42); + try std.testing.expectEqual(@as(u64, 42), bar.value); + try std.testing.expect(bar.label == null); +} + +test "Bar withLabel" { + const bar = Bar.withLabel("Test", 100); + try std.testing.expectEqual(@as(u64, 100), bar.value); + try std.testing.expect(bar.label != null); +} + +test "Bar fluent setters" { + const bar = Bar.init(10) + .setLabelRaw("Label") + .textValue("10%") + .setStyle(Style.default.fg(Color.red)); + try std.testing.expectEqual(@as(u64, 10), bar.value); + try std.testing.expect(bar.label != null); + try std.testing.expectEqualStrings("10%", bar.text_value.?); + try std.testing.expectEqual(Color.red, bar.style.foreground.?); +} + +test "BarGroup init" { + const bars = [_]Bar{ + Bar.init(10), + Bar.init(20), + }; + const group = BarGroup.init(&bars); + try std.testing.expectEqual(@as(usize, 2), group.bars.len); + try std.testing.expect(group.label == null); +} + +test "BarGroup withLabel" { + const bars = [_]Bar{Bar.init(10)}; + const group = BarGroup.withLabel("Group1", &bars); + try std.testing.expect(group.label != null); + try std.testing.expectEqual(@as(usize, 1), group.bars.len); +} + +test "BarGroup max" { + const bars = [_]Bar{ + Bar.init(10), + Bar.init(50), + Bar.init(30), + }; + const group = BarGroup.init(&bars); + try std.testing.expectEqual(@as(u64, 50), group.max().?); +} + +test "BarGroup max empty" { + const group = BarGroup.init(&.{}); + try std.testing.expect(group.max() == null); +} + +test "BarChart init" { + const chart = BarChart.init(); + try std.testing.expectEqual(@as(u16, 1), chart.bar_width); + try std.testing.expectEqual(@as(u16, 1), chart.bar_gap); + try std.testing.expectEqual(@as(u16, 0), chart.group_gap); + try std.testing.expectEqual(Direction.vertical, chart.direction); +} + +test "BarChart fluent setters" { + const chart = BarChart.init() + .barWidth(3) + .barGap(2) + .groupGap(4) + .setDirection(.horizontal) + .setStyle(Style.default.fg(Color.blue)); + try std.testing.expectEqual(@as(u16, 3), chart.bar_width); + try std.testing.expectEqual(@as(u16, 2), chart.bar_gap); + try std.testing.expectEqual(@as(u16, 4), chart.group_gap); + try std.testing.expectEqual(Direction.horizontal, chart.direction); + try std.testing.expectEqual(Color.blue, chart.style.foreground.?); +} + +test "BarChart maximumDataValue with max set" { + const chart = BarChart.init().setMax(100); + try std.testing.expectEqual(@as(u64, 100), chart.maximumDataValue()); +} + +test "BarChart maximumDataValue from data" { + const bars = [_]Bar{ + Bar.init(10), + Bar.init(50), + Bar.init(30), + }; + const groups = [_]BarGroup{BarGroup.init(&bars)}; + const chart = BarChart.init().setData(&groups); + try std.testing.expectEqual(@as(u64, 50), chart.maximumDataValue()); +} + +test "BarChart maximumDataValue empty data" { + const chart = BarChart.init(); + try std.testing.expectEqual(@as(u64, 1), chart.maximumDataValue()); +} + +test "BarChart labelInfo empty" { + const chart = BarChart.init(); + const info = chart.labelInfo(0); + try std.testing.expect(!info.group_label_visible); + try std.testing.expect(!info.bar_label_visible); + try std.testing.expectEqual(@as(u16, 0), info.height); +} + +test "BarChart labelInfo with bar labels" { + const bars = [_]Bar{Bar.withLabel("Test", 10)}; + const groups = [_]BarGroup{BarGroup.init(&bars)}; + const chart = BarChart.init().setData(&groups); + const info = chart.labelInfo(5); + try std.testing.expect(!info.group_label_visible); + try std.testing.expect(info.bar_label_visible); + try std.testing.expectEqual(@as(u16, 1), info.height); +} + +test "BarChart labelInfo with group labels" { + const bars = [_]Bar{Bar.init(10)}; + const groups = [_]BarGroup{BarGroup.withLabel("Group", &bars)}; + const chart = BarChart.init().setData(&groups); + const info = chart.labelInfo(5); + try std.testing.expect(info.group_label_visible); + try std.testing.expect(!info.bar_label_visible); + try std.testing.expectEqual(@as(u16, 1), info.height); +} + +test "BarChart labelInfo with both labels" { + const bars = [_]Bar{Bar.withLabel("Bar", 10)}; + const groups = [_]BarGroup{BarGroup.withLabel("Group", &bars)}; + const chart = BarChart.init().setData(&groups); + const info = chart.labelInfo(5); + try std.testing.expect(info.group_label_visible); + try std.testing.expect(info.bar_label_visible); + try std.testing.expectEqual(@as(u16, 2), info.height); +} diff --git a/src/widgets/calendar.zig b/src/widgets/calendar.zig new file mode 100644 index 0000000..d7cf376 --- /dev/null +++ b/src/widgets/calendar.zig @@ -0,0 +1,434 @@ +//! Calendar widget for displaying a monthly calendar. +//! +//! The Monthly widget displays a calendar for a given month with optional +//! styling for specific dates, weekday headers, and month headers. +//! +//! ## Features +//! +//! - Display any month/year +//! - Optional weekday header (Su Mo Tu We Th Fr Sa) +//! - Optional month/year header +//! - Custom styling for specific dates +//! - Show surrounding days from adjacent months + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const text_mod = @import("../text.zig"); +const Line = text_mod.Line; +const Span = text_mod.Span; +const block_mod = @import("block.zig"); +const Block = block_mod.Block; + +// ============================================================================ +// Date (simple date representation) +// ============================================================================ + +/// A simple date representation (year, month, day). +pub const Date = struct { + year: i16, + month: u4, // 1-12 + day: u5, // 1-31 + + /// Creates a new Date. + pub fn init(year: i16, month: u4, day: u5) Date { + return .{ .year = year, .month = month, .day = day }; + } + + /// Returns true if the year is a leap year. + pub fn isLeapYear(self: Date) bool { + const y = self.year; + return (@mod(y, 4) == 0 and @mod(y, 100) != 0) or @mod(y, 400) == 0; + } + + /// Returns the number of days in the month. + pub fn daysInMonth(self: Date) u5 { + return switch (self.month) { + 1, 3, 5, 7, 8, 10, 12 => 31, + 4, 6, 9, 11 => 30, + 2 => if (self.isLeapYear()) 29 else 28, + else => 31, + }; + } + + /// Returns the day of the week (0 = Sunday, 6 = Saturday). + /// Uses Zeller's congruence. + pub fn dayOfWeek(self: Date) u3 { + var y: i32 = self.year; + var m: i32 = self.month; + + if (m < 3) { + m += 12; + y -= 1; + } + + const q: i32 = self.day; + const k: i32 = @mod(y, 100); + const j: i32 = @divFloor(y, 100); + + // Zeller's formula for Gregorian calendar + var h: i32 = q + @divFloor(13 * (m + 1), 5) + k + @divFloor(k, 4) + @divFloor(j, 4) - 2 * j; + h = @mod(h, 7); + + // Convert to Sunday = 0 + return @intCast(@mod(h + 6, 7)); + } + + /// Returns the month name. + pub fn monthName(self: Date) []const u8 { + return switch (self.month) { + 1 => "January", + 2 => "February", + 3 => "March", + 4 => "April", + 5 => "May", + 6 => "June", + 7 => "July", + 8 => "August", + 9 => "September", + 10 => "October", + 11 => "November", + 12 => "December", + else => "Unknown", + }; + } +}; + +// ============================================================================ +// CalendarEventStore +// ============================================================================ + +/// A store for calendar events with associated styles. +/// Maximum 32 events for simplicity (stack allocation). +pub const CalendarEventStore = struct { + dates: [32]Date = undefined, + styles: [32]Style = undefined, + count: usize = 0, + + pub const default_val: CalendarEventStore = .{}; + + /// Creates an empty event store. + pub fn init() CalendarEventStore { + return .{}; + } + + /// Adds a date with a style. + pub fn add(self: *CalendarEventStore, date: Date, s: Style) void { + if (self.count < 32) { + self.dates[self.count] = date; + self.styles[self.count] = s; + self.count += 1; + } + } + + /// Gets the style for a date, or default if not found. + pub fn getStyle(self: CalendarEventStore, date: Date) Style { + for (0..self.count) |i| { + const d = self.dates[i]; + if (d.year == date.year and d.month == date.month and d.day == date.day) { + return self.styles[i]; + } + } + return Style.default; + } +}; + +// ============================================================================ +// Monthly +// ============================================================================ + +/// A monthly calendar widget. +/// +/// Displays a calendar grid for a specific month with optional headers +/// and date styling. +pub const Monthly = struct { + /// The date to display (uses year and month). + display_date: Date, + /// Event store for styled dates. + events: CalendarEventStore = CalendarEventStore.default_val, + /// Style for days outside the current month. + show_surrounding: ?Style = null, + /// Style for weekday header. + show_weekday: ?Style = null, + /// Style for month/year header. + show_month: ?Style = null, + /// Default style for dates. + default_style: Style = Style.default, + /// Optional block wrapper. + block: ?Block = null, + + /// Creates a new Monthly calendar for the given date. + pub fn init(display_date: Date) Monthly { + return .{ .display_date = display_date }; + } + + /// Creates a Monthly calendar with an event store. + pub fn withEvents(display_date: Date, events: CalendarEventStore) Monthly { + return .{ .display_date = display_date, .events = events }; + } + + /// Show surrounding days from adjacent months. + pub fn showSurrounding(self: Monthly, s: Style) Monthly { + var cal = self; + cal.show_surrounding = s; + return cal; + } + + /// Show weekday header (Su Mo Tu We Th Fr Sa). + pub fn showWeekdaysHeader(self: Monthly, s: Style) Monthly { + var cal = self; + cal.show_weekday = s; + return cal; + } + + /// Show month/year header. + pub fn showMonthHeader(self: Monthly, s: Style) Monthly { + var cal = self; + cal.show_month = s; + return cal; + } + + /// Sets the default style. + pub fn setDefaultStyle(self: Monthly, s: Style) Monthly { + var cal = self; + cal.default_style = s; + return cal; + } + + /// Sets the block wrapper. + pub fn setBlock(self: Monthly, b: Block) Monthly { + var cal = self; + cal.block = b; + return cal; + } + + /// Returns the width required to render the calendar. + pub fn width(self: Monthly) u16 { + // 7 days * 3 chars each = 21 + var w: u16 = 21; + if (self.block) |b| { + w += b.horizontalSpace(); + } + return w; + } + + /// Returns the height required to render the calendar. + pub fn height(self: Monthly) u16 { + const weeks = self.weeksInMonth(); + var h: u16 = weeks; + if (self.show_month != null) h += 1; + if (self.show_weekday != null) h += 1; + if (self.block) |b| { + h += b.verticalSpace(); + } + return h; + } + + /// Calculate weeks needed for this month. + fn weeksInMonth(self: Monthly) u16 { + const first_day = Date.init(self.display_date.year, self.display_date.month, 1); + const first_weekday = first_day.dayOfWeek(); + const days_in_month = first_day.daysInMonth(); + + // Days before first of month + days in month + const total_days = first_weekday + days_in_month; + return (total_days + 6) / 7; // Ceiling division + } + + /// Renders the calendar to a buffer. + pub fn render(self: Monthly, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + // Render block if present + const inner = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (inner.isEmpty()) return; + + var y = inner.top(); + + // Month header + if (self.show_month) |month_style| { + var header_buf: [32]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "{s} {}", .{ + self.display_date.monthName(), + self.display_date.year, + }) catch ""; + + // Center the header + const header_len: u16 = @intCast(header.len); + const x = inner.left() + (inner.width -| header_len) / 2; + _ = buf.setString(x, y, header, month_style); + y += 1; + } + + // Weekday header + if (self.show_weekday) |weekday_style| { + _ = buf.setString(inner.left(), y, " Su Mo Tu We Th Fr Sa", weekday_style); + y += 1; + } + + // Calendar grid + self.renderDays(inner, buf, y); + } + + fn renderDays(self: Monthly, area: Rect, buf: *Buffer, start_y: u16) void { + const first_day = Date.init(self.display_date.year, self.display_date.month, 1); + const first_weekday = first_day.dayOfWeek(); + const days_in_month = first_day.daysInMonth(); + + // Previous month info for surrounding days + const prev_month: u4 = if (self.display_date.month == 1) 12 else self.display_date.month - 1; + const prev_year = if (self.display_date.month == 1) self.display_date.year - 1 else self.display_date.year; + const prev_month_date = Date.init(prev_year, prev_month, 1); + const prev_month_days = prev_month_date.daysInMonth(); + + var y = start_y; + var day_of_week: u3 = 0; + var day: i16 = 1 - @as(i16, first_weekday); + + while (day <= @as(i16, days_in_month)) { + if (y >= area.bottom()) break; + + var x = area.left(); + + // Render a week row + day_of_week = 0; + while (day_of_week < 7) : (day_of_week += 1) { + // Gutter space + _ = buf.setString(x, y, " ", self.default_style); + x += 1; + + var day_str: [2]u8 = undefined; + var style: Style = self.default_style; + + if (day < 1) { + // Previous month + if (self.show_surrounding) |surr_style| { + const d: u5 = @intCast(prev_month_days + @as(u5, @intCast(day))); + _ = std.fmt.bufPrint(&day_str, "{d:2}", .{d}) catch {}; + style = self.default_style.patch(surr_style); + } else { + day_str = " ".*; + } + } else if (day > @as(i16, days_in_month)) { + // Next month + if (self.show_surrounding) |surr_style| { + const d: u5 = @intCast(day - @as(i16, days_in_month)); + _ = std.fmt.bufPrint(&day_str, "{d:2}", .{d}) catch {}; + style = self.default_style.patch(surr_style); + } else { + day_str = " ".*; + } + } else { + // Current month + const d: u5 = @intCast(day); + _ = std.fmt.bufPrint(&day_str, "{d:2}", .{d}) catch {}; + const date = Date.init(self.display_date.year, self.display_date.month, d); + style = self.default_style.patch(self.events.getStyle(date)); + } + + _ = buf.setString(x, y, &day_str, style); + x += 2; + day += 1; + } + + y += 1; + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Date init" { + const date = Date.init(2024, 12, 25); + try std.testing.expectEqual(@as(i16, 2024), date.year); + try std.testing.expectEqual(@as(u4, 12), date.month); + try std.testing.expectEqual(@as(u5, 25), date.day); +} + +test "Date isLeapYear" { + try std.testing.expect(Date.init(2024, 1, 1).isLeapYear()); + try std.testing.expect(!Date.init(2023, 1, 1).isLeapYear()); + try std.testing.expect(!Date.init(1900, 1, 1).isLeapYear()); + try std.testing.expect(Date.init(2000, 1, 1).isLeapYear()); +} + +test "Date daysInMonth" { + try std.testing.expectEqual(@as(u5, 31), Date.init(2024, 1, 1).daysInMonth()); + try std.testing.expectEqual(@as(u5, 29), Date.init(2024, 2, 1).daysInMonth()); + try std.testing.expectEqual(@as(u5, 28), Date.init(2023, 2, 1).daysInMonth()); + try std.testing.expectEqual(@as(u5, 30), Date.init(2024, 4, 1).daysInMonth()); +} + +test "Date dayOfWeek" { + // Known dates for testing + // 2024-01-01 is Monday (1) + try std.testing.expectEqual(@as(u3, 1), Date.init(2024, 1, 1).dayOfWeek()); + // 2024-12-25 is Wednesday (3) + try std.testing.expectEqual(@as(u3, 3), Date.init(2024, 12, 25).dayOfWeek()); + // 2023-01-01 is Sunday (0) + try std.testing.expectEqual(@as(u3, 0), Date.init(2023, 1, 1).dayOfWeek()); +} + +test "Date monthName" { + try std.testing.expectEqualStrings("January", Date.init(2024, 1, 1).monthName()); + try std.testing.expectEqualStrings("December", Date.init(2024, 12, 1).monthName()); +} + +test "CalendarEventStore" { + var store = CalendarEventStore.init(); + const date = Date.init(2024, 12, 25); + store.add(date, Style.default.fg(Color.red)); + + const style = store.getStyle(date); + try std.testing.expectEqual(Color.red, style.foreground.?); + + // Unknown date should return default + const other_style = store.getStyle(Date.init(2024, 1, 1)); + try std.testing.expect(other_style.foreground == null); +} + +test "Monthly init" { + const cal = Monthly.init(Date.init(2024, 12, 1)); + try std.testing.expectEqual(@as(i16, 2024), cal.display_date.year); + try std.testing.expectEqual(@as(u4, 12), cal.display_date.month); +} + +test "Monthly width" { + const cal = Monthly.init(Date.init(2024, 1, 1)); + try std.testing.expectEqual(@as(u16, 21), cal.width()); +} + +test "Monthly height" { + // February 2015 starts on Sunday and spans 4 weeks + const cal = Monthly.init(Date.init(2015, 2, 1)); + try std.testing.expectEqual(@as(u16, 4), cal.height()); + + // With headers + const cal_with_headers = Monthly.init(Date.init(2015, 2, 1)) + .showMonthHeader(Style.default) + .showWeekdaysHeader(Style.default); + try std.testing.expectEqual(@as(u16, 6), cal_with_headers.height()); +} + +test "Monthly setters" { + const cal = Monthly.init(Date.init(2024, 1, 1)) + .showMonthHeader(Style.default.fg(Color.blue)) + .showWeekdaysHeader(Style.default.fg(Color.green)) + .showSurrounding(Style.default.fg(Color.white)) + .setDefaultStyle(Style.default.fg(Color.yellow)); + + try std.testing.expect(cal.show_month != null); + try std.testing.expect(cal.show_weekday != null); + try std.testing.expect(cal.show_surrounding != null); + try std.testing.expectEqual(Color.yellow, cal.default_style.foreground.?); +} diff --git a/src/widgets/canvas.zig b/src/widgets/canvas.zig new file mode 100644 index 0000000..a8ff856 --- /dev/null +++ b/src/widgets/canvas.zig @@ -0,0 +1,637 @@ +//! Canvas widget for drawing arbitrary shapes. +//! +//! The Canvas widget provides a drawing surface where you can render shapes +//! like lines, points, rectangles, and circles using various markers including +//! braille patterns for high-resolution rendering. +//! +//! ## Shapes +//! +//! - `Points`: A collection of (x, y) coordinates +//! - `Line`: A line between two points +//! - `Rectangle`: A rectangle defined by position and size +//! - `Circle`: A circle defined by center and radius +//! +//! ## Example +//! +//! ```zig +//! const canvas = Canvas.init() +//! .xBounds(0.0, 100.0) +//! .yBounds(0.0, 100.0) +//! .marker(.braille); +//! +//! // In paint callback: +//! ctx.drawLine(0.0, 0.0, 100.0, 100.0, Color.red); +//! ``` + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const symbols = @import("../symbols/symbols.zig"); +const block_mod = @import("block.zig"); +const Block = block_mod.Block; + +// ============================================================================ +// Marker +// ============================================================================ + +/// Marker types for canvas drawing. +pub const Marker = enum { + /// Use dots (•) - 1x1 resolution per cell. + dot, + /// Use block characters (█) - 1x1 resolution per cell. + block, + /// Use bar characters (▄) - 1x1 resolution per cell. + bar, + /// Use braille patterns - 2x4 resolution per cell. + braille, + /// Use half blocks (▀▄) - 1x2 resolution per cell. + half_block, + + pub const default: Marker = .braille; +}; + +// ============================================================================ +// Grid (internal pixel storage) +// ============================================================================ + +/// Internal grid for storing painted pixels. +const Grid = struct { + width: u16, + height: u16, + marker: Marker, + // For braille: store pattern bits per cell + // For others: store color per cell + cells: []u8, + colors: []?Color, + allocator: std.mem.Allocator, + + fn init(allocator: std.mem.Allocator, width: u16, height: u16, marker: Marker) !Grid { + const len = @as(usize, width) * @as(usize, height); + const cells = try allocator.alloc(u8, len); + @memset(cells, 0); + const colors = try allocator.alloc(?Color, len); + @memset(colors, null); + return .{ + .width = width, + .height = height, + .marker = marker, + .cells = cells, + .colors = colors, + .allocator = allocator, + }; + } + + fn deinit(self: *Grid) void { + self.allocator.free(self.cells); + self.allocator.free(self.colors); + } + + /// Get resolution in dots (x, y). + fn resolution(self: Grid) struct { f64, f64 } { + return switch (self.marker) { + .braille => .{ + @as(f64, @floatFromInt(self.width)) * 2.0, + @as(f64, @floatFromInt(self.height)) * 4.0, + }, + .half_block => .{ + @as(f64, @floatFromInt(self.width)), + @as(f64, @floatFromInt(self.height)) * 2.0, + }, + else => .{ + @as(f64, @floatFromInt(self.width)), + @as(f64, @floatFromInt(self.height)), + }, + }; + } + + /// Paint a dot at (x, y) in grid coordinates. + fn paint(self: *Grid, x: usize, y: usize, color: Color) void { + switch (self.marker) { + .braille => { + // Braille: 2x4 dots per cell + const cell_x = x / 2; + const cell_y = y / 4; + if (cell_x >= self.width or cell_y >= self.height) return; + + const idx = cell_y * @as(usize, self.width) + cell_x; + const dot_x = x % 2; + const dot_y = y % 4; + + // Braille bit pattern: + // 0 3 + // 1 4 + // 2 5 + // 6 7 + const bit: u8 = switch (dot_y) { + 0 => if (dot_x == 0) 0 else 3, + 1 => if (dot_x == 0) 1 else 4, + 2 => if (dot_x == 0) 2 else 5, + 3 => if (dot_x == 0) 6 else 7, + else => 0, + }; + + self.cells[idx] |= @as(u8, 1) << @intCast(bit); + self.colors[idx] = color; + }, + .half_block => { + // Half block: 1x2 dots per cell + const cell_x = x; + const cell_y = y / 2; + if (cell_x >= self.width or cell_y >= self.height) return; + + const idx = cell_y * @as(usize, self.width) + cell_x; + const half = y % 2; + + // bit 0 = upper, bit 1 = lower + self.cells[idx] |= @as(u8, 1) << @intCast(half); + self.colors[idx] = color; + }, + else => { + // 1x1 resolution + if (x >= self.width or y >= self.height) return; + const idx = y * @as(usize, self.width) + x; + self.cells[idx] = 1; + self.colors[idx] = color; + }, + } + } + + /// Get symbol and style for a cell. + fn getCell(self: Grid, cell_x: u16, cell_y: u16) struct { symbol: []const u8, style: Style } { + const idx = @as(usize, cell_y) * @as(usize, self.width) + @as(usize, cell_x); + if (idx >= self.cells.len) return .{ .symbol = " ", .style = Style.default }; + + const pattern = self.cells[idx]; + const color = self.colors[idx]; + + if (pattern == 0) { + return .{ .symbol = " ", .style = Style.default }; + } + + const base_style = if (color) |c| Style.default.fg(c) else Style.default; + + return switch (self.marker) { + .braille => .{ + .symbol = symbols.braille.fromPattern(pattern), + .style = base_style, + }, + .half_block => .{ + .symbol = switch (pattern) { + 0b01 => symbols.half_block.UPPER, + 0b10 => symbols.half_block.LOWER, + else => symbols.half_block.FULL, + }, + .style = base_style, + }, + .dot => .{ + .symbol = symbols.DOT, + .style = base_style, + }, + .block => .{ + .symbol = symbols.block.FULL, + .style = base_style, + }, + .bar => .{ + .symbol = symbols.bar.NINE_LEVELS.full, + .style = base_style, + }, + }; + } +}; + +// ============================================================================ +// Painter +// ============================================================================ + +/// Painter provides methods for drawing on the canvas. +/// It handles coordinate transformation from canvas space to grid space. +pub const Painter = struct { + grid: *Grid, + x_bounds: [2]f64, + y_bounds: [2]f64, + res_x: f64, + res_y: f64, + + /// Convert canvas (x, y) coordinates to grid coordinates. + pub fn getPoint(self: Painter, x: f64, y: f64) ?struct { x: usize, y: usize } { + const left = self.x_bounds[0]; + const right = self.x_bounds[1]; + const bottom = self.y_bounds[0]; + const top = self.y_bounds[1]; + + if (x < left or x > right or y < bottom or y > top) { + return null; + } + + const width = right - left; + const height = top - bottom; + + if (width <= 0.0 or height <= 0.0) { + return null; + } + + const gx: usize = @intFromFloat(@round((x - left) * (self.res_x - 1.0) / width)); + const gy: usize = @intFromFloat(@round((top - y) * (self.res_y - 1.0) / height)); + + return .{ .x = gx, .y = gy }; + } + + /// Paint a point at the given grid coordinates. + pub fn paint(self: *Painter, x: usize, y: usize, color: Color) void { + self.grid.paint(x, y, color); + } + + /// Draw a line from (x1, y1) to (x2, y2). + pub fn drawLine(self: *Painter, x1: f64, y1: f64, x2: f64, y2: f64, color: Color) void { + const p1 = self.getPoint(x1, y1) orelse return; + const p2 = self.getPoint(x2, y2) orelse return; + + self.drawLineGrid(p1.x, p1.y, p2.x, p2.y, color); + } + + fn drawLineGrid(self: *Painter, gx1: usize, gy1: usize, gx2: usize, gy2: usize, color: Color) void { + // Bresenham's line algorithm + const dx: isize = @as(isize, @intCast(gx2)) - @as(isize, @intCast(gx1)); + const dy: isize = @as(isize, @intCast(gy2)) - @as(isize, @intCast(gy1)); + + const steps = @max(@abs(dx), @abs(dy)); + if (steps == 0) { + self.paint(gx1, gy1, color); + return; + } + + const x_inc: f64 = @as(f64, @floatFromInt(dx)) / @as(f64, @floatFromInt(steps)); + const y_inc: f64 = @as(f64, @floatFromInt(dy)) / @as(f64, @floatFromInt(steps)); + + var x: f64 = @floatFromInt(gx1); + var y: f64 = @floatFromInt(gy1); + + var i: usize = 0; + while (i <= steps) : (i += 1) { + const px: usize = @intFromFloat(@round(x)); + const py: usize = @intFromFloat(@round(y)); + self.paint(px, py, color); + x += x_inc; + y += y_inc; + } + } + + /// Draw points at the given coordinates. + pub fn drawPoints(self: *Painter, coords: []const [2]f64, color: Color) void { + for (coords) |coord| { + if (self.getPoint(coord[0], coord[1])) |p| { + self.paint(p.x, p.y, color); + } + } + } + + /// Draw a rectangle outline. + pub fn drawRectangle(self: *Painter, x: f64, y: f64, width: f64, height: f64, color: Color) void { + // Top + self.drawLine(x, y + height, x + width, y + height, color); + // Bottom + self.drawLine(x, y, x + width, y, color); + // Left + self.drawLine(x, y, x, y + height, color); + // Right + self.drawLine(x + width, y, x + width, y + height, color); + } + + /// Draw a circle outline. + pub fn drawCircle(self: *Painter, center_x: f64, center_y: f64, radius: f64, color: Color) void { + // Use parametric equation: x = cx + r*cos(t), y = cy + r*sin(t) + const segments: usize = @max(16, @as(usize, @intFromFloat(radius * 4))); + + var i: usize = 0; + while (i < segments) : (i += 1) { + const t1 = @as(f64, @floatFromInt(i)) * 2.0 * std.math.pi / @as(f64, @floatFromInt(segments)); + const t2 = @as(f64, @floatFromInt(i + 1)) * 2.0 * std.math.pi / @as(f64, @floatFromInt(segments)); + + const x1 = center_x + radius * @cos(t1); + const y1 = center_y + radius * @sin(t1); + const x2 = center_x + radius * @cos(t2); + const y2 = center_y + radius * @sin(t2); + + self.drawLine(x1, y1, x2, y2, color); + } + } +}; + +// ============================================================================ +// Canvas +// ============================================================================ + +/// A canvas widget for drawing arbitrary shapes. +/// +/// The canvas provides a coordinate system where you can draw lines, points, +/// rectangles, and circles. It supports various marker types including braille +/// patterns for higher resolution. +pub const Canvas = struct { + /// Optional block wrapper. + block: ?Block = null, + /// X axis bounds [min, max]. + x_bounds: [2]f64 = .{ 0.0, 1.0 }, + /// Y axis bounds [min, max]. + y_bounds: [2]f64 = .{ 0.0, 1.0 }, + /// Marker type for rendering. + marker: Marker = .braille, + /// Background color. + background_color: Color = Color.reset, + /// Base style. + style: Style = Style.default, + + /// Creates a new Canvas with default settings. + pub fn init() Canvas { + return .{}; + } + + /// Sets the block wrapper. + pub fn setBlock(self: Canvas, b: Block) Canvas { + var canvas = self; + canvas.block = b; + return canvas; + } + + /// Sets the X axis bounds. + pub fn xBounds(self: Canvas, min: f64, max: f64) Canvas { + var canvas = self; + canvas.x_bounds = .{ min, max }; + return canvas; + } + + /// Sets the Y axis bounds. + pub fn yBounds(self: Canvas, min: f64, max: f64) Canvas { + var canvas = self; + canvas.y_bounds = .{ min, max }; + return canvas; + } + + /// Sets the marker type. + pub fn setMarker(self: Canvas, m: Marker) Canvas { + var canvas = self; + canvas.marker = m; + return canvas; + } + + /// Sets the background color. + pub fn backgroundColor(self: Canvas, color: Color) Canvas { + var canvas = self; + canvas.background_color = color; + return canvas; + } + + /// Sets the base style. + pub fn setStyle(self: Canvas, s: Style) Canvas { + var canvas = self; + canvas.style = s; + return canvas; + } + + /// Render the canvas with a paint callback. + /// The callback receives a Painter that can be used to draw shapes. + pub fn renderWithPainter( + self: Canvas, + area: Rect, + buf: *Buffer, + allocator: std.mem.Allocator, + paint_fn: *const fn (*Painter) void, + ) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + // Render block if present + const canvas_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (canvas_area.isEmpty()) return; + + // Set background + if (self.background_color != Color.reset) { + buf.setStyle(canvas_area, Style.default.bg(self.background_color)); + } + + // Create grid + var grid = Grid.init(allocator, canvas_area.width, canvas_area.height, self.marker) catch return; + defer grid.deinit(); + + const res = grid.resolution(); + var painter = Painter{ + .grid = &grid, + .x_bounds = self.x_bounds, + .y_bounds = self.y_bounds, + .res_x = res[0], + .res_y = res[1], + }; + + // Call paint function + paint_fn(&painter); + + // Render grid to buffer + var y: u16 = 0; + while (y < canvas_area.height) : (y += 1) { + var x: u16 = 0; + while (x < canvas_area.width) : (x += 1) { + const cell = grid.getCell(x, y); + if (cell.symbol.len > 0 and cell.symbol[0] != ' ') { + _ = buf.setString(canvas_area.x + x, canvas_area.y + y, cell.symbol, cell.style); + } + } + } + } + + /// Simple render that draws nothing (for widget compatibility). + pub fn render(self: Canvas, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + if (self.block) |b| { + b.render(area, buf); + } + + // Without a paint function, we just render the empty canvas with background + const canvas_area = if (self.block) |b| b.inner(area) else area; + + if (self.background_color != Color.reset) { + buf.setStyle(canvas_area, Style.default.bg(self.background_color)); + } + } +}; + +// ============================================================================ +// Shape Types (for convenience) +// ============================================================================ + +/// A line shape from (x1, y1) to (x2, y2). +pub const Line = struct { + x1: f64, + y1: f64, + x2: f64, + y2: f64, + color: Color, + + pub fn init(x1: f64, y1: f64, x2: f64, y2: f64, color: Color) Line { + return .{ .x1 = x1, .y1 = y1, .x2 = x2, .y2 = y2, .color = color }; + } + + pub fn draw(self: Line, painter: *Painter) void { + painter.drawLine(self.x1, self.y1, self.x2, self.y2, self.color); + } +}; + +/// A group of points. +pub const Points = struct { + coords: []const [2]f64, + color: Color, + + pub fn init(coords: []const [2]f64, color: Color) Points { + return .{ .coords = coords, .color = color }; + } + + pub fn draw(self: Points, painter: *Painter) void { + painter.drawPoints(self.coords, self.color); + } +}; + +/// A rectangle shape. +pub const Rectangle = struct { + x: f64, + y: f64, + width: f64, + height: f64, + color: Color, + + pub fn init(x: f64, y: f64, width: f64, height: f64, color: Color) Rectangle { + return .{ .x = x, .y = y, .width = width, .height = height, .color = color }; + } + + pub fn draw(self: Rectangle, painter: *Painter) void { + painter.drawRectangle(self.x, self.y, self.width, self.height, self.color); + } +}; + +/// A circle shape. +pub const Circle = struct { + x: f64, + y: f64, + radius: f64, + color: Color, + + pub fn init(x: f64, y: f64, radius: f64, color: Color) Circle { + return .{ .x = x, .y = y, .radius = radius, .color = color }; + } + + pub fn draw(self: Circle, painter: *Painter) void { + painter.drawCircle(self.x, self.y, self.radius, self.color); + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Canvas init" { + const canvas = Canvas.init(); + try std.testing.expectEqual(@as(f64, 0.0), canvas.x_bounds[0]); + try std.testing.expectEqual(@as(f64, 1.0), canvas.x_bounds[1]); + try std.testing.expectEqual(Marker.braille, canvas.marker); +} + +test "Canvas setters" { + const canvas = Canvas.init() + .xBounds(-10.0, 10.0) + .yBounds(0.0, 100.0) + .setMarker(.dot) + .backgroundColor(Color.black); + try std.testing.expectEqual(@as(f64, -10.0), canvas.x_bounds[0]); + try std.testing.expectEqual(@as(f64, 10.0), canvas.x_bounds[1]); + try std.testing.expectEqual(@as(f64, 0.0), canvas.y_bounds[0]); + try std.testing.expectEqual(@as(f64, 100.0), canvas.y_bounds[1]); + try std.testing.expectEqual(Marker.dot, canvas.marker); + try std.testing.expectEqual(Color.black, canvas.background_color); +} + +test "Line init" { + const line = Line.init(0.0, 0.0, 10.0, 10.0, Color.red); + try std.testing.expectEqual(@as(f64, 0.0), line.x1); + try std.testing.expectEqual(@as(f64, 10.0), line.x2); + try std.testing.expectEqual(Color.red, line.color); +} + +test "Points init" { + const coords = [_][2]f64{ .{ 1.0, 2.0 }, .{ 3.0, 4.0 } }; + const points = Points.init(&coords, Color.blue); + try std.testing.expectEqual(@as(usize, 2), points.coords.len); + try std.testing.expectEqual(Color.blue, points.color); +} + +test "Rectangle init" { + const rect = Rectangle.init(0.0, 0.0, 10.0, 5.0, Color.green); + try std.testing.expectEqual(@as(f64, 10.0), rect.width); + try std.testing.expectEqual(@as(f64, 5.0), rect.height); + try std.testing.expectEqual(Color.green, rect.color); +} + +test "Circle init" { + const circle = Circle.init(5.0, 5.0, 3.0, Color.yellow); + try std.testing.expectEqual(@as(f64, 5.0), circle.x); + try std.testing.expectEqual(@as(f64, 5.0), circle.y); + try std.testing.expectEqual(@as(f64, 3.0), circle.radius); + try std.testing.expectEqual(Color.yellow, circle.color); +} + +test "Marker default" { + try std.testing.expectEqual(Marker.braille, Marker.default); +} + +test "Grid resolution braille" { + const allocator = std.testing.allocator; + var grid = try Grid.init(allocator, 10, 5, .braille); + defer grid.deinit(); + + const res = grid.resolution(); + try std.testing.expectEqual(@as(f64, 20.0), res[0]); // 10 * 2 + try std.testing.expectEqual(@as(f64, 20.0), res[1]); // 5 * 4 +} + +test "Grid resolution dot" { + const allocator = std.testing.allocator; + var grid = try Grid.init(allocator, 10, 5, .dot); + defer grid.deinit(); + + const res = grid.resolution(); + try std.testing.expectEqual(@as(f64, 10.0), res[0]); + try std.testing.expectEqual(@as(f64, 5.0), res[1]); +} + +test "Painter getPoint" { + const allocator = std.testing.allocator; + var grid = try Grid.init(allocator, 10, 10, .dot); + defer grid.deinit(); + + const res = grid.resolution(); + var painter = Painter{ + .grid = &grid, + .x_bounds = .{ 0.0, 10.0 }, + .y_bounds = .{ 0.0, 10.0 }, + .res_x = res[0], + .res_y = res[1], + }; + + // Origin (bottom-left in canvas = top-left inverted) + const p1 = painter.getPoint(0.0, 10.0); + try std.testing.expect(p1 != null); + try std.testing.expectEqual(@as(usize, 0), p1.?.x); + try std.testing.expectEqual(@as(usize, 0), p1.?.y); + + // Out of bounds + const p2 = painter.getPoint(-1.0, 5.0); + try std.testing.expect(p2 == null); +} diff --git a/src/widgets/chart.zig b/src/widgets/chart.zig new file mode 100644 index 0000000..7da966a --- /dev/null +++ b/src/widgets/chart.zig @@ -0,0 +1,711 @@ +//! Chart widget for plotting datasets in a cartesian coordinate system. +//! +//! The Chart widget displays one or more datasets with X and Y axes, +//! optional labels, and a legend. +//! +//! ## Features +//! +//! - Multiple datasets with different styles +//! - Configurable X and Y axes with labels and bounds +//! - Scatter, line, and bar graph types +//! - Legend display +//! - Uses Canvas internally for high-resolution rendering + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const text_mod = @import("../text.zig"); +const Line = text_mod.Line; +const Alignment = text_mod.Alignment; +const symbols = @import("../symbols/symbols.zig"); +const block_mod = @import("block.zig"); +const Block = block_mod.Block; +const canvas_mod = @import("canvas.zig"); +const Canvas = canvas_mod.Canvas; +const Painter = canvas_mod.Painter; + +// ============================================================================ +// GraphType +// ============================================================================ + +/// Determines how data points are rendered. +pub const GraphType = enum { + /// Draw each point individually. + scatter, + /// Draw lines between consecutive points. + line, + /// Draw vertical bars from the X axis to each point. + bar, + + pub const default: GraphType = .scatter; +}; + +// ============================================================================ +// LegendPosition +// ============================================================================ + +/// Position of the legend in the chart. +pub const LegendPosition = enum { + top, + top_right, + top_left, + left, + right, + bottom, + bottom_right, + bottom_left, + + pub const default: LegendPosition = .top_right; +}; + +// ============================================================================ +// Axis +// ============================================================================ + +/// An axis (X or Y) for the chart. +/// +/// Contains the title, bounds, labels, and style for the axis. +pub const Axis = struct { + /// Title displayed at the end of the axis. + title: ?Line = null, + /// Min and max values for the axis. + bounds: [2]f64 = .{ 0.0, 1.0 }, + /// Labels to display along the axis. + labels: []const Line = &.{}, + /// Style for the axis line and labels. + style: Style = Style.default, + /// Alignment for labels. + labels_alignment: Alignment = .left, + + pub const default_val: Axis = .{}; + + /// Creates a new Axis with default settings. + pub fn init() Axis { + return .{}; + } + + /// Sets the title. + pub fn setTitle(self: Axis, t: Line) Axis { + var axis = self; + axis.title = t; + return axis; + } + + /// Sets the title from a raw string. + pub fn setTitleRaw(self: Axis, t: []const u8) Axis { + var axis = self; + axis.title = Line.raw(t); + return axis; + } + + /// Sets the bounds. + pub fn setBounds(self: Axis, min: f64, max: f64) Axis { + var axis = self; + axis.bounds = .{ min, max }; + return axis; + } + + /// Sets the labels. + pub fn setLabels(self: Axis, l: []const Line) Axis { + var axis = self; + axis.labels = l; + return axis; + } + + /// Sets the style. + pub fn setStyle(self: Axis, s: Style) Axis { + var axis = self; + axis.style = s; + return axis; + } + + /// Sets the labels alignment. + pub fn setLabelsAlignment(self: Axis, a: Alignment) Axis { + var axis = self; + axis.labels_alignment = a; + return axis; + } +}; + +// ============================================================================ +// Dataset +// ============================================================================ + +/// A dataset to be plotted on the chart. +/// +/// Contains the data points, name, style, marker, and graph type. +pub const Dataset = struct { + /// Name displayed in the legend. + name: ?Line = null, + /// Data points as (x, y) tuples. + data: []const [2]f64 = &.{}, + /// Marker type for rendering. + marker: canvas_mod.Marker = .braille, + /// Graph type (scatter, line, bar). + graph_type: GraphType = .scatter, + /// Style for the dataset. + style: Style = Style.default, + + /// Creates a new Dataset. + pub fn init() Dataset { + return .{}; + } + + /// Sets the name. + pub fn setName(self: Dataset, n: Line) Dataset { + var ds = self; + ds.name = n; + return ds; + } + + /// Sets the name from a raw string. + pub fn setNameRaw(self: Dataset, n: []const u8) Dataset { + var ds = self; + ds.name = Line.raw(n); + return ds; + } + + /// Sets the data points. + pub fn setData(self: Dataset, d: []const [2]f64) Dataset { + var ds = self; + ds.data = d; + return ds; + } + + /// Sets the marker type. + pub fn setMarker(self: Dataset, m: canvas_mod.Marker) Dataset { + var ds = self; + ds.marker = m; + return ds; + } + + /// Sets the graph type. + pub fn setGraphType(self: Dataset, g: GraphType) Dataset { + var ds = self; + ds.graph_type = g; + return ds; + } + + /// Sets the style. + pub fn setStyle(self: Dataset, s: Style) Dataset { + var ds = self; + ds.style = s; + return ds; + } +}; + +// ============================================================================ +// Chart +// ============================================================================ + +/// A chart widget for plotting data. +/// +/// Displays one or more datasets in a cartesian coordinate system with +/// configurable axes and legend. +pub const Chart = struct { + /// Optional block wrapper. + block: ?Block = null, + /// X axis configuration. + x_axis: Axis = Axis.default_val, + /// Y axis configuration. + y_axis: Axis = Axis.default_val, + /// Datasets to plot. + datasets: []const Dataset = &.{}, + /// Base style for the chart. + style: Style = Style.default, + /// Legend position (null to hide). + legend_position: ?LegendPosition = .top_right, + + /// Creates a new Chart with default settings. + pub fn init() Chart { + return .{}; + } + + /// Creates a Chart with the given datasets. + pub fn create(datasets: []const Dataset) Chart { + return .{ .datasets = datasets }; + } + + /// Sets the block wrapper. + pub fn setBlock(self: Chart, b: Block) Chart { + var chart = self; + chart.block = b; + return chart; + } + + /// Sets the X axis. + pub fn xAxis(self: Chart, axis: Axis) Chart { + var chart = self; + chart.x_axis = axis; + return chart; + } + + /// Sets the Y axis. + pub fn yAxis(self: Chart, axis: Axis) Chart { + var chart = self; + chart.y_axis = axis; + return chart; + } + + /// Sets the datasets. + pub fn setDatasets(self: Chart, ds: []const Dataset) Chart { + var chart = self; + chart.datasets = ds; + return chart; + } + + /// Sets the base style. + pub fn setStyle(self: Chart, s: Style) Chart { + var chart = self; + chart.style = s; + return chart; + } + + /// Sets the legend position. + pub fn legendPosition(self: Chart, pos: ?LegendPosition) Chart { + var chart = self; + chart.legend_position = pos; + return chart; + } + + /// Renders the chart to a buffer. + pub fn render(self: Chart, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + // Render block if present + const chart_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (chart_area.isEmpty()) return; + + // Calculate layout + const layout = self.calculateLayout(chart_area); + + // Render axis labels + self.renderXLabels(chart_area, buf, layout); + self.renderYLabels(chart_area, buf, layout); + + // Render axis lines + self.renderAxes(buf, layout); + + // Render graph area with data + self.renderGraph(buf, layout); + + // Render legend + if (self.legend_position != null) { + self.renderLegend(chart_area, buf, layout); + } + + // Render axis titles + self.renderAxisTitles(buf, layout); + } + + const ChartLayout = struct { + graph_area: Rect, + label_x_y: ?u16, + label_y_x: ?u16, + axis_x_y: ?u16, + axis_y_x: ?u16, + }; + + fn calculateLayout(self: Chart, area: Rect) ChartLayout { + var x = area.left(); + var y = area.bottom() -| 1; + + // Space for X axis labels + var label_x_y: ?u16 = null; + if (self.x_axis.labels.len > 0 and y > area.top()) { + label_x_y = y; + y -|= 1; + } + + // Space for Y axis labels + var label_y_x: ?u16 = null; + if (self.y_axis.labels.len > 0) { + label_y_x = x; + // Find max label width + var max_width: u16 = 0; + for (self.y_axis.labels) |label| { + const w: u16 = @intCast(label.width()); + if (w > max_width) max_width = w; + } + x += @min(max_width + 1, area.width / 3); + } + + // Space for X axis line + var axis_x_y: ?u16 = null; + if (self.x_axis.labels.len > 0 and y > area.top()) { + axis_x_y = y; + y -|= 1; + } + + // Space for Y axis line + var axis_y_x: ?u16 = null; + if (self.y_axis.labels.len > 0 and x + 1 < area.right()) { + axis_y_x = x; + x += 1; + } + + const graph_width = area.right() -| x; + const graph_height = (y -| area.top()) +| 1; + + return .{ + .graph_area = Rect.init(x, area.top(), graph_width, graph_height), + .label_x_y = label_x_y, + .label_y_x = label_y_x, + .axis_x_y = axis_x_y, + .axis_y_x = axis_y_x, + }; + } + + fn renderXLabels(self: Chart, area: Rect, buf: *Buffer, layout: ChartLayout) void { + const y = layout.label_x_y orelse return; + if (self.x_axis.labels.len < 2) return; + + const graph_area = layout.graph_area; + const labels_len: u16 = @intCast(self.x_axis.labels.len); + const width_between = graph_area.width / labels_len; + + // First label + if (self.x_axis.labels.len > 0) { + const label = self.x_axis.labels[0]; + const label_width = @min(@as(u16, @intCast(label.width())), width_between); + const label_area = Rect.init(area.left(), y, label_width, 1); + label.render(label_area, buf); + } + + // Last label (right-aligned) + if (self.x_axis.labels.len > 1) { + const label = self.x_axis.labels[self.x_axis.labels.len - 1]; + const label_width: u16 = @intCast(label.width()); + const label_x = graph_area.right() -| label_width; + const label_area = Rect.init(label_x, y, label_width, 1); + label.render(label_area, buf); + } + } + + fn renderYLabels(self: Chart, area: Rect, buf: *Buffer, layout: ChartLayout) void { + const x = layout.label_y_x orelse return; + if (self.y_axis.labels.len == 0) return; + + const graph_area = layout.graph_area; + const labels_len: u16 = @intCast(self.y_axis.labels.len); + + for (self.y_axis.labels, 0..) |label, i| { + const dy: u16 = @intCast(i * @as(usize, graph_area.height -| 1) / @max(labels_len - 1, 1)); + const label_y = graph_area.bottom() -| 1 -| dy; + if (label_y >= area.top() and label_y < graph_area.bottom()) { + const label_width = graph_area.left() -| area.left() -| 1; + const label_area = Rect.init(x, label_y, label_width, 1); + label.render(label_area, buf); + } + } + } + + fn renderAxes(self: Chart, buf: *Buffer, layout: ChartLayout) void { + const graph_area = layout.graph_area; + + // X axis (horizontal line) + if (layout.axis_x_y) |y| { + var x = graph_area.left(); + while (x < graph_area.right()) : (x += 1) { + _ = buf.setString(x, y, symbols.line.NORMAL.horizontal, self.x_axis.style); + } + } + + // Y axis (vertical line) + if (layout.axis_y_x) |x| { + var y = graph_area.top(); + while (y < graph_area.bottom()) : (y += 1) { + _ = buf.setString(x, y, symbols.line.NORMAL.vertical, self.y_axis.style); + } + } + + // Corner + if (layout.axis_x_y) |ay| { + if (layout.axis_y_x) |ax| { + _ = buf.setString(ax, ay, symbols.line.NORMAL.bottom_left, self.x_axis.style); + } + } + } + + fn renderGraph(self: Chart, buf: *Buffer, layout: ChartLayout) void { + const graph_area = layout.graph_area; + if (graph_area.isEmpty()) return; + + // For each dataset, render directly to buffer using simple markers + for (self.datasets) |dataset| { + const color = dataset.style.foreground orelse Color.reset; + + for (dataset.data) |point| { + const px = self.mapX(point[0], graph_area); + const py = self.mapY(point[1], graph_area); + + if (px < graph_area.right() and py >= graph_area.top() and py < graph_area.bottom()) { + const symbol = switch (dataset.marker) { + .dot => symbols.DOT, + .block => symbols.block.FULL, + else => symbols.DOT, + }; + _ = buf.setString(px, py, symbol, Style.default.fg(color)); + } + } + + // Draw lines if graph type is line + if (dataset.graph_type == .line and dataset.data.len > 1) { + var i: usize = 0; + while (i < dataset.data.len - 1) : (i += 1) { + const p1 = dataset.data[i]; + const p2 = dataset.data[i + 1]; + + const x1 = self.mapX(p1[0], graph_area); + const y1 = self.mapY(p1[1], graph_area); + const x2 = self.mapX(p2[0], graph_area); + const y2 = self.mapY(p2[1], graph_area); + + // Simple line drawing for text mode + self.drawSimpleLine(buf, x1, y1, x2, y2, color, graph_area); + } + } + + // Draw bars if graph type is bar + if (dataset.graph_type == .bar) { + for (dataset.data) |point| { + const px = self.mapX(point[0], graph_area); + const py = self.mapY(point[1], graph_area); + const base_y = graph_area.bottom() -| 1; + + var y = py; + while (y <= base_y) : (y += 1) { + if (px < graph_area.right()) { + _ = buf.setString(px, y, symbols.block.FULL, Style.default.fg(color)); + } + } + } + } + } + } + + fn mapX(self: Chart, x: f64, area: Rect) u16 { + const min = self.x_axis.bounds[0]; + const max = self.x_axis.bounds[1]; + if (max <= min) return area.left(); + + const ratio = (x - min) / (max - min); + const offset: u16 = @intFromFloat(@max(0.0, ratio * @as(f64, @floatFromInt(area.width -| 1)))); + return area.left() + offset; + } + + fn mapY(self: Chart, y: f64, area: Rect) u16 { + const min = self.y_axis.bounds[0]; + const max = self.y_axis.bounds[1]; + if (max <= min) return area.bottom() -| 1; + + const ratio = (y - min) / (max - min); + const offset: u16 = @intFromFloat(@max(0.0, ratio * @as(f64, @floatFromInt(area.height -| 1)))); + return area.bottom() -| 1 -| offset; + } + + fn drawSimpleLine(self: Chart, buf: *Buffer, x1: u16, y1: u16, x2: u16, y2: u16, color: Color, area: Rect) void { + _ = self; + // Bresenham's line algorithm (simplified for text mode) + const dx: i32 = @as(i32, @intCast(x2)) - @as(i32, @intCast(x1)); + const dy: i32 = @as(i32, @intCast(y2)) - @as(i32, @intCast(y1)); + + const steps: u32 = @intCast(@max(@abs(dx), @abs(dy))); + if (steps == 0) return; + + const x_inc: f64 = @as(f64, @floatFromInt(dx)) / @as(f64, @floatFromInt(steps)); + const y_inc: f64 = @as(f64, @floatFromInt(dy)) / @as(f64, @floatFromInt(steps)); + + var x: f64 = @floatFromInt(x1); + var y: f64 = @floatFromInt(y1); + + var i: u32 = 0; + while (i <= steps) : (i += 1) { + const px: u16 = @intFromFloat(@round(x)); + const py: u16 = @intFromFloat(@round(y)); + + if (px >= area.left() and px < area.right() and + py >= area.top() and py < area.bottom()) + { + _ = buf.setString(px, py, symbols.DOT, Style.default.fg(color)); + } + + x += x_inc; + y += y_inc; + } + } + + fn renderLegend(self: Chart, area: Rect, buf: *Buffer, layout: ChartLayout) void { + // Count named datasets + var count: u16 = 0; + var max_width: u16 = 0; + for (self.datasets) |ds| { + if (ds.name) |name| { + count += 1; + const w: u16 = @intCast(name.width()); + if (w > max_width) max_width = w; + } + } + + if (count == 0) return; + + const legend_width = max_width + 4; // "• " + name + " " + const legend_height = count + 2; // border top/bottom + + // Calculate position + const pos = self.legend_position orelse return; + const legend_area = switch (pos) { + .top_right => Rect.init( + layout.graph_area.right() -| legend_width, + layout.graph_area.top(), + legend_width, + legend_height, + ), + .top_left => Rect.init( + layout.graph_area.left(), + layout.graph_area.top(), + legend_width, + legend_height, + ), + .bottom_right => Rect.init( + layout.graph_area.right() -| legend_width, + layout.graph_area.bottom() -| legend_height, + legend_width, + legend_height, + ), + .bottom_left => Rect.init( + layout.graph_area.left(), + layout.graph_area.bottom() -| legend_height, + legend_width, + legend_height, + ), + else => Rect.init( + area.right() -| legend_width, + area.top(), + legend_width, + legend_height, + ), + }; + + // Clear legend area + buf.setStyle(legend_area, self.style); + + // Draw legend entries + var y: u16 = legend_area.top() + 1; + for (self.datasets) |ds| { + if (ds.name) |name| { + const color = ds.style.foreground orelse Color.reset; + _ = buf.setString(legend_area.left() + 1, y, symbols.DOT, Style.default.fg(color)); + _ = buf.setString(legend_area.left() + 3, y, name.rawContent(), ds.style); + y += 1; + } + } + } + + fn renderAxisTitles(self: Chart, buf: *Buffer, layout: ChartLayout) void { + const graph_area = layout.graph_area; + + // X axis title (right side) + if (self.x_axis.title) |title| { + const w: u16 = @intCast(title.width()); + const x = graph_area.right() -| w; + const y = graph_area.bottom(); + if (y < graph_area.bottom() + 2) { + _ = buf.setLine(x, y, title, w); + } + } + + // Y axis title (top) + if (self.y_axis.title) |title| { + const w: u16 = @intCast(title.width()); + const x = graph_area.left(); + const y = graph_area.top() -| 1; + if (y >= layout.graph_area.top() -| 1) { + _ = buf.setLine(x, graph_area.top(), title, w); + } + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Axis init" { + const axis = Axis.init(); + try std.testing.expectEqual(@as(f64, 0.0), axis.bounds[0]); + try std.testing.expectEqual(@as(f64, 1.0), axis.bounds[1]); +} + +test "Axis setters" { + const axis = Axis.init() + .setTitleRaw("X Axis") + .setBounds(-10.0, 10.0) + .setStyle(Style.default.fg(Color.red)); + try std.testing.expect(axis.title != null); + try std.testing.expectEqual(@as(f64, -10.0), axis.bounds[0]); + try std.testing.expectEqual(@as(f64, 10.0), axis.bounds[1]); + try std.testing.expectEqual(Color.red, axis.style.foreground.?); +} + +test "Dataset init" { + const ds = Dataset.init(); + try std.testing.expect(ds.name == null); + try std.testing.expectEqual(@as(usize, 0), ds.data.len); + try std.testing.expectEqual(GraphType.scatter, ds.graph_type); +} + +test "Dataset setters" { + const data = [_][2]f64{ .{ 1.0, 2.0 }, .{ 3.0, 4.0 } }; + const ds = Dataset.init() + .setNameRaw("Data 1") + .setData(&data) + .setGraphType(.line) + .setMarker(.dot) + .setStyle(Style.default.fg(Color.blue)); + try std.testing.expect(ds.name != null); + try std.testing.expectEqual(@as(usize, 2), ds.data.len); + try std.testing.expectEqual(GraphType.line, ds.graph_type); + try std.testing.expectEqual(canvas_mod.Marker.dot, ds.marker); + try std.testing.expectEqual(Color.blue, ds.style.foreground.?); +} + +test "Chart init" { + const chart = Chart.init(); + try std.testing.expectEqual(@as(usize, 0), chart.datasets.len); + try std.testing.expect(chart.legend_position != null); +} + +test "Chart setters" { + const data = [_][2]f64{.{ 1.0, 1.0 }}; + const datasets = [_]Dataset{Dataset.init().setData(&data)}; + + const chart = Chart.init() + .setDatasets(&datasets) + .xAxis(Axis.init().setBounds(0.0, 10.0)) + .yAxis(Axis.init().setBounds(0.0, 10.0)) + .legendPosition(.bottom_left); + + try std.testing.expectEqual(@as(usize, 1), chart.datasets.len); + try std.testing.expectEqual(@as(f64, 10.0), chart.x_axis.bounds[1]); + try std.testing.expectEqual(@as(f64, 10.0), chart.y_axis.bounds[1]); + try std.testing.expectEqual(LegendPosition.bottom_left, chart.legend_position.?); +} + +test "GraphType default" { + try std.testing.expectEqual(GraphType.scatter, GraphType.default); +} + +test "LegendPosition default" { + try std.testing.expectEqual(LegendPosition.top_right, LegendPosition.default); +} diff --git a/src/widgets/clear.zig b/src/widgets/clear.zig new file mode 100644 index 0000000..aed6945 --- /dev/null +++ b/src/widgets/clear.zig @@ -0,0 +1,73 @@ +//! Clear widget that clears/resets an area. +//! +//! The Clear widget fills an area with the default background and resets +//! all cell content. This is useful for clearing portions of the screen +//! before rendering other widgets. + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; + +// ============================================================================ +// Clear +// ============================================================================ + +/// A widget that clears/resets a rectangular area. +/// +/// This widget fills the given area with spaces and applies the default style, +/// effectively "clearing" that portion of the terminal. +pub const Clear = struct { + /// Creates a new Clear widget. + pub fn init() Clear { + return .{}; + } + + /// Renders the clear widget, resetting the entire area. + pub fn render(self: Clear, area: Rect, buf: *Buffer) void { + _ = self; + + if (area.isEmpty()) return; + + // Reset all cells in the area + var y: u16 = area.top(); + while (y < area.bottom()) : (y += 1) { + var x: u16 = area.left(); + while (x < area.right()) : (x += 1) { + if (buf.getCell(x, y)) |cell| { + cell.reset(); + } + } + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Clear init" { + const clear = Clear.init(); + _ = clear; +} + +test "Clear render" { + // Create a small buffer + var buf = Buffer.init(std.testing.allocator, Rect.init(0, 0, 5, 3)) catch return; + defer buf.deinit(); + + // Fill with some content + _ = buf.setString(0, 0, "Hello", Style.default); + _ = buf.setString(0, 1, "World", Style.default); + + // Clear a portion + const clear = Clear.init(); + clear.render(Rect.init(0, 0, 3, 2), &buf); + + // The cleared cells should be reset + if (buf.getCell(0, 0)) |cell| { + try std.testing.expectEqualStrings(" ", cell.symbol.slice()); + } +} diff --git a/src/widgets/gauge.zig b/src/widgets/gauge.zig new file mode 100644 index 0000000..886e6aa --- /dev/null +++ b/src/widgets/gauge.zig @@ -0,0 +1,410 @@ +//! Gauge widgets for displaying progress bars. +//! +//! - `Gauge` is a full-height progress bar with centered label +//! - `LineGauge` is a single-line progress bar with left-aligned label + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const text_mod = @import("../text.zig"); +const Line = text_mod.Line; +const Span = text_mod.Span; +const symbols = @import("../symbols/symbols.zig"); +const block_mod = @import("block.zig"); +const Block = block_mod.Block; + +// ============================================================================ +// Gauge +// ============================================================================ + +/// A widget to display a progress bar. +/// +/// A `Gauge` renders a bar filled according to the ratio set. +/// The bar width and height are defined by the Rect it is rendered in. +/// The associated label is always centered horizontally and vertically. +pub const Gauge = struct { + /// Optional block to wrap the gauge. + block: ?Block = null, + /// Progress ratio (0.0 to 1.0). + ratio: f64 = 0.0, + /// Label to display (if null, shows percentage). + label: ?[]const u8 = null, + /// Whether to use unicode block characters for higher precision. + use_unicode: bool = false, + /// Base style for the widget. + style: Style = Style.default, + /// Style for the gauge bar itself. + gauge_style: Style = Style.default, + + /// Creates a new Gauge with default settings. + pub fn init() Gauge { + return .{}; + } + + /// Wraps the gauge in a Block. + pub fn setBlock(self: Gauge, b: Block) Gauge { + var gauge = self; + gauge.block = b; + return gauge; + } + + /// Sets the progress as a percentage (0-100). + pub fn percent(self: Gauge, pct: u16) Gauge { + std.debug.assert(pct <= 100); + var gauge = self; + gauge.ratio = @as(f64, @floatFromInt(pct)) / 100.0; + return gauge; + } + + /// Sets the progress as a ratio (0.0-1.0). + pub fn setRatio(self: Gauge, r: f64) Gauge { + std.debug.assert(r >= 0.0 and r <= 1.0); + var gauge = self; + gauge.ratio = r; + return gauge; + } + + /// Sets the label to display. + pub fn setLabel(self: Gauge, lbl: []const u8) Gauge { + var gauge = self; + gauge.label = lbl; + return gauge; + } + + /// Sets the base style. + pub fn setStyle(self: Gauge, s: Style) Gauge { + var gauge = self; + gauge.style = s; + return gauge; + } + + /// Sets the gauge bar style. + pub fn gaugeStyle(self: Gauge, s: Style) Gauge { + var gauge = self; + gauge.gauge_style = s; + return gauge; + } + + /// Enables or disables unicode block characters. + pub fn useUnicode(self: Gauge, unicode: bool) Gauge { + var gauge = self; + gauge.use_unicode = unicode; + return gauge; + } + + /// Convenience style setters. + pub fn fg(self: Gauge, color: Color) Gauge { + var gauge = self; + gauge.style = gauge.style.fg(color); + return gauge; + } + + pub fn bg(self: Gauge, color: Color) Gauge { + var gauge = self; + gauge.style = gauge.style.bg(color); + return gauge; + } + + /// Renders the gauge to a buffer. + pub fn render(self: Gauge, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + // Render block if present + const gauge_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (gauge_area.isEmpty()) return; + + buf.setStyle(gauge_area, self.gauge_style); + + // Calculate label + var label_buf: [16]u8 = undefined; + const label_str = if (self.label) |l| l else blk: { + const pct = @as(u8, @intFromFloat(@round(self.ratio * 100.0))); + const len = std.fmt.bufPrint(&label_buf, "{}%", .{pct}) catch &label_buf; + break :blk len; + }; + + const label_width: u16 = @intCast(@min(gauge_area.width, label_str.len)); + const label_col = gauge_area.left() + (gauge_area.width - label_width) / 2; + const label_row = gauge_area.top() + gauge_area.height / 2; + + // Calculate filled width + const filled_width = @as(f64, @floatFromInt(gauge_area.width)) * self.ratio; + const end: u16 = if (self.use_unicode) + gauge_area.left() + @as(u16, @intFromFloat(@floor(filled_width))) + else + gauge_area.left() + @as(u16, @intFromFloat(@round(filled_width))); + + // Render the bar + var y: u16 = gauge_area.top(); + while (y < gauge_area.bottom()) : (y += 1) { + var x: u16 = gauge_area.left(); + while (x < end) : (x += 1) { + // In the filled area + const in_label = (x >= label_col and x < label_col + label_width and y == label_row); + if (!in_label) { + // Use full block + const fg_color = self.gauge_style.foreground orelse Color.reset; + const bg_color = self.gauge_style.background orelse Color.reset; + _ = buf.setString(x, y, symbols.block.FULL, Style.default.fg(fg_color).bg(bg_color)); + } else { + // In label area of filled section - swap colors + const fg_color = self.gauge_style.background orelse Color.reset; + const bg_color = self.gauge_style.foreground orelse Color.reset; + _ = buf.setString(x, y, " ", Style.default.fg(fg_color).bg(bg_color)); + } + } + + // Render unicode partial block if needed + if (self.use_unicode and self.ratio < 1.0 and x < gauge_area.right()) { + const frac = filled_width - @floor(filled_width); + const block_char = getUnicodeBlock(frac); + _ = buf.setString(x, y, block_char, self.gauge_style); + } + } + + // Render the label + if (label_row < gauge_area.bottom()) { + const max_len = gauge_area.right() -| label_col; + const to_write = label_str[0..@min(label_str.len, max_len)]; + _ = buf.setString(label_col, label_row, to_write, self.gauge_style); + } + } +}; + +/// Returns the unicode block character for a fractional fill. +fn getUnicodeBlock(frac: f64) []const u8 { + const level: u16 = @intFromFloat(@round(frac * 8.0)); + return switch (level) { + 1 => symbols.block.ONE_EIGHTH, + 2 => symbols.block.ONE_QUARTER, + 3 => symbols.block.THREE_EIGHTHS, + 4 => symbols.block.HALF, + 5 => symbols.block.FIVE_EIGHTHS, + 6 => symbols.block.THREE_QUARTERS, + 7 => symbols.block.SEVEN_EIGHTHS, + 8 => symbols.block.FULL, + else => " ", + }; +} + +// ============================================================================ +// LineGauge +// ============================================================================ + +/// A compact widget to display a progress bar over a single line. +/// +/// A `LineGauge` renders a line filled with symbols according to the ratio. +/// Unlike `Gauge`, only the width can be defined by the rendering Rect. +/// The height is always 1. The label is always left-aligned. +pub const LineGauge = struct { + /// Optional block to wrap the gauge. + block: ?Block = null, + /// Progress ratio (0.0 to 1.0). + ratio: f64 = 0.0, + /// Label to display (if null, shows percentage). + label: ?[]const u8 = null, + /// Base style for the widget. + style: Style = Style.default, + /// Symbol for the filled part. + filled_symbol: []const u8 = symbols.line.HORIZONTAL, + /// Symbol for the unfilled part. + unfilled_symbol: []const u8 = symbols.line.HORIZONTAL, + /// Style for the filled part. + filled_style: Style = Style.default, + /// Style for the unfilled part. + unfilled_style: Style = Style.default, + + /// Creates a new LineGauge with default settings. + pub fn init() LineGauge { + return .{}; + } + + /// Wraps the gauge in a Block. + pub fn setBlock(self: LineGauge, b: Block) LineGauge { + var gauge = self; + gauge.block = b; + return gauge; + } + + /// Sets the progress as a ratio (0.0-1.0). + pub fn setRatio(self: LineGauge, r: f64) LineGauge { + std.debug.assert(r >= 0.0 and r <= 1.0); + var gauge = self; + gauge.ratio = r; + return gauge; + } + + /// Sets the progress as a percentage (0-100). + pub fn percent(self: LineGauge, pct: u16) LineGauge { + std.debug.assert(pct <= 100); + var gauge = self; + gauge.ratio = @as(f64, @floatFromInt(pct)) / 100.0; + return gauge; + } + + /// Sets the label to display. + pub fn setLabel(self: LineGauge, lbl: []const u8) LineGauge { + var gauge = self; + gauge.label = lbl; + return gauge; + } + + /// Sets the base style. + pub fn setStyle(self: LineGauge, s: Style) LineGauge { + var gauge = self; + gauge.style = s; + return gauge; + } + + /// Sets the symbol for the filled part. + pub fn filledSymbol(self: LineGauge, sym: []const u8) LineGauge { + var gauge = self; + gauge.filled_symbol = sym; + return gauge; + } + + /// Sets the symbol for the unfilled part. + pub fn unfilledSymbol(self: LineGauge, sym: []const u8) LineGauge { + var gauge = self; + gauge.unfilled_symbol = sym; + return gauge; + } + + /// Sets the style for the filled part. + pub fn filledStyle(self: LineGauge, s: Style) LineGauge { + var gauge = self; + gauge.filled_style = s; + return gauge; + } + + /// Sets the style for the unfilled part. + pub fn unfilledStyle(self: LineGauge, s: Style) LineGauge { + var gauge = self; + gauge.unfilled_style = s; + return gauge; + } + + /// Convenience style setters. + pub fn fg(self: LineGauge, color: Color) LineGauge { + var gauge = self; + gauge.style = gauge.style.fg(color); + return gauge; + } + + pub fn bg(self: LineGauge, color: Color) LineGauge { + var gauge = self; + gauge.style = gauge.style.bg(color); + return gauge; + } + + /// Renders the line gauge to a buffer. + pub fn render(self: LineGauge, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + // Render block if present + const gauge_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (gauge_area.isEmpty()) return; + + // Create label + var label_buf: [16]u8 = undefined; + const label_str = if (self.label) |l| l else blk: { + const pct = @as(u8, @intFromFloat(@round(self.ratio * 100.0))); + const len = std.fmt.bufPrint(&label_buf, "{:>3}%", .{pct}) catch &label_buf; + break :blk len; + }; + + // Render label + const row = gauge_area.top(); + const label_end = @min(gauge_area.left() + @as(u16, @intCast(label_str.len)), gauge_area.right()); + _ = buf.setString(gauge_area.left(), row, label_str, self.style); + + // Start bar after label + space + const start = label_end + 1; + if (start >= gauge_area.right()) return; + + // Calculate filled width + const bar_width = gauge_area.right() - start; + const filled_width = @as(f64, @floatFromInt(bar_width)) * self.ratio; + const end = start + @as(u16, @intFromFloat(@floor(filled_width))); + + // Render filled part + var col: u16 = start; + while (col < end) : (col += 1) { + _ = buf.setString(col, row, self.filled_symbol, self.filled_style); + } + + // Render unfilled part + while (col < gauge_area.right()) : (col += 1) { + _ = buf.setString(col, row, self.unfilled_symbol, self.unfilled_style); + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Gauge default" { + const gauge = Gauge.init(); + try std.testing.expectEqual(@as(f64, 0.0), gauge.ratio); + try std.testing.expect(!gauge.use_unicode); +} + +test "Gauge percent" { + const gauge = Gauge.init().percent(50); + try std.testing.expectEqual(@as(f64, 0.5), gauge.ratio); +} + +test "Gauge ratio" { + const gauge = Gauge.init().setRatio(0.75); + try std.testing.expectEqual(@as(f64, 0.75), gauge.ratio); +} + +test "Gauge use unicode" { + const gauge = Gauge.init().useUnicode(true); + try std.testing.expect(gauge.use_unicode); +} + +test "LineGauge default" { + const gauge = LineGauge.init(); + try std.testing.expectEqual(@as(f64, 0.0), gauge.ratio); + try std.testing.expectEqualStrings(symbols.line.HORIZONTAL, gauge.filled_symbol); +} + +test "LineGauge symbols" { + const gauge = LineGauge.init() + .filledSymbol("=") + .unfilledSymbol("-"); + try std.testing.expectEqualStrings("=", gauge.filled_symbol); + try std.testing.expectEqualStrings("-", gauge.unfilled_symbol); +} + +test "LineGauge styles" { + const gauge = LineGauge.init() + .filledStyle(Style.default.fg(Color.green)) + .unfilledStyle(Style.default.fg(Color.red)); + try std.testing.expectEqual(Color.green, gauge.filled_style.foreground.?); + try std.testing.expectEqual(Color.red, gauge.unfilled_style.foreground.?); +} + +test "getUnicodeBlock" { + try std.testing.expectEqualStrings(" ", getUnicodeBlock(0.0)); + try std.testing.expectEqualStrings(symbols.block.HALF, getUnicodeBlock(0.5)); + try std.testing.expectEqualStrings(symbols.block.FULL, getUnicodeBlock(1.0)); +} diff --git a/src/widgets/list.zig b/src/widgets/list.zig new file mode 100644 index 0000000..27ec736 --- /dev/null +++ b/src/widgets/list.zig @@ -0,0 +1,569 @@ +//! The List widget displays a list of items and allows selecting one. +//! +//! A List is a collection of `ListItem`s that can be rendered with optional +//! selection highlighting. It supports scrolling, different directions, +//! and customizable highlight symbols. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const text_mod = @import("../text.zig"); +const Line = text_mod.Line; +const Text = text_mod.Text; +const Span = text_mod.Span; +const Alignment = text_mod.Alignment; +const Block = @import("block.zig").Block; + +// ============================================================================ +// ListState +// ============================================================================ + +/// State of the List widget. +/// +/// This state is used to track the selected item and scroll offset. +/// When the list is rendered with state, the selected item will be +/// highlighted and the list will scroll to keep it visible. +pub const ListState = struct { + /// Index of the first visible item. + offset: usize = 0, + /// Index of the selected item (if any). + selected: ?usize = null, + + pub const default: ListState = .{}; + + /// Creates a new ListState with the given offset. + pub fn withOffset(self: ListState, offset: usize) ListState { + var state = self; + state.offset = offset; + return state; + } + + /// Creates a new ListState with the given selected index. + pub fn withSelected(self: ListState, sel: ?usize) ListState { + var state = self; + state.selected = sel; + return state; + } + + /// Returns the current offset. + pub fn getOffset(self: ListState) usize { + return self.offset; + } + + /// Returns a mutable pointer to the offset. + pub fn offsetMut(self: *ListState) *usize { + return &self.offset; + } + + /// Returns the selected index. + pub fn getSelected(self: ListState) ?usize { + return self.selected; + } + + /// Returns a mutable pointer to the selected index. + pub fn selectedMut(self: *ListState) *?usize { + return &self.selected; + } + + /// Sets the selected index. + /// Setting to null also resets the offset to 0. + pub fn select(self: *ListState, index: ?usize) void { + self.selected = index; + if (index == null) { + self.offset = 0; + } + } + + /// Selects the next item (or the first if none selected). + pub fn selectNext(self: *ListState) void { + const next = if (self.selected) |s| s +| 1 else 0; + self.select(next); + } + + /// Selects the previous item (or the last if none selected). + pub fn selectPrevious(self: *ListState) void { + const prev = if (self.selected) |s| s -| 1 else std.math.maxInt(usize); + self.select(prev); + } + + /// Selects the first item. + pub fn selectFirst(self: *ListState) void { + self.select(0); + } + + /// Selects the last item. + pub fn selectLast(self: *ListState) void { + self.select(std.math.maxInt(usize)); + } + + /// Scrolls down by the given amount. + pub fn scrollDownBy(self: *ListState, amount: u16) void { + const current = self.selected orelse 0; + self.select(current +| @as(usize, amount)); + } + + /// Scrolls up by the given amount. + pub fn scrollUpBy(self: *ListState, amount: u16) void { + const current = self.selected orelse 0; + self.select(current -| @as(usize, amount)); + } +}; + +// ============================================================================ +// ListItem +// ============================================================================ + +/// A single item in a List. +/// +/// The item's height is defined by the number of lines it contains. +pub const ListItem = struct { + /// The text content of this item. + content: []const Line, + /// Style applied to the entire item. + style: Style = Style.default, + + /// Creates a new ListItem from a slice of Lines. + pub fn fromLines(lines: []const Line) ListItem { + return .{ .content = lines }; + } + + /// Creates a new ListItem from a single line. + pub fn fromLine(line: Line) ListItem { + return .{ .content = &.{line} }; + } + + /// Creates a new ListItem from raw text. + pub fn raw(text: []const u8) ListItem { + return .{ + .content = &.{Line.raw(text)}, + }; + } + + /// Sets the style of this item. + pub fn setStyle(self: ListItem, s: Style) ListItem { + var item = self; + item.style = s; + return item; + } + + /// Returns the height (number of lines). + pub fn height(self: ListItem) usize { + return self.content.len; + } + + /// Returns the width (max width of all lines). + pub fn width(self: ListItem) usize { + var max_width: usize = 0; + for (self.content) |line| { + const w = line.width(); + if (w > max_width) max_width = w; + } + return max_width; + } + + /// Convenience: set foreground color. + pub fn fg(self: ListItem, color: Color) ListItem { + var item = self; + item.style = item.style.fg(color); + return item; + } + + /// Convenience: set background color. + pub fn bg(self: ListItem, color: Color) ListItem { + var item = self; + item.style = item.style.bg(color); + return item; + } + + /// Convenience: set bold. + pub fn bold(self: ListItem) ListItem { + var item = self; + item.style = item.style.bold(); + return item; + } + + /// Convenience: set italic. + pub fn italic(self: ListItem) ListItem { + var item = self; + item.style = item.style.italic(); + return item; + } +}; + +// ============================================================================ +// HighlightSpacing +// ============================================================================ + +/// Defines when to allocate space for the highlight symbol. +pub const HighlightSpacing = enum { + /// Always allocate space for the highlight symbol. + always, + /// Only allocate space when an item is selected. + when_selected, + /// Never allocate space for the highlight symbol. + never, + + pub const default: HighlightSpacing = .when_selected; +}; + +// ============================================================================ +// ListDirection +// ============================================================================ + +/// Defines the direction in which the list will be rendered. +pub const ListDirection = enum { + /// First item at the top, going down. + top_to_bottom, + /// First item at the bottom, going up. + bottom_to_top, + + pub const default: ListDirection = .top_to_bottom; +}; + +// ============================================================================ +// List +// ============================================================================ + +/// A widget to display several items among which one can be selected. +/// +/// A list is a collection of `ListItem`s. It supports: +/// - Selection highlighting with customizable style and symbol +/// - Scrolling (with scroll padding) +/// - Top-to-bottom or bottom-to-top rendering +/// - Optional block wrapper +pub const List = struct { + /// Items in the list. + items: []const ListItem, + /// Optional block to wrap the list. + block: ?Block = null, + /// Base style for the widget. + style: Style = Style.default, + /// Direction of rendering. + direction: ListDirection = .top_to_bottom, + /// Style for the selected item. + highlight_style: Style = Style.default, + /// Symbol shown before the selected item. + highlight_symbol: ?[]const u8 = null, + /// Whether to repeat highlight symbol for multi-line items. + repeat_highlight_symbol: bool = false, + /// When to allocate space for highlight symbol. + highlight_spacing: HighlightSpacing = .when_selected, + /// Padding around selected item during scroll. + scroll_padding: usize = 0, + + /// Creates a new List with the given items. + pub fn init(items: []const ListItem) List { + return .{ .items = items }; + } + + /// Wraps the list in a Block. + pub fn setBlock(self: List, b: Block) List { + var list = self; + list.block = b; + return list; + } + + /// Sets the base style. + pub fn setStyle(self: List, s: Style) List { + var list = self; + list.style = s; + return list; + } + + /// Sets the highlight symbol. + pub fn highlightSymbol(self: List, symbol: []const u8) List { + var list = self; + list.highlight_symbol = symbol; + return list; + } + + /// Sets the highlight style. + pub fn highlightStyle(self: List, s: Style) List { + var list = self; + list.highlight_style = s; + return list; + } + + /// Sets whether to repeat the highlight symbol. + pub fn repeatHighlightSymbol(self: List, repeat: bool) List { + var list = self; + list.repeat_highlight_symbol = repeat; + return list; + } + + /// Sets the highlight spacing mode. + pub fn setHighlightSpacing(self: List, spacing: HighlightSpacing) List { + var list = self; + list.highlight_spacing = spacing; + return list; + } + + /// Sets the list direction. + pub fn setDirection(self: List, dir: ListDirection) List { + var list = self; + list.direction = dir; + return list; + } + + /// Sets the scroll padding. + pub fn setScrollPadding(self: List, padding: usize) List { + var list = self; + list.scroll_padding = padding; + return list; + } + + /// Returns the number of items. + pub fn len(self: List) usize { + return self.items.len; + } + + /// Returns true if the list is empty. + pub fn isEmpty(self: List) bool { + return self.items.len == 0; + } + + /// Convenience style setters. + pub fn fg(self: List, color: Color) List { + var list = self; + list.style = list.style.fg(color); + return list; + } + + pub fn bg(self: List, color: Color) List { + var list = self; + list.style = list.style.bg(color); + return list; + } + + /// Renders the list to a buffer (stateless). + pub fn render(self: List, area: Rect, buf: *Buffer) void { + var state = ListState.default; + self.renderStateful(area, buf, &state); + } + + /// Renders the list to a buffer with state. + pub fn renderStateful(self: List, area: Rect, buf: *Buffer, state: *ListState) void { + if (area.isEmpty()) return; + + // Apply base style + buf.setStyle(area, self.style); + + // Render block if present + const list_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (list_area.isEmpty() or self.items.len == 0) return; + + // Calculate highlight symbol width + const highlight_symbol_width: u16 = if (self.highlight_symbol) |sym| + @intCast(text_mod.unicodeWidth(sym)) + else + 0; + + // Determine if we should show highlight spacing + const show_highlight_spacing = switch (self.highlight_spacing) { + .always => true, + .when_selected => state.selected != null, + .never => false, + }; + + const prefix_width: u16 = if (show_highlight_spacing) highlight_symbol_width else 0; + const content_width = list_area.width -| prefix_width; + + if (content_width == 0) return; + + // Clamp selected index to valid range + if (state.selected) |sel| { + if (sel >= self.items.len) { + state.selected = self.items.len -| 1; + } + } + + // Calculate which items to render + const visible_height = list_area.height; + + // Update offset based on selection and scroll padding + if (state.selected) |selected| { + // Ensure selected item is visible with padding + const padding = @min(self.scroll_padding, @as(usize, visible_height / 2)); + + // Calculate total height of items before selected + var height_before: usize = 0; + for (self.items[0..selected]) |item| { + height_before += item.height(); + } + + // Calculate height of selected item + const selected_height = self.items[selected].height(); + + // Scroll up if needed + if (height_before < state.offset + padding) { + state.offset = height_before -| padding; + } + + // Scroll down if needed + const height_after = height_before + selected_height; + if (height_after > state.offset + visible_height - padding) { + state.offset = height_after -| (visible_height - padding); + } + } + + // Render items + var y: u16 = 0; + var current_height: usize = 0; + var item_index: usize = 0; + + // Skip items before offset + while (item_index < self.items.len and current_height + self.items[item_index].height() <= state.offset) { + current_height += self.items[item_index].height(); + item_index += 1; + } + + // Render visible items + while (item_index < self.items.len and y < visible_height) { + const item = self.items[item_index]; + const is_selected = if (state.selected) |sel| sel == item_index else false; + + // Calculate how many lines to skip (partial visibility) + const skip_lines: usize = if (current_height < state.offset) + state.offset - current_height + else + 0; + + // Render each line of the item + for (item.content[skip_lines..], 0..) |line, line_idx| { + if (y >= visible_height) break; + + const line_y = if (self.direction == .top_to_bottom) + list_area.y + y + else + list_area.y + (visible_height - 1) - y; + + // Determine styles + var line_style = self.style.patch(item.style); + if (is_selected) { + line_style = line_style.patch(self.highlight_style); + } + + // Apply style to the entire line area + const line_area = Rect.init(list_area.x, line_y, list_area.width, 1); + buf.setStyle(line_area, line_style); + + // Render highlight symbol + if (show_highlight_spacing) { + const show_symbol = is_selected and (line_idx == 0 or self.repeat_highlight_symbol); + if (show_symbol) { + if (self.highlight_symbol) |sym| { + _ = buf.setString(list_area.x, line_y, sym, line_style); + } + } + } + + // Render content + const content_area = Rect.init( + list_area.x + prefix_width, + line_y, + content_width, + 1, + ); + line.renderWithAlignment(content_area, buf, null); + + y += 1; + } + + current_height += item.height(); + item_index += 1; + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "ListState default" { + const state = ListState.default; + try std.testing.expectEqual(@as(usize, 0), state.offset); + try std.testing.expectEqual(@as(?usize, null), state.selected); +} + +test "ListState navigation" { + var state = ListState.default; + + state.selectFirst(); + try std.testing.expectEqual(@as(?usize, 0), state.selected); + + state.selectNext(); + try std.testing.expectEqual(@as(?usize, 1), state.selected); + + state.selectPrevious(); + try std.testing.expectEqual(@as(?usize, 0), state.selected); + + state.selectPrevious(); + try std.testing.expectEqual(@as(?usize, 0), state.selected); // Can't go below 0 +} + +test "ListState select" { + var state = ListState.default; + + state.select(5); + try std.testing.expectEqual(@as(?usize, 5), state.selected); + + state.select(null); + try std.testing.expectEqual(@as(?usize, null), state.selected); + try std.testing.expectEqual(@as(usize, 0), state.offset); // Reset on null +} + +test "ListItem creation" { + const item = ListItem.raw("Test item"); + try std.testing.expectEqual(@as(usize, 1), item.height()); +} + +test "ListItem style" { + const item = ListItem.raw("Test").fg(Color.red).bold(); + try std.testing.expectEqual(Color.red, item.style.foreground.?); + try std.testing.expect(item.style.add_modifiers.bold); +} + +test "List creation" { + const items = [_]ListItem{ + ListItem.raw("Item 1"), + ListItem.raw("Item 2"), + ListItem.raw("Item 3"), + }; + const list = List.init(&items); + try std.testing.expectEqual(@as(usize, 3), list.len()); + try std.testing.expect(!list.isEmpty()); +} + +test "List empty" { + const items = [_]ListItem{}; + const list = List.init(&items); + try std.testing.expect(list.isEmpty()); +} + +test "List styling" { + const items = [_]ListItem{}; + const list = List.init(&items) + .highlightSymbol(">> ") + .highlightStyle(Style.default.fg(Color.yellow)) + .setDirection(.bottom_to_top); + + try std.testing.expectEqualStrings(">> ", list.highlight_symbol.?); + try std.testing.expectEqual(ListDirection.bottom_to_top, list.direction); +} + +test "HighlightSpacing default" { + try std.testing.expectEqual(HighlightSpacing.when_selected, HighlightSpacing.default); +} + +test "ListDirection default" { + try std.testing.expectEqual(ListDirection.top_to_bottom, ListDirection.default); +} diff --git a/src/widgets/scrollbar.zig b/src/widgets/scrollbar.zig new file mode 100644 index 0000000..bf84b95 --- /dev/null +++ b/src/widgets/scrollbar.zig @@ -0,0 +1,354 @@ +//! The Scrollbar widget displays a scrollbar next to other widgets. +//! +//! The scrollbar can be horizontal or vertical and shows the current +//! scroll position within the content. + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const symbols = @import("../symbols/symbols.zig"); + +// ============================================================================ +// ScrollbarOrientation +// ============================================================================ + +/// Orientation of the scrollbar. +pub const ScrollbarOrientation = enum { + vertical_right, + vertical_left, + horizontal_bottom, + horizontal_top, + + pub const default: ScrollbarOrientation = .vertical_right; + + /// Returns true if this is a vertical orientation. + pub fn isVertical(self: ScrollbarOrientation) bool { + return self == .vertical_right or self == .vertical_left; + } + + /// Returns true if this is a horizontal orientation. + pub fn isHorizontal(self: ScrollbarOrientation) bool { + return self == .horizontal_bottom or self == .horizontal_top; + } +}; + +// ============================================================================ +// ScrollbarState +// ============================================================================ + +/// State of the Scrollbar widget. +pub const ScrollbarState = struct { + /// Current scroll position. + position: usize = 0, + /// Total content length. + content_length: usize = 0, + /// Viewport length (visible area). + viewport_content_length: usize = 0, + + pub const default: ScrollbarState = .{}; + + /// Creates a new ScrollbarState with the given content length. + pub fn init(content_length: usize) ScrollbarState { + return .{ .content_length = content_length }; + } + + /// Sets the current position. + pub fn setPosition(self: ScrollbarState, pos: usize) ScrollbarState { + var state = self; + state.position = pos; + return state; + } + + /// Sets the content length. + pub fn setContentLength(self: ScrollbarState, len: usize) ScrollbarState { + var state = self; + state.content_length = len; + return state; + } + + /// Sets the viewport content length. + pub fn setViewportContentLength(self: ScrollbarState, len: usize) ScrollbarState { + var state = self; + state.viewport_content_length = len; + return state; + } + + /// Scrolls to the next position. + pub fn next(self: *ScrollbarState) void { + self.position = @min(self.position + 1, self.content_length -| 1); + } + + /// Scrolls to the previous position. + pub fn prev(self: *ScrollbarState) void { + self.position = self.position -| 1; + } + + /// Scrolls to the first position. + pub fn first(self: *ScrollbarState) void { + self.position = 0; + } + + /// Scrolls to the last position. + pub fn last(self: *ScrollbarState) void { + self.position = self.content_length -| 1; + } +}; + +// ============================================================================ +// Scrollbar +// ============================================================================ + +/// A widget to display a scrollbar. +/// +/// The scrollbar shows the current scroll position within content that +/// doesn't fit entirely in the viewport. +pub const Scrollbar = struct { + /// Orientation of the scrollbar. + orientation: ScrollbarOrientation = .vertical_right, + /// Symbol set for the scrollbar. + symbols: symbols.scrollbar.Set = symbols.scrollbar.VERTICAL, + /// Style for the thumb (position indicator). + thumb_style: Style = Style.default, + /// Style for the track. + track_style: Style = Style.default, + /// Style for begin/end symbols. + begin_style: Style = Style.default, + end_style: Style = Style.default, + /// Whether to show the begin symbol. + begin_symbol: ?[]const u8 = null, + /// Whether to show the end symbol. + end_symbol: ?[]const u8 = null, + /// Track symbol override. + track_symbol: ?[]const u8 = null, + /// Thumb symbol override. + thumb_symbol: ?[]const u8 = null, + + /// Creates a new Scrollbar with default settings. + pub fn init(orientation: ScrollbarOrientation) Scrollbar { + const syms = if (orientation.isVertical()) + symbols.scrollbar.VERTICAL + else + symbols.scrollbar.HORIZONTAL; + + return .{ + .orientation = orientation, + .symbols = syms, + }; + } + + /// Sets the orientation. + pub fn setOrientation(self: Scrollbar, o: ScrollbarOrientation) Scrollbar { + var sb = self; + sb.orientation = o; + return sb; + } + + /// Sets the symbol set. + pub fn setSymbols(self: Scrollbar, s: symbols.scrollbar.Set) Scrollbar { + var sb = self; + sb.symbols = s; + return sb; + } + + /// Sets the thumb style. + pub fn thumbStyle(self: Scrollbar, s: Style) Scrollbar { + var sb = self; + sb.thumb_style = s; + return sb; + } + + /// Sets the track style. + pub fn trackStyle(self: Scrollbar, s: Style) Scrollbar { + var sb = self; + sb.track_style = s; + return sb; + } + + /// Sets the begin symbol. + pub fn beginSymbol(self: Scrollbar, sym: ?[]const u8) Scrollbar { + var sb = self; + sb.begin_symbol = sym; + return sb; + } + + /// Sets the end symbol. + pub fn endSymbol(self: Scrollbar, sym: ?[]const u8) Scrollbar { + var sb = self; + sb.end_symbol = sym; + return sb; + } + + /// Renders the scrollbar to a buffer. + pub fn render(self: Scrollbar, area: Rect, buf: *Buffer, state: *ScrollbarState) void { + if (area.isEmpty()) return; + if (state.content_length == 0) return; + + if (self.orientation.isVertical()) { + self.renderVertical(area, buf, state); + } else { + self.renderHorizontal(area, buf, state); + } + } + + fn renderVertical(self: Scrollbar, area: Rect, buf: *Buffer, state: *ScrollbarState) void { + const x = switch (self.orientation) { + .vertical_right => area.right() -| 1, + .vertical_left => area.left(), + else => area.left(), + }; + + var start_y = area.top(); + var end_y = area.bottom(); + + // Draw begin symbol + if (self.begin_symbol) |sym| { + _ = buf.setString(x, start_y, sym, self.begin_style); + start_y += 1; + } + + // Draw end symbol + if (self.end_symbol) |sym| { + if (end_y > start_y) { + _ = buf.setString(x, end_y - 1, sym, self.end_style); + end_y -= 1; + } + } + + const track_len = end_y -| start_y; + if (track_len == 0) return; + + // Calculate thumb position and size + const content_len = state.content_length; + const viewport_len = if (state.viewport_content_length > 0) + state.viewport_content_length + else + @as(usize, track_len); + + const thumb_len = @max(1, @as(u16, @intCast((viewport_len * @as(usize, track_len)) / @max(1, content_len)))); + const max_scroll = content_len -| viewport_len; + const thumb_pos: u16 = if (max_scroll == 0) 0 else @intCast((state.position * (@as(usize, track_len) -| thumb_len)) / max_scroll); + + // Draw track and thumb + var y = start_y; + while (y < end_y) : (y += 1) { + const rel_y = y - start_y; + const symbol = self.track_symbol orelse self.symbols.track; + const thumb_symbol = self.thumb_symbol orelse self.symbols.thumb; + + if (rel_y >= thumb_pos and rel_y < thumb_pos + thumb_len) { + _ = buf.setString(x, y, thumb_symbol, self.thumb_style); + } else { + _ = buf.setString(x, y, symbol, self.track_style); + } + } + } + + fn renderHorizontal(self: Scrollbar, area: Rect, buf: *Buffer, state: *ScrollbarState) void { + const y = switch (self.orientation) { + .horizontal_bottom => area.bottom() -| 1, + .horizontal_top => area.top(), + else => area.top(), + }; + + var start_x = area.left(); + var end_x = area.right(); + + // Draw begin symbol + if (self.begin_symbol) |sym| { + _ = buf.setString(start_x, y, sym, self.begin_style); + start_x += 1; + } + + // Draw end symbol + if (self.end_symbol) |sym| { + if (end_x > start_x) { + _ = buf.setString(end_x - 1, y, sym, self.end_style); + end_x -= 1; + } + } + + const track_len = end_x -| start_x; + if (track_len == 0) return; + + // Calculate thumb position and size + const content_len = state.content_length; + const viewport_len = if (state.viewport_content_length > 0) + state.viewport_content_length + else + @as(usize, track_len); + + const thumb_len = @max(1, @as(u16, @intCast((viewport_len * @as(usize, track_len)) / @max(1, content_len)))); + const max_scroll = content_len -| viewport_len; + const thumb_pos: u16 = if (max_scroll == 0) 0 else @intCast((state.position * (@as(usize, track_len) -| thumb_len)) / max_scroll); + + // Draw track and thumb + var x = start_x; + while (x < end_x) : (x += 1) { + const rel_x = x - start_x; + const symbol = self.track_symbol orelse self.symbols.track; + const thumb_symbol = self.thumb_symbol orelse self.symbols.thumb; + + if (rel_x >= thumb_pos and rel_x < thumb_pos + thumb_len) { + _ = buf.setString(x, y, thumb_symbol, self.thumb_style); + } else { + _ = buf.setString(x, y, symbol, self.track_style); + } + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "ScrollbarState default" { + const state = ScrollbarState.default; + try std.testing.expectEqual(@as(usize, 0), state.position); + try std.testing.expectEqual(@as(usize, 0), state.content_length); +} + +test "ScrollbarState navigation" { + var state = ScrollbarState.init(10); + + state.next(); + try std.testing.expectEqual(@as(usize, 1), state.position); + + state.next(); + state.next(); + try std.testing.expectEqual(@as(usize, 3), state.position); + + state.prev(); + try std.testing.expectEqual(@as(usize, 2), state.position); + + state.first(); + try std.testing.expectEqual(@as(usize, 0), state.position); + + state.last(); + try std.testing.expectEqual(@as(usize, 9), state.position); +} + +test "Scrollbar init" { + const sb = Scrollbar.init(.vertical_right); + try std.testing.expectEqual(ScrollbarOrientation.vertical_right, sb.orientation); + + const sb_h = Scrollbar.init(.horizontal_bottom); + try std.testing.expectEqual(ScrollbarOrientation.horizontal_bottom, sb_h.orientation); +} + +test "ScrollbarOrientation isVertical" { + try std.testing.expect(ScrollbarOrientation.vertical_right.isVertical()); + try std.testing.expect(ScrollbarOrientation.vertical_left.isVertical()); + try std.testing.expect(!ScrollbarOrientation.horizontal_bottom.isVertical()); + try std.testing.expect(!ScrollbarOrientation.horizontal_top.isVertical()); +} + +test "ScrollbarOrientation isHorizontal" { + try std.testing.expect(!ScrollbarOrientation.vertical_right.isHorizontal()); + try std.testing.expect(!ScrollbarOrientation.vertical_left.isHorizontal()); + try std.testing.expect(ScrollbarOrientation.horizontal_bottom.isHorizontal()); + try std.testing.expect(ScrollbarOrientation.horizontal_top.isHorizontal()); +} diff --git a/src/widgets/sparkline.zig b/src/widgets/sparkline.zig new file mode 100644 index 0000000..be42ada --- /dev/null +++ b/src/widgets/sparkline.zig @@ -0,0 +1,221 @@ +//! The Sparkline widget displays a sparkline over one or more lines. +//! +//! Each bar represents a value from the dataset. The height is determined +//! by the value in the dataset. + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const symbols = @import("../symbols/symbols.zig"); +const block_mod = @import("block.zig"); +const Block = block_mod.Block; + +// ============================================================================ +// RenderDirection +// ============================================================================ + +/// Defines the direction in which the sparkline will be rendered. +pub const RenderDirection = enum { + /// The first value is on the left, going to the right. + left_to_right, + /// The first value is on the right, going to the left. + right_to_left, + + pub const default: RenderDirection = .left_to_right; +}; + +// ============================================================================ +// Sparkline +// ============================================================================ + +/// Widget to render a sparkline over one or more lines. +/// +/// Each bar in a Sparkline represents a value from the provided dataset. +/// The height of the bar is determined by the value in the dataset. +pub const Sparkline = struct { + /// Optional block to wrap the sparkline. + block: ?Block = null, + /// Widget style. + style: Style = Style.default, + /// The data to display. + data: []const u64 = &.{}, + /// Maximum value (if null, uses max of dataset). + max: ?u64 = null, + /// Bar set for rendering. + bar_set: symbols.bar.Set = symbols.bar.NINE_LEVELS, + /// Render direction. + direction: RenderDirection = .left_to_right, + + /// Creates a new Sparkline with default settings. + pub fn init() Sparkline { + return .{}; + } + + /// Wraps the sparkline in a Block. + pub fn setBlock(self: Sparkline, b: Block) Sparkline { + var spark = self; + spark.block = b; + return spark; + } + + /// Sets the style. + pub fn setStyle(self: Sparkline, s: Style) Sparkline { + var spark = self; + spark.style = s; + return spark; + } + + /// Sets the data. + pub fn setData(self: Sparkline, d: []const u64) Sparkline { + var spark = self; + spark.data = d; + return spark; + } + + /// Sets the maximum value. + pub fn setMax(self: Sparkline, m: u64) Sparkline { + var spark = self; + spark.max = m; + return spark; + } + + /// Sets the bar set. + pub fn barSet(self: Sparkline, bs: symbols.bar.Set) Sparkline { + var spark = self; + spark.bar_set = bs; + return spark; + } + + /// Sets the render direction. + pub fn setDirection(self: Sparkline, dir: RenderDirection) Sparkline { + var spark = self; + spark.direction = dir; + return spark; + } + + /// Convenience style setters. + pub fn fg(self: Sparkline, color: Color) Sparkline { + var spark = self; + spark.style = spark.style.fg(color); + return spark; + } + + pub fn bg(self: Sparkline, color: Color) Sparkline { + var spark = self; + spark.style = spark.style.bg(color); + return spark; + } + + /// Renders the sparkline to a buffer. + pub fn render(self: Sparkline, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + // Render block if present + const spark_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (spark_area.isEmpty()) return; + + self.renderSparkline(spark_area, buf); + } + + fn renderSparkline(self: Sparkline, spark_area: Rect, buf: *Buffer) void { + // Determine the maximum height + const max_height = self.max orelse blk: { + var m: u64 = 1; + for (self.data) |v| { + if (v > m) m = v; + } + break :blk m; + }; + + // Determine how many bars to render + const max_index = @min(@as(usize, spark_area.width), self.data.len); + + // Render each bar + for (self.data[0..max_index], 0..) |value, i| { + const x: u16 = switch (self.direction) { + .left_to_right => spark_area.left() + @as(u16, @intCast(i)), + .right_to_left => spark_area.right() - @as(u16, @intCast(i)) - 1, + }; + + // Calculate scaled height (in eighths) + var height: u64 = if (max_height == 0) 0 else value * @as(u64, spark_area.height) * 8 / max_height; + + // Render from bottom to top + var j: u16 = spark_area.height; + while (j > 0) : (j -= 1) { + const y = spark_area.top() + j - 1; + const symbol = self.symbolForHeight(height); + + if (height > 8) { + height -= 8; + } else { + height = 0; + } + + _ = buf.setString(x, y, symbol, self.style); + } + } + } + + fn symbolForHeight(self: Sparkline, height: u64) []const u8 { + return switch (height) { + 0 => self.bar_set.empty, + 1 => self.bar_set.one_eighth, + 2 => self.bar_set.one_quarter, + 3 => self.bar_set.three_eighths, + 4 => self.bar_set.half, + 5 => self.bar_set.five_eighths, + 6 => self.bar_set.three_quarters, + 7 => self.bar_set.seven_eighths, + else => self.bar_set.full, + }; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Sparkline default" { + const spark = Sparkline.init(); + try std.testing.expectEqual(@as(usize, 0), spark.data.len); + try std.testing.expectEqual(@as(?u64, null), spark.max); +} + +test "Sparkline with data" { + const data = [_]u64{ 1, 2, 3, 4, 5 }; + const spark = Sparkline.init().setData(&data); + try std.testing.expectEqual(@as(usize, 5), spark.data.len); +} + +test "Sparkline with max" { + const spark = Sparkline.init().setMax(100); + try std.testing.expectEqual(@as(u64, 100), spark.max.?); +} + +test "Sparkline direction" { + const spark = Sparkline.init().setDirection(.right_to_left); + try std.testing.expectEqual(RenderDirection.right_to_left, spark.direction); +} + +test "Sparkline symbolForHeight" { + const spark = Sparkline.init(); + try std.testing.expectEqualStrings(spark.bar_set.empty, spark.symbolForHeight(0)); + try std.testing.expectEqualStrings(spark.bar_set.half, spark.symbolForHeight(4)); + try std.testing.expectEqualStrings(spark.bar_set.full, spark.symbolForHeight(8)); + try std.testing.expectEqualStrings(spark.bar_set.full, spark.symbolForHeight(100)); +} + +test "RenderDirection default" { + try std.testing.expectEqual(RenderDirection.left_to_right, RenderDirection.default); +} diff --git a/src/widgets/table.zig b/src/widgets/table.zig new file mode 100644 index 0000000..117faea --- /dev/null +++ b/src/widgets/table.zig @@ -0,0 +1,841 @@ +//! Table widget for displaying data in rows and columns. +//! +//! The Table widget displays multiple rows and columns in a grid and allows +//! selecting rows, columns, or individual cells. +//! +//! ## Components +//! +//! - `Cell`: A single cell containing Text content +//! - `Row`: A row of cells with height and margin configuration +//! - `Table`: The main widget containing rows, optional header/footer +//! - `TableState`: Stateful widget tracking selection and scroll offset + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const text_mod = @import("../text.zig"); +const Text = text_mod.Text; +const Line = text_mod.Line; +const Span = text_mod.Span; +const block_mod = @import("block.zig"); +const Block = block_mod.Block; +const layout_mod = @import("../layout.zig"); +const Constraint = layout_mod.Constraint; +const list_mod = @import("list.zig"); +const HighlightSpacing = list_mod.HighlightSpacing; + +// ============================================================================ +// Cell +// ============================================================================ + +/// A cell contains the content to be displayed in a Row of a Table. +/// +/// You can style the cell and its content independently. +pub const Cell = struct { + /// The text content of the cell. + content: Text = Text.default, + /// Style applied to the cell area. + style: Style = Style.default, + + /// Creates a new Cell with the given content. + pub fn init(content: Text) Cell { + return .{ .content = content }; + } + + /// Creates a Cell from a raw string. + pub fn fromString(str: []const u8) Cell { + return .{ .content = Text.raw(str) }; + } + + /// Creates a Cell from a Line. + pub fn fromLine(line: Line) Cell { + const lines = [_]Line{line}; + return .{ .content = Text.init(&lines) }; + } + + /// Creates a Cell from a Span. + pub fn fromSpan(span: Span) Cell { + return Cell.fromLine(Line.fromSpan(span)); + } + + /// Sets the content of the cell. + pub fn setContent(self: Cell, content: Text) Cell { + var cell = self; + cell.content = content; + return cell; + } + + /// Sets the style of the cell. + pub fn setStyle(self: Cell, s: Style) Cell { + var cell = self; + cell.style = s; + return cell; + } + + /// Renders the cell to the buffer. + fn render(self: Cell, area: Rect, buf: *Buffer) void { + buf.setStyle(area, self.style); + self.content.render(area, buf); + } +}; + +// ============================================================================ +// Row +// ============================================================================ + +/// A single row of data to be displayed in a Table. +/// +/// A Row is a collection of Cells with height and margin configuration. +pub const Row = struct { + /// The cells in this row. + cells: []const Cell = &.{}, + /// Height of the row in lines. + height: u16 = 1, + /// Margin above the row. + top_margin: u16 = 0, + /// Margin below the row. + bottom_margin: u16 = 0, + /// Style for the entire row. + style: Style = Style.default, + + /// Creates a new Row with the given cells. + pub fn init(cells: []const Cell) Row { + return .{ .cells = cells }; + } + + /// Creates a Row from string slices. + /// Note: This creates Cell wrappers but the strings must have appropriate lifetime. + pub fn fromStrings(strings: []const []const u8, cell_buf: []Cell) Row { + const count = @min(strings.len, cell_buf.len); + for (strings[0..count], 0..) |str, i| { + cell_buf[i] = Cell.fromString(str); + } + return .{ .cells = cell_buf[0..count] }; + } + + /// Sets the cells. + pub fn setCells(self: Row, cells: []const Cell) Row { + var row = self; + row.cells = cells; + return row; + } + + /// Sets the height. + pub fn setHeight(self: Row, h: u16) Row { + var row = self; + row.height = h; + return row; + } + + /// Sets the top margin. + pub fn topMargin(self: Row, margin: u16) Row { + var row = self; + row.top_margin = margin; + return row; + } + + /// Sets the bottom margin. + pub fn bottomMargin(self: Row, margin: u16) Row { + var row = self; + row.bottom_margin = margin; + return row; + } + + /// Sets the style. + pub fn setStyle(self: Row, s: Style) Row { + var row = self; + row.style = s; + return row; + } + + /// Returns the total height including margins. + fn heightWithMargin(self: Row) u16 { + return self.height +| self.top_margin +| self.bottom_margin; + } +}; + +// ============================================================================ +// TableState +// ============================================================================ + +/// State of a Table widget for tracking selection and scroll position. +/// +/// This state is used with stateful rendering to allow row/column/cell selection. +pub const TableState = struct { + /// Index of the first row to be displayed. + offset: usize = 0, + /// Index of the selected row (null if none). + selected: ?usize = null, + /// Index of the selected column (null if none). + selected_column: ?usize = null, + + pub const default: TableState = .{}; + + /// Creates a new TableState. + pub fn init() TableState { + return .{}; + } + + /// Creates a TableState with initial offset. + pub fn withOffset(offset: usize) TableState { + return .{ .offset = offset }; + } + + /// Creates a TableState with initial selection. + pub fn withSelected(sel: ?usize) TableState { + return .{ .selected = sel }; + } + + /// Sets the selected row. + pub fn select(self: *TableState, index: ?usize) void { + self.selected = index; + if (index == null) { + self.offset = 0; + } + } + + /// Sets the selected column. + pub fn selectColumn(self: *TableState, index: ?usize) void { + self.selected_column = index; + } + + /// Sets the selected cell (row, column). + pub fn selectCell(self: *TableState, indexes: ?struct { usize, usize }) void { + if (indexes) |idx| { + self.selected = idx[0]; + self.selected_column = idx[1]; + } else { + self.offset = 0; + self.selected = null; + self.selected_column = null; + } + } + + /// Returns the selected cell as (row, column) or null. + pub fn selectedCell(self: TableState) ?struct { usize, usize } { + if (self.selected) |r| { + if (self.selected_column) |c| { + return .{ r, c }; + } + } + return null; + } + + /// Selects the next row. + pub fn selectNext(self: *TableState) void { + const next = if (self.selected) |i| i +| 1 else 0; + self.select(next); + } + + /// Selects the previous row. + pub fn selectPrevious(self: *TableState) void { + const prev = if (self.selected) |i| i -| 1 else std.math.maxInt(usize); + self.select(prev); + } + + /// Selects the first row. + pub fn selectFirst(self: *TableState) void { + self.select(0); + } + + /// Selects the last row. + pub fn selectLast(self: *TableState) void { + self.select(std.math.maxInt(usize)); + } + + /// Selects the next column. + pub fn selectNextColumn(self: *TableState) void { + const next = if (self.selected_column) |i| i +| 1 else 0; + self.selectColumn(next); + } + + /// Selects the previous column. + pub fn selectPreviousColumn(self: *TableState) void { + const prev = if (self.selected_column) |i| i -| 1 else std.math.maxInt(usize); + self.selectColumn(prev); + } + + /// Scrolls down by the given amount. + pub fn scrollDownBy(self: *TableState, amount: u16) void { + const sel = self.selected orelse 0; + self.select(sel +| @as(usize, amount)); + } + + /// Scrolls up by the given amount. + pub fn scrollUpBy(self: *TableState, amount: u16) void { + const sel = self.selected orelse 0; + self.select(sel -| @as(usize, amount)); + } +}; + +// ============================================================================ +// Table +// ============================================================================ + +/// A widget to display data in formatted columns. +/// +/// A Table is a collection of Rows, each composed of Cells. +/// It can have an optional header and footer, and supports row/column selection. +pub const Table = struct { + /// Data rows to display. + rows: []const Row = &.{}, + /// Optional header row. + header: ?Row = null, + /// Optional footer row. + footer: ?Row = null, + /// Width constraints for columns. + widths: []const Constraint = &.{}, + /// Spacing between columns. + column_spacing: u16 = 1, + /// Optional block wrapper. + block: ?Block = null, + /// Base style for the widget. + style: Style = Style.default, + /// Style for the selected row. + row_highlight_style: Style = Style.default, + /// Style for the selected column. + column_highlight_style: Style = Style.default, + /// Style for the selected cell. + cell_highlight_style: Style = Style.default, + /// Symbol displayed in front of the selected row. + highlight_symbol: []const u8 = "", + /// When to show the highlight spacing. + highlight_spacing: HighlightSpacing = .when_selected, + + /// Creates a new Table with default settings. + pub fn init() Table { + return .{}; + } + + /// Creates a Table with rows and column widths. + pub fn create(rows: []const Row, widths: []const Constraint) Table { + return .{ + .rows = rows, + .widths = widths, + }; + } + + /// Sets the rows. + pub fn setRows(self: Table, rows: []const Row) Table { + var table = self; + table.rows = rows; + return table; + } + + /// Sets the header row. + pub fn setHeader(self: Table, header: Row) Table { + var table = self; + table.header = header; + return table; + } + + /// Sets the footer row. + pub fn setFooter(self: Table, footer: Row) Table { + var table = self; + table.footer = footer; + return table; + } + + /// Sets the column widths. + pub fn setWidths(self: Table, widths: []const Constraint) Table { + var table = self; + table.widths = widths; + return table; + } + + /// Sets the column spacing. + pub fn columnSpacing(self: Table, spacing: u16) Table { + var table = self; + table.column_spacing = spacing; + return table; + } + + /// Sets the block wrapper. + pub fn setBlock(self: Table, b: Block) Table { + var table = self; + table.block = b; + return table; + } + + /// Sets the base style. + pub fn setStyle(self: Table, s: Style) Table { + var table = self; + table.style = s; + return table; + } + + /// Sets the row highlight style. + pub fn rowHighlightStyle(self: Table, s: Style) Table { + var table = self; + table.row_highlight_style = s; + return table; + } + + /// Sets the column highlight style. + pub fn columnHighlightStyle(self: Table, s: Style) Table { + var table = self; + table.column_highlight_style = s; + return table; + } + + /// Sets the cell highlight style. + pub fn cellHighlightStyle(self: Table, s: Style) Table { + var table = self; + table.cell_highlight_style = s; + return table; + } + + /// Sets the highlight symbol. + pub fn highlightSymbol(self: Table, symbol: []const u8) Table { + var table = self; + table.highlight_symbol = symbol; + return table; + } + + /// Sets the highlight spacing mode. + pub fn setHighlightSpacing(self: Table, spacing: HighlightSpacing) Table { + var table = self; + table.highlight_spacing = spacing; + return table; + } + + /// Returns the number of columns based on widths, rows, header, and footer. + fn columnCount(self: Table) usize { + var max_count: usize = self.widths.len; + + for (self.rows) |row| { + if (row.cells.len > max_count) max_count = row.cells.len; + } + if (self.header) |h| { + if (h.cells.len > max_count) max_count = h.cells.len; + } + if (self.footer) |f| { + if (f.cells.len > max_count) max_count = f.cells.len; + } + + return max_count; + } + + /// Returns the selection column width. + fn selectionWidth(self: Table, state: *TableState) u16 { + const has_selection = state.selected != null; + if (self.highlight_spacing.shouldAdd(has_selection)) { + return @intCast(text_mod.unicodeWidth(self.highlight_symbol)); + } + return 0; + } + + /// Renders the table without state. + pub fn render(self: Table, area: Rect, buf: *Buffer) void { + var state = TableState.default; + self.renderStateful(area, buf, &state); + } + + /// Renders the table with state (stateful widget). + pub fn renderStateful(self: Table, area: Rect, buf: *Buffer, state: *TableState) void { + buf.setStyle(area, self.style); + + // Render block if present + const table_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (table_area.isEmpty()) return; + + // Clamp selection to valid range + if (state.selected) |sel| { + if (sel >= self.rows.len and self.rows.len > 0) { + state.selected = self.rows.len - 1; + } + } + if (self.rows.len == 0) { + state.selected = null; + } + + const col_count = self.columnCount(); + if (state.selected_column) |sel| { + if (sel >= col_count and col_count > 0) { + state.selected_column = col_count - 1; + } + } + if (col_count == 0) { + state.selected_column = null; + } + + const sel_width = self.selectionWidth(state); + + // Calculate column widths + var column_positions: [64]struct { x: u16, width: u16 } = undefined; + const positions = self.calculateColumnPositions(table_area.width, sel_width, col_count, &column_positions); + + // Calculate layout areas + const header_height = if (self.header) |h| h.height +| h.top_margin +| h.bottom_margin else 0; + const footer_height = if (self.footer) |f| f.height +| f.top_margin +| f.bottom_margin else 0; + + const header_area = Rect.init( + table_area.x, + table_area.y + (if (self.header) |h| h.top_margin else 0), + table_area.width, + if (self.header) |h| h.height else 0, + ); + + const rows_area = Rect.init( + table_area.x, + table_area.y + header_height, + table_area.width, + table_area.height -| header_height -| footer_height, + ); + + const footer_area = Rect.init( + table_area.x, + table_area.bottom() -| footer_height + (if (self.footer) |f| f.top_margin else 0), + table_area.width, + if (self.footer) |f| f.height else 0, + ); + + // Render header + if (self.header) |header| { + buf.setStyle(header_area, header.style); + self.renderRow(header, header_area, buf, positions); + } + + // Render rows + self.renderRows(rows_area, buf, state, sel_width, positions); + + // Render footer + if (self.footer) |footer| { + buf.setStyle(footer_area, footer.style); + self.renderRow(footer, footer_area, buf, positions); + } + } + + fn calculateColumnPositions( + self: Table, + max_width: u16, + selection_width: u16, + col_count: usize, + out: []struct { x: u16, width: u16 }, + ) []struct { x: u16, width: u16 } { + if (col_count == 0) return out[0..0]; + + const actual_count = @min(col_count, out.len); + const available_width = max_width -| selection_width; + + // Simple width calculation: divide available space equally if no widths specified + if (self.widths.len == 0) { + const col_width = available_width / @as(u16, @intCast(@max(1, actual_count))); + var x = selection_width; + for (out[0..actual_count], 0..) |*pos, i| { + pos.x = x; + pos.width = col_width; + x += col_width + self.column_spacing; + _ = i; + } + } else { + // Use constraints to calculate widths + var x = selection_width; + const total_spacing = self.column_spacing * @as(u16, @intCast(@max(1, actual_count) - 1)); + const space_for_cols = available_width -| total_spacing; + + for (out[0..actual_count], 0..) |*pos, i| { + const constraint = if (i < self.widths.len) self.widths[i] else Constraint{ .min = 0 }; + const width: u16 = switch (constraint) { + .length => |l| @min(l, space_for_cols), + .percentage => |p| @intCast((space_for_cols * p) / 100), + .min => |m| m, + .max => |m| @min(m, space_for_cols), + .ratio => |r| @intCast(@as(u32, space_for_cols) * r.num / @max(1, r.den)), + }; + pos.x = x; + pos.width = width; + x += width + self.column_spacing; + } + } + + return out[0..actual_count]; + } + + fn renderRow( + self: Table, + row: Row, + area: Rect, + buf: *Buffer, + positions: []struct { x: u16, width: u16 }, + ) void { + _ = self; + for (positions, 0..) |pos, i| { + if (i < row.cells.len) { + const cell_area = Rect.init( + area.x + pos.x, + area.y, + pos.width, + area.height, + ); + row.cells[i].render(cell_area, buf); + } + } + } + + fn renderRows( + self: Table, + area: Rect, + buf: *Buffer, + state: *TableState, + selection_width: u16, + positions: []struct { x: u16, width: u16 }, + ) void { + if (self.rows.len == 0) return; + + // Calculate visible row range + const visible = self.visibleRows(state, area); + state.offset = visible.start; + + var y_offset: u16 = 0; + var selected_row_area: ?Rect = null; + + var i: usize = visible.start; + while (i < visible.end) : (i += 1) { + const row = self.rows[i]; + const y = area.y + y_offset + row.top_margin; + const height = @min(y + row.height, area.bottom()) -| y; + const row_area = Rect.init(area.x, y, area.width, height); + + buf.setStyle(row_area, row.style); + + const is_selected = state.selected == i; + + // Render selection symbol + if (selection_width > 0 and is_selected) { + const sel_area = Rect.init(area.x, y, selection_width, height); + buf.setStyle(sel_area, row.style); + _ = buf.setString(sel_area.x, sel_area.y, self.highlight_symbol, row.style); + } + + // Render cells + for (positions, 0..) |pos, col_idx| { + if (col_idx < row.cells.len) { + const cell_area = Rect.init( + area.x + pos.x, + y, + pos.width, + height, + ); + row.cells[col_idx].render(cell_area, buf); + } + } + + if (is_selected) { + selected_row_area = row_area; + } + + y_offset += row.heightWithMargin(); + } + + // Apply highlight styles + if (selected_row_area) |row_area| { + buf.setStyle(row_area, self.row_highlight_style); + } + + if (state.selected_column) |col_idx| { + if (col_idx < positions.len) { + const pos = positions[col_idx]; + const col_area = Rect.init(area.x + pos.x, area.y, pos.width, area.height); + buf.setStyle(col_area, self.column_highlight_style); + + // Cell highlight (intersection of row and column) + if (selected_row_area) |row_area| { + const cell_area = row_area.intersection(col_area); + buf.setStyle(cell_area, self.cell_highlight_style); + } + } + } + } + + fn visibleRows(self: Table, state: *TableState, area: Rect) struct { start: usize, end: usize } { + const last_row = self.rows.len -| 1; + var start = @min(state.offset, last_row); + + if (state.selected) |selected| { + start = @min(start, selected); + } + + var end = start; + var height: u16 = 0; + + for (self.rows[start..]) |row| { + if (height + row.height > area.height) break; + height += row.heightWithMargin(); + end += 1; + } + + // Scroll down to make selected visible + if (state.selected) |selected| { + const sel = @min(selected, last_row); + while (sel >= end and end < self.rows.len) { + height +|= self.rows[end].heightWithMargin(); + end += 1; + while (height > area.height and start < end) { + height -|= self.rows[start].heightWithMargin(); + start += 1; + } + } + } + + // Include partial row if space + if (height < area.height and end < self.rows.len) { + end += 1; + } + + return .{ .start = start, .end = end }; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Cell init" { + const cell = Cell.fromString("test"); + _ = cell; +} + +test "Cell style" { + const cell = Cell.fromString("test").setStyle(Style.default.fg(Color.red)); + try std.testing.expectEqual(Color.red, cell.style.foreground.?); +} + +test "Row init" { + const cells = [_]Cell{ + Cell.fromString("A"), + Cell.fromString("B"), + }; + const row = Row.init(&cells); + try std.testing.expectEqual(@as(usize, 2), row.cells.len); + try std.testing.expectEqual(@as(u16, 1), row.height); +} + +test "Row setters" { + const row = Row.init(&.{}) + .setHeight(3) + .topMargin(1) + .bottomMargin(2) + .setStyle(Style.default.fg(Color.blue)); + try std.testing.expectEqual(@as(u16, 3), row.height); + try std.testing.expectEqual(@as(u16, 1), row.top_margin); + try std.testing.expectEqual(@as(u16, 2), row.bottom_margin); + try std.testing.expectEqual(Color.blue, row.style.foreground.?); +} + +test "Row heightWithMargin" { + const row = Row.init(&.{}).setHeight(2).topMargin(1).bottomMargin(3); + try std.testing.expectEqual(@as(u16, 6), row.heightWithMargin()); +} + +test "TableState init" { + const state = TableState.init(); + try std.testing.expectEqual(@as(usize, 0), state.offset); + try std.testing.expect(state.selected == null); + try std.testing.expect(state.selected_column == null); +} + +test "TableState select" { + var state = TableState.init(); + state.select(5); + try std.testing.expectEqual(@as(?usize, 5), state.selected); + + state.select(null); + try std.testing.expect(state.selected == null); + try std.testing.expectEqual(@as(usize, 0), state.offset); +} + +test "TableState navigation" { + var state = TableState.init(); + + state.selectFirst(); + try std.testing.expectEqual(@as(?usize, 0), state.selected); + + state.selectNext(); + try std.testing.expectEqual(@as(?usize, 1), state.selected); + + state.selectPrevious(); + try std.testing.expectEqual(@as(?usize, 0), state.selected); + + state.selectPrevious(); + try std.testing.expectEqual(@as(?usize, 0), state.selected); +} + +test "TableState column selection" { + var state = TableState.init(); + + state.selectColumn(2); + try std.testing.expectEqual(@as(?usize, 2), state.selected_column); + + state.selectNextColumn(); + try std.testing.expectEqual(@as(?usize, 3), state.selected_column); + + state.selectPreviousColumn(); + try std.testing.expectEqual(@as(?usize, 2), state.selected_column); +} + +test "TableState cell selection" { + var state = TableState.init(); + + state.selectCell(.{ 2, 3 }); + try std.testing.expectEqual(@as(?usize, 2), state.selected); + try std.testing.expectEqual(@as(?usize, 3), state.selected_column); + + const cell = state.selectedCell(); + try std.testing.expect(cell != null); + try std.testing.expectEqual(@as(usize, 2), cell.?[0]); + try std.testing.expectEqual(@as(usize, 3), cell.?[1]); +} + +test "Table init" { + const table = Table.init(); + try std.testing.expectEqual(@as(usize, 0), table.rows.len); + try std.testing.expectEqual(@as(u16, 1), table.column_spacing); +} + +test "Table setters" { + const cells = [_]Cell{Cell.fromString("A")}; + const rows = [_]Row{Row.init(&cells)}; + const widths = [_]Constraint{Constraint{ .length = 10 }}; + + const table = Table.init() + .setRows(&rows) + .setWidths(&widths) + .columnSpacing(2) + .highlightSymbol(">> ") + .rowHighlightStyle(Style.default.fg(Color.yellow)); + + try std.testing.expectEqual(@as(usize, 1), table.rows.len); + try std.testing.expectEqual(@as(usize, 1), table.widths.len); + try std.testing.expectEqual(@as(u16, 2), table.column_spacing); + try std.testing.expectEqualStrings(">> ", table.highlight_symbol); + try std.testing.expectEqual(Color.yellow, table.row_highlight_style.foreground.?); +} + +test "Table columnCount" { + const cells1 = [_]Cell{ Cell.fromString("A"), Cell.fromString("B") }; + const cells2 = [_]Cell{ Cell.fromString("C"), Cell.fromString("D"), Cell.fromString("E") }; + const rows = [_]Row{ + Row.init(&cells1), + Row.init(&cells2), + }; + const table = Table.init().setRows(&rows); + + try std.testing.expectEqual(@as(usize, 3), table.columnCount()); +} + +test "Table with header" { + const header_cells = [_]Cell{ Cell.fromString("Col1"), Cell.fromString("Col2") }; + const header = Row.init(&header_cells).setStyle(Style.default.fg(Color.white)); + + const table = Table.init().setHeader(header); + try std.testing.expect(table.header != null); +} diff --git a/src/widgets/tabs.zig b/src/widgets/tabs.zig new file mode 100644 index 0000000..c4df82b --- /dev/null +++ b/src/widgets/tabs.zig @@ -0,0 +1,310 @@ +//! The Tabs widget displays a horizontal set of tabs with a single tab selected. +//! +//! Each tab title can be styled individually. The selected tab is styled using +//! the highlight_style. The divider between tabs can be customized. + +const std = @import("std"); +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const text_mod = @import("../text.zig"); +const Line = text_mod.Line; +const Span = text_mod.Span; +const symbols = @import("../symbols/symbols.zig"); +const block_mod = @import("block.zig"); +const Block = block_mod.Block; + +// ============================================================================ +// Tabs +// ============================================================================ + +/// A widget that displays a horizontal set of tabs with a single tab selected. +/// +/// Each tab title is stored as a Line which can be individually styled. +/// The selected tab is styled using highlight_style. The divider can be +/// customized with divider(), and padding with padding(), paddingLeft(), +/// or paddingRight(). +pub const Tabs = struct { + /// Optional block to wrap the tabs. + block: ?Block = null, + /// Tab titles. + titles: []const Line, + /// Index of the selected tab. + selected: ?usize = null, + /// Base style for the widget. + style: Style = Style.default, + /// Style for the selected tab. + highlight_style: Style = Style.default.reversed(), + /// Divider between tabs. + divider: []const u8 = symbols.line.VERTICAL, + /// Left padding for each tab. + padding_left: []const u8 = " ", + /// Right padding for each tab. + padding_right: []const u8 = " ", + + /// Creates new Tabs with the given titles. + pub fn init(titles: []const Line) Tabs { + return .{ + .titles = titles, + .selected = if (titles.len > 0) 0 else null, + }; + } + + /// Creates new Tabs from raw string titles. + pub fn fromStrings(titles: []const []const u8) Tabs { + _ = titles; + // This would need runtime memory allocation + // In practice, use init() with pre-constructed Lines + return .{ + .titles = &.{}, + .selected = null, + }; + } + + /// Sets the block to wrap the tabs. + pub fn setBlock(self: Tabs, b: Block) Tabs { + var tabs = self; + tabs.block = b; + return tabs; + } + + /// Sets the selected tab index. + pub fn select(self: Tabs, index: ?usize) Tabs { + var tabs = self; + tabs.selected = index; + return tabs; + } + + /// Sets the base style. + pub fn setStyle(self: Tabs, s: Style) Tabs { + var tabs = self; + tabs.style = s; + return tabs; + } + + /// Sets the highlight style for the selected tab. + pub fn highlightStyle(self: Tabs, s: Style) Tabs { + var tabs = self; + tabs.highlight_style = s; + return tabs; + } + + /// Sets the divider between tabs. + pub fn setDivider(self: Tabs, div: []const u8) Tabs { + var tabs = self; + tabs.divider = div; + return tabs; + } + + /// Sets the padding on both sides. + pub fn padding(self: Tabs, left: []const u8, right: []const u8) Tabs { + var tabs = self; + tabs.padding_left = left; + tabs.padding_right = right; + return tabs; + } + + /// Sets the left padding. + pub fn paddingLeft(self: Tabs, p: []const u8) Tabs { + var tabs = self; + tabs.padding_left = p; + return tabs; + } + + /// Sets the right padding. + pub fn paddingRight(self: Tabs, p: []const u8) Tabs { + var tabs = self; + tabs.padding_right = p; + return tabs; + } + + /// Convenience style setters. + pub fn fg(self: Tabs, color: Color) Tabs { + var tabs = self; + tabs.style = tabs.style.fg(color); + return tabs; + } + + pub fn bg(self: Tabs, color: Color) Tabs { + var tabs = self; + tabs.style = tabs.style.bg(color); + return tabs; + } + + /// Calculates the rendered width of the tabs (without block). + pub fn width(self: Tabs) usize { + if (self.titles.len == 0) return 0; + + var total: usize = 0; + + // Titles width + for (self.titles) |title| { + total += title.width(); + } + + // Padding width + const padding_width = text_mod.unicodeWidth(self.padding_left) + + text_mod.unicodeWidth(self.padding_right); + total += padding_width * self.titles.len; + + // Divider width + const divider_count = self.titles.len -| 1; + total += text_mod.unicodeWidth(self.divider) * divider_count; + + return total; + } + + /// Renders the tabs to a buffer. + pub fn render(self: Tabs, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + buf.setStyle(area, self.style); + + // Render block if present + const tabs_area = if (self.block) |b| blk: { + b.render(area, buf); + break :blk b.inner(area); + } else area; + + if (tabs_area.isEmpty()) return; + + self.renderTabs(tabs_area, buf); + } + + fn renderTabs(self: Tabs, tabs_area: Rect, buf: *Buffer) void { + var x = tabs_area.left(); + const titles_count = self.titles.len; + + for (self.titles, 0..) |title, i| { + const last_title = (titles_count - 1 == i); + var remaining_width = tabs_area.right() -| x; + + if (remaining_width == 0) break; + + // Left padding + const left_pad_written = buf.setString(x, tabs_area.top(), self.padding_left, self.style); + x +|= left_pad_written; + remaining_width = tabs_area.right() -| x; + if (remaining_width == 0) break; + + // Title + const title_start = x; + for (title.spans) |span| { + if (x >= tabs_area.right()) break; + const span_written = buf.setString(x, tabs_area.top(), span.content, self.style.patch(span.style)); + x +|= span_written; + } + const title_end = x; + + // Apply highlight style if selected + if (self.selected) |sel| { + if (sel == i) { + const title_width = title_end -| title_start; + if (title_width > 0) { + const highlight_rect = Rect.init(title_start, tabs_area.top(), title_width, 1); + buf.setStyle(highlight_rect, self.highlight_style); + } + } + } + + remaining_width = tabs_area.right() -| x; + if (remaining_width == 0) break; + + // Right padding + const right_pad_written = buf.setString(x, tabs_area.top(), self.padding_right, self.style); + x +|= right_pad_written; + remaining_width = tabs_area.right() -| x; + if (remaining_width == 0 or last_title) break; + + // Divider + const divider_written = buf.setString(x, tabs_area.top(), self.divider, self.style); + x +|= divider_written; + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Tabs default" { + const titles = [_]Line{}; + const tabs = Tabs.init(&titles); + try std.testing.expectEqual(@as(?usize, null), tabs.selected); +} + +test "Tabs with titles" { + const titles = [_]Line{ + Line.raw("Tab1"), + Line.raw("Tab2"), + Line.raw("Tab3"), + }; + const tabs = Tabs.init(&titles); + try std.testing.expectEqual(@as(?usize, 0), tabs.selected); + try std.testing.expectEqual(@as(usize, 3), tabs.titles.len); +} + +test "Tabs select" { + const titles = [_]Line{ + Line.raw("Tab1"), + Line.raw("Tab2"), + }; + const tabs = Tabs.init(&titles).select(1); + try std.testing.expectEqual(@as(?usize, 1), tabs.selected); + + const tabs_none = Tabs.init(&titles).select(null); + try std.testing.expectEqual(@as(?usize, null), tabs_none.selected); +} + +test "Tabs divider" { + const titles = [_]Line{ + Line.raw("A"), + Line.raw("B"), + }; + const tabs = Tabs.init(&titles).setDivider("--"); + try std.testing.expectEqualStrings("--", tabs.divider); +} + +test "Tabs padding" { + const titles = [_]Line{}; + const tabs = Tabs.init(&titles).padding("->", "<-"); + try std.testing.expectEqualStrings("->", tabs.padding_left); + try std.testing.expectEqualStrings("<-", tabs.padding_right); +} + +test "Tabs highlight style" { + const titles = [_]Line{}; + const tabs = Tabs.init(&titles).highlightStyle(Style.default.fg(Color.yellow)); + try std.testing.expectEqual(Color.yellow, tabs.highlight_style.foreground.?); +} + +test "Tabs width empty" { + const titles = [_]Line{}; + const tabs = Tabs.init(&titles); + try std.testing.expectEqual(@as(usize, 0), tabs.width()); +} + +test "Tabs width basic" { + const titles = [_]Line{ + Line.raw("A"), + Line.raw("BB"), + Line.raw("CCC"), + }; + // " A " + "|" + " BB " + "|" + " CCC " + // = 3 + 1 + 4 + 1 + 5 = 14 + const tabs = Tabs.init(&titles); + try std.testing.expectEqual(@as(usize, 14), tabs.width()); +} + +test "Tabs width no padding" { + const titles = [_]Line{ + Line.raw("A"), + Line.raw("BB"), + }; + // "A" + "|" + "BB" = 1 + 1 + 2 = 4 + const tabs = Tabs.init(&titles).padding("", ""); + try std.testing.expectEqual(@as(usize, 4), tabs.width()); +}