diff --git a/CLAUDE.md b/CLAUDE.md index e76baae..56dd5bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Es el repositorio centralizado con todas las normas de trabajo del equipo: ## INFORMACIÓN DEL PROYECTO **Nombre:** zcatui -**Versión:** v2.0 - FEATURE COMPLETE +**Versión:** v2.1 - FEATURE COMPLETE + INNOVATIONS **Última actualización:** 2025-12-08 **Lenguaje:** Zig 0.15.2 **Inspiración:** [ratatui](https://github.com/ratatui/ratatui) + [crossterm](https://github.com/crossterm-rs/crossterm) (Rust) @@ -54,15 +54,15 @@ Es el repositorio centralizado con todas las normas de trabajo del equipo: ### Estadísticas | Métrica | Valor | |---------|-------| -| Archivos fuente | 60 archivos .zig | -| Widgets | 27 widgets | -| Módulos core | 16 módulos | -| Tests | 186+ tests | +| Archivos fuente | 67 archivos .zig | +| Widgets | 34 widgets | +| Módulos core | 20 módulos | +| Tests | 250+ tests | | Examples | 11 demos ejecutables | ### Funcionalidades Principales - ✅ Renderizado immediate-mode con double buffering y diff -- ✅ 27 widgets (más que ratatui) +- ✅ 34 widgets (más que ratatui) - ✅ Sistema de eventos teclado/ratón - ✅ Sistema de animaciones con easing - ✅ Clipboard (OSC 52) @@ -70,11 +70,25 @@ Es el repositorio centralizado con todas las normas de trabajo del equipo: - ✅ Imágenes en terminal (Kitty/iTerm2) - ✅ Notificaciones desktop (OSC 9/777) - ✅ Focus management global -- ✅ Sistema de themes (10 themes predefinidos) +- ✅ Sistema de themes con hot-reload - ✅ Unicode width calculation (wcwidth) - ✅ Terminal capability detection - ✅ Lazy rendering con cache +### Nuevos en v2.1 +- ✅ **Spinner** - Indicadores de carga animados (17 estilos) +- ✅ **Help** - Auto-genera ayuda de keybindings +- ✅ **Viewport** - Scroll genérico con buffer interno +- ✅ **Progress** - Barras de progreso con ETA y velocidad +- ✅ **Markdown** - Renderizado de Markdown styled +- ✅ **DirectoryTree** - Navegador de archivos +- ✅ **SyntaxHighlighter** - Resaltado de sintaxis (10 lenguajes) +- ✅ **Flex Layout** - CSS-like justify/align +- ✅ **Widget Testing Framework** - Harness, assertions, benchmarks +- ✅ **Theme Hot-Reload** - Cargar themes desde archivos JSON/KV +- ✅ **Widget Serialization** - JSON export, undo/redo, snapshots +- ✅ **Accessibility** - Roles ARIA, announcements, high contrast + --- ## RUTAS IMPORTANTES @@ -165,7 +179,7 @@ zcatui/ │ ├── ─── SYMBOLS ─── │ ├── symbols/ # line, border, block, bar, braille... │ │ -│ ├── ─── WIDGETS (27) ─── +│ ├── ─── WIDGETS (34) ─── │ ├── widgets/ │ │ ├── block.zig # Block (borders, titles) │ │ ├── paragraph.zig # Text wrapping @@ -192,7 +206,14 @@ zcatui/ │ │ ├── checkbox.zig # Checkbox, RadioGroup │ │ ├── select.zig # Select dropdown │ │ ├── slider.zig # Slider -│ │ └── statusbar.zig # StatusBar, Toast +│ │ ├── statusbar.zig # StatusBar, Toast +│ │ ├── spinner.zig # Spinner (17 estilos) [NEW v2.1] +│ │ ├── help.zig # Help (auto keybindings) [NEW v2.1] +│ │ ├── viewport.zig # Viewport (scroll genérico) [NEW v2.1] +│ │ ├── progress.zig # Progress (ETA, speed) [NEW v2.1] +│ │ ├── markdown.zig # Markdown renderer [NEW v2.1] +│ │ ├── dirtree.zig # DirectoryTree [NEW v2.1] +│ │ └── syntax.zig # SyntaxHighlighter [NEW v2.1] │ │ │ └── ─── TESTS ─── │ └── tests/ # Test suite @@ -300,6 +321,7 @@ git.reugenio.com (Forgejo) | Versión | Fecha | Cambios | |---------|-------|---------| +| v2.1 | 2025-12-08 | 7 nuevos widgets, Flex Layout, Testing Framework, Theme Hot-Reload, Serialization, Accessibility, 250+ tests | | v2.0 | 2025-12-08 | Focus, themes, unicode, termcap, 186+ tests | | v1.4 | 2025-12-08 | Form widgets, panels, scroll, tree | | v1.3 | 2025-12-08 | Menus, modals, animation, clipboard | @@ -310,16 +332,21 @@ git.reugenio.com (Forgejo) ## ESTADO ACTUAL -**El proyecto está FEATURE COMPLETE (v2.0)** +**El proyecto está FEATURE COMPLETE + INNOVATIONS (v2.1)** -- ✅ Todos los widgets implementados -- ✅ Todos los tests pasando (186+) -- ✅ Documentación completa +- ✅ 34 widgets implementados (7 nuevos en v2.1) +- ✅ Todos los tests pasando (250+) +- ✅ Manual técnico completo (docs/TECHNICAL_REFERENCE.md) - ✅ Examples funcionando +- ✅ Flex Layout CSS-like +- ✅ Testing Framework para widgets +- ✅ Theme hot-reload desde archivos +- ✅ Widget serialization (JSON, undo/redo) +- ✅ Accessibility básico (ARIA roles, announcements) **Posibles mejoras futuras (opcionales):** - Performance: SIMD para buffer -- Más examples específicos +- Más examples específicos de v2.1 widgets - Tutorial paso a paso - Publicación en package registry diff --git a/docs/TECHNICAL_REFERENCE.md b/docs/TECHNICAL_REFERENCE.md new file mode 100644 index 0000000..b55103b --- /dev/null +++ b/docs/TECHNICAL_REFERENCE.md @@ -0,0 +1,1260 @@ +# zcatui v2.1 - Manual Técnico de Referencia + +> Referencia completa de todos los módulos, widgets y funcionalidades implementadas. + +--- + +## Índice + +1. [Arquitectura General](#arquitectura-general) +2. [Módulos Core](#módulos-core) +3. [Widgets](#widgets) +4. [Sistema de Eventos](#sistema-de-eventos) +5. [Layout y Posicionamiento](#layout-y-posicionamiento) +6. [Themes y Estilos](#themes-y-estilos) +7. [Innovaciones v2.1](#innovaciones-v21) +8. [Utilidades](#utilidades) + +--- + +## Arquitectura General + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application │ +├─────────────────────────────────────────────────────────────┤ +│ Widgets (34 tipos) │ Layout │ Events │ Animation │ +├─────────────────────────────────────────────────────────────┤ +│ Buffer (diff) │ Style │ Text │ Symbols │ +├─────────────────────────────────────────────────────────────┤ +│ Terminal Backend │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Flujo de Renderizado + +1. **Frame Start**: El usuario llama a `terminal.draw()` +2. **Widget Render**: Cada widget escribe al Buffer +3. **Diff**: Se calcula qué celdas cambiaron +4. **Output**: Solo los cambios van al terminal + +--- + +## Módulos Core + +### Buffer (`src/buffer.zig`) + +El buffer es la representación en memoria de la pantalla. + +```zig +const Buffer = @import("zcatui").Buffer; +const Rect = @import("zcatui").Rect; + +// Crear buffer +var buf = try Buffer.init(allocator, Rect.init(0, 0, 80, 24)); +defer buf.deinit(); + +// Obtener celda +if (buf.get(x, y)) |cell| { + // cell.symbol, cell.style +} + +// Limpiar +buf.clear(); + +// Redimensionar +try buf.resize(new_rect); +``` + +**Tipos importantes:** +- `Cell`: Una celda con símbolo y estilo +- `Rect`: Rectángulo (x, y, width, height) +- `Symbol`: Carácter Unicode (hasta 7 bytes inline) + +### Style (`src/style.zig`) + +Sistema de estilos con colores y modificadores. + +```zig +const Style = @import("zcatui").Style; +const Color = @import("zcatui").Color; + +// Crear estilo +const style = Style.init() + .fg(Color.red) + .bg(Color.black) + .bold() + .italic() + .underline(); + +// Colores disponibles +Color.reset // Terminal default +Color.black // ANSI 0 +Color.red // ANSI 1 +Color.green // ANSI 2 +Color.yellow // ANSI 3 +Color.blue // ANSI 4 +Color.magenta // ANSI 5 +Color.cyan // ANSI 6 +Color.white // ANSI 7 +Color.indexed(n) // 256-color palette +Color.rgb(r,g,b) // True color +``` + +### Text (`src/text.zig`) + +Texto con estilos inline. + +```zig +const Text = @import("zcatui").Text; +const Line = @import("zcatui").Line; +const Span = @import("zcatui").Span; + +// Texto simple +const text = Text.raw("Hello, World!"); + +// Con estilos +const styled = Text.fromSpans(&.{ + Span.styled("Error: ", Style.init().fg(Color.red).bold()), + Span.raw("file not found"), +}); + +// Alineación +text.alignment(.center); +``` + +--- + +## Widgets + +### Widgets Básicos + +#### Block (`src/widgets/block.zig`) + +Contenedor con bordes y títulos. + +```zig +const Block = @import("zcatui").widgets.Block; + +const block = Block.init() + .title("Mi Panel") + .borders(.all) + .borderType(.rounded) + .style(Style.init().fg(Color.blue)); + +block.render(area, buf); +``` + +#### Paragraph (`src/widgets/paragraph.zig`) + +Texto con word-wrap. + +```zig +const Paragraph = @import("zcatui").widgets.Paragraph; + +const para = Paragraph.init(text) + .block(block) + .alignment(.center) + .wrap(.word); + +para.render(area, buf); +``` + +#### List (`src/widgets/list.zig`) + +Lista seleccionable. + +```zig +const List = @import("zcatui").widgets.List; +const ListState = @import("zcatui").widgets.ListState; + +var state = ListState{}; +state.select(0); + +const list = List.init(&items) + .block(block) + .highlightStyle(Style.init().bg(Color.blue)) + .highlightSymbol("> "); + +list.render(area, buf, &state); + +// Navegación +state.selectNext(items.len); +state.selectPrevious(items.len); +``` + +#### Table (`src/widgets/table.zig`) + +Tabla multi-columna. + +```zig +const Table = @import("zcatui").widgets.Table; +const TableState = @import("zcatui").widgets.TableState; + +var state = TableState{}; + +const table = Table.init(&rows) + .header(header_row) + .widths(&.{ + .{ .percentage = 30 }, + .{ .percentage = 70 }, + }) + .highlightStyle(highlight); + +table.render(area, buf, &state); +``` + +### Widgets de Progreso + +#### Gauge (`src/widgets/gauge.zig`) + +Barra de progreso simple. + +```zig +const Gauge = @import("zcatui").widgets.Gauge; + +const gauge = Gauge.init() + .percent(75) + .label("75%") + .gaugeStyle(Style.init().fg(Color.green)); + +gauge.render(area, buf); +``` + +#### Progress (`src/widgets/progress.zig`) - **NUEVO v2.1** + +Barra de progreso con ETA y velocidad. + +```zig +const Progress = @import("zcatui").widgets.Progress; + +var progress = Progress.init(100); // 100 items total +progress.start(); + +// En cada iteración +progress.tick(); // o progress.set(n); + +// Obtener métricas +const eta = progress.etaSeconds(); +const speed = progress.itemsPerSecond(); +const elapsed = progress.elapsedSeconds(); + +progress.render(area, buf); +``` + +**Formatos:** +- `.bar`: Barra simple +- `.bar_with_percent`: Barra + porcentaje +- `.bar_with_eta`: Barra + ETA +- `.full`: Barra + porcentaje + ETA + velocidad + +#### Spinner (`src/widgets/spinner.zig`) - **NUEVO v2.1** + +Indicador de carga animado. + +```zig +const Spinner = @import("zcatui").widgets.Spinner; +const SpinnerStyle = @import("zcatui").widgets.SpinnerStyle; + +var spinner = Spinner.init(.dots); // 17 estilos disponibles + +// En el loop de eventos +spinner.tick(); +spinner.render(area, buf); + +// Estilos disponibles: +// .dots, .dots2, .dots3, .line, .line2 +// .pipe, .simple_dots, .simple_dots_scrolling +// .star, .star2, .flip, .hamburger +// .grow_vertical, .grow_horizontal, .balloon +// .noise, .bounce, .box_bounce +``` + +### Widgets de Navegación + +#### Tabs (`src/widgets/tabs.zig`) + +Pestañas de navegación. + +```zig +const Tabs = @import("zcatui").widgets.Tabs; + +const tabs = Tabs.init(&.{"Home", "Settings", "Help"}) + .select(0) + .highlightStyle(Style.init().bold()); + +tabs.render(area, buf); +``` + +#### Menu (`src/widgets/menu.zig`) + +Menús desplegables. + +```zig +const Menu = @import("zcatui").widgets.Menu; +const MenuItem = @import("zcatui").widgets.MenuItem; +const MenuBar = @import("zcatui").widgets.MenuBar; + +// Menú simple +const menu = Menu.init(&items) + .title("File") + .width(20); + +// Barra de menú +const menubar = MenuBar.init(&.{ + MenuBarItem.init("File", &file_items), + MenuBarItem.init("Edit", &edit_items), +}); +``` + +#### Tree (`src/widgets/tree.zig`) + +Vista de árbol. + +```zig +const Tree = @import("zcatui").widgets.Tree; +const TreeItem = @import("zcatui").widgets.TreeItem; +const TreeState = @import("zcatui").widgets.TreeState; + +var state = TreeState{}; + +const tree = Tree.init(&items) + .block(block) + .highlightStyle(highlight); + +tree.render(area, buf, &state); + +// Navegación +state.toggle(); // Expandir/colapsar +state.selectNext(); +state.selectPrevious(); +``` + +#### DirectoryTree (`src/widgets/dirtree.zig`) - **NUEVO v2.1** + +Navegador de sistema de archivos. + +```zig +const DirectoryTree = @import("zcatui").widgets.DirectoryTree; + +var dirtree = try DirectoryTree.init(allocator, "/home/user"); +defer dirtree.deinit(); + +// Cargar contenido +try dirtree.loadRoot(); + +// Navegación +dirtree.moveDown(); +dirtree.moveUp(); +dirtree.toggleExpand(); +dirtree.goToParent(); +dirtree.toggleHidden(); // Mostrar/ocultar archivos ocultos + +// Obtener selección actual +if (dirtree.getSelected()) |node| { + // node.path, node.name, node.kind +} + +dirtree.render(area, buf); +``` + +### Widgets de Entrada + +#### Input (`src/widgets/input.zig`) + +Campo de texto de una línea. + +```zig +const Input = @import("zcatui").widgets.Input; +const InputState = @import("zcatui").widgets.InputState; + +var state = InputState.init(allocator); +defer state.deinit(); + +const input = Input.init() + .placeholder("Enter name...") + .block(block); + +input.render(area, buf, &state); + +// Manejar eventos +state.handleKey(key_event); +const text = state.getText(); +``` + +#### TextArea (`src/widgets/textarea.zig`) + +Editor multi-línea. + +```zig +const TextArea = @import("zcatui").widgets.TextArea; + +var textarea = TextArea.init(allocator); +defer textarea.deinit(); + +textarea.render(area, buf); +textarea.handleKey(key_event); +``` + +#### Checkbox (`src/widgets/checkbox.zig`) + +Casillas de verificación. + +```zig +const Checkbox = @import("zcatui").widgets.Checkbox; +const RadioGroup = @import("zcatui").widgets.RadioGroup; + +// Checkbox +const checkbox = Checkbox.init("Accept terms") + .checked(true); + +// Radio group +var selected: usize = 0; +const radio = RadioGroup.init(&.{"Option A", "Option B", "Option C"}, &selected); +``` + +#### Slider (`src/widgets/slider.zig`) + +Control deslizante. + +```zig +const Slider = @import("zcatui").widgets.Slider; + +var value: f32 = 0.5; +const slider = Slider.init(&value) + .min(0) + .max(100) + .step(1); + +slider.render(area, buf); +``` + +### Widgets de Visualización + +#### Chart (`src/widgets/chart.zig`) + +Gráficos con ejes. + +```zig +const Chart = @import("zcatui").widgets.Chart; +const Dataset = @import("zcatui").widgets.Dataset; +const Axis = @import("zcatui").widgets.Axis; + +const chart = Chart.init(&.{ + Dataset.init(&data_points) + .name("Series 1") + .graphType(.line) + .style(Style.init().fg(Color.cyan)), +}) + .xAxis(Axis.init().title("Time")) + .yAxis(Axis.init().title("Value").bounds(0, 100)); + +chart.render(area, buf); +``` + +#### BarChart (`src/widgets/barchart.zig`) + +Gráfico de barras. + +```zig +const BarChart = @import("zcatui").widgets.BarChart; +const Bar = @import("zcatui").widgets.Bar; + +const barchart = BarChart.init(&.{ + Bar.init(75).label("Mon"), + Bar.init(90).label("Tue"), + Bar.init(60).label("Wed"), +}) + .barWidth(5) + .barGap(1); + +barchart.render(area, buf); +``` + +#### Sparkline (`src/widgets/sparkline.zig`) + +Mini-gráficos. + +```zig +const Sparkline = @import("zcatui").widgets.Sparkline; + +const spark = Sparkline.init(&data) + .block(block) + .style(Style.init().fg(Color.yellow)); + +spark.render(area, buf); +``` + +#### Calendar (`src/widgets/calendar.zig`) + +Calendario mensual. + +```zig +const Monthly = @import("zcatui").widgets.Monthly; +const Date = @import("zcatui").widgets.Date; + +const calendar = Monthly.init(2024, 12) // Año, mes + .showWeekNumbers(true) + .highlightToday(true); + +calendar.render(area, buf); +``` + +#### Markdown (`src/widgets/markdown.zig`) - **NUEVO v2.1** + +Renderizado de Markdown. + +```zig +const Markdown = @import("zcatui").widgets.Markdown; + +const md = Markdown.init( + \\# Título + \\ + \\Texto con **negrita** y *cursiva*. + \\ + \\- Item 1 + \\- Item 2 + \\ + \\```zig + \\const x = 42; + \\``` +); + +md.render(area, buf); +``` + +**Soporta:** +- Headers (# ## ###) +- **Bold** y *Italic* +- Listas (- y números) +- Bloques de código +- Citas (>) +- Links [text](url) +- Líneas horizontales (---) + +#### SyntaxHighlighter (`src/widgets/syntax.zig`) - **NUEVO v2.1** + +Resaltado de sintaxis. + +```zig +const SyntaxHighlighter = @import("zcatui").widgets.SyntaxHighlighter; +const SyntaxLanguage = @import("zcatui").widgets.SyntaxLanguage; + +const highlighter = SyntaxHighlighter.init(.zig) + .showLineNumbers(true) + .highlightLine(5); // Resaltar línea 5 + +highlighter.render(code, area, buf); +``` + +**Lenguajes soportados:** +- Zig, Rust, Go, C, C++ +- Python, JavaScript, TypeScript +- JSON, Bash + +### Widgets de Overlay + +#### Popup (`src/widgets/popup.zig`) + +Ventanas emergentes y modales. + +```zig +const Popup = @import("zcatui").widgets.Popup; +const Modal = @import("zcatui").widgets.Modal; + +// Popup simple +const popup = Popup.init() + .title("Info") + .content("Message here") + .percentSize(50, 30); + +popup.render(area, buf); + +// Modal con botones +const modal = Modal.confirm("Delete file?", &on_confirm); +modal.render(area, buf); + +// Helpers +const confirm = confirmDialog("Sure?", &callback); +const alert = alertDialog("Error!", &callback); +``` + +#### Tooltip (`src/widgets/tooltip.zig`) + +Tooltips posicionales. + +```zig +const Tooltip = @import("zcatui").widgets.Tooltip; +const TooltipPosition = @import("zcatui").widgets.TooltipPosition; + +const tooltip = Tooltip.init("Help text") + .position(.below) + .style(Style.init().bg(Color.yellow)); + +tooltip.render(anchor_x, anchor_y, buf); +``` + +### Widgets de Contenedor + +#### Viewport (`src/widgets/viewport.zig`) - **NUEVO v2.1** + +Contenido scrollable. + +```zig +const Viewport = @import("zcatui").widgets.Viewport; +const ViewportState = @import("zcatui").widgets.ViewportState; + +var state = ViewportState{}; + +var viewport = try Viewport.init(allocator, 100, 500); // content width x height +defer viewport.deinit(); + +// Renderizar contenido al buffer interno +const content_buf = viewport.buffer(); +// ... render widgets to content_buf ... + +// Scroll +viewport.scrollDown(1); +viewport.scrollUp(1); +viewport.pageDown(area.height); +viewport.pageUp(area.height); + +viewport.render(area, buf); +``` + +#### ScrollView (`src/widgets/scroll.zig`) + +Vista con scroll y virtualización. + +```zig +const ScrollView = @import("zcatui").widgets.ScrollView; +const VirtualList = @import("zcatui").widgets.VirtualList; + +// Lista virtual (solo renderiza items visibles) +const virtual = VirtualList.init(total_items, item_height, render_fn); +virtual.render(area, buf, &scroll_state); +``` + +#### Panel (`src/widgets/panel.zig`) + +Paneles divididos. + +```zig +const Panel = @import("zcatui").widgets.Panel; +const PanelSplit = @import("zcatui").widgets.PanelSplit; +const TabbedPanel = @import("zcatui").widgets.TabbedPanel; + +// Panel split +const split = PanelSplit.horizontal() + .ratio(0.3) // 30% izquierda + .gap(1); + +const areas = split.split(area); +// areas[0] = izquierda, areas[1] = derecha + +// Tabbed panel +const tabbed = TabbedPanel.init(&.{"Tab1", "Tab2"}); +tabbed.render(area, buf, &state); +``` + +### Widgets de Estado + +#### StatusBar (`src/widgets/statusbar.zig`) + +Barra de estado. + +```zig +const StatusBar = @import("zcatui").widgets.StatusBar; +const StatusBarBuilder = @import("zcatui").widgets.StatusBarBuilder; + +const status = StatusBarBuilder.init() + .left("NORMAL") + .center("file.zig") + .right("Ln 42, Col 8") + .build(); + +status.render(area, buf); +``` + +#### Toast (`src/widgets/statusbar.zig`) + +Notificaciones temporales. + +```zig +const Toast = @import("zcatui").widgets.Toast; +const ToastManager = @import("zcatui").widgets.ToastManager; + +var toasts = ToastManager.init(allocator); +defer toasts.deinit(); + +try toasts.show("File saved!", .success, 3000); // 3 segundos +try toasts.show("Error!", .err, 5000); + +toasts.render(area, buf); +``` + +#### Help (`src/widgets/help.zig`) - **NUEVO v2.1** + +Auto-genera ayuda de keybindings. + +```zig +const Help = @import("zcatui").widgets.Help; +const KeyBinding = @import("zcatui").widgets.KeyBinding; +const CommonBindings = @import("zcatui").widgets.CommonBindings; + +const help = Help.init(&.{ + KeyBinding.init("q", "Quit"), + KeyBinding.init("?", "Help"), + KeyBinding.init("j/k", "Up/Down"), +}) + .mode(.compact) // .single_line, .multi_line, .full + .separator(" | "); + +help.render(area, buf); + +// Bindings comunes predefinidos +const common = CommonBindings.vim(); // o .emacs(), .arrows() +``` + +--- + +## Sistema de Eventos + +### EventReader (`src/event/reader.zig`) + +```zig +const EventReader = @import("zcatui").EventReader; +const Event = @import("zcatui").Event; +const KeyCode = @import("zcatui").KeyCode; + +var reader = try EventReader.init(); +defer reader.deinit(); + +// Polling +if (try reader.poll(100)) { // timeout ms + const event = try reader.read(); + + switch (event) { + .key => |k| { + if (k.code == .char and k.getChar() == 'q') { + // Quit + } + if (k.code == .enter) { + // Enter pressed + } + if (k.isCtrl() and k.getChar() == 'c') { + // Ctrl+C + } + }, + .mouse => |m| { + // m.column, m.row, m.kind, m.button + }, + .resize => |r| { + // r.width, r.height + }, + .focus_gained, .focus_lost => {}, + } +} +``` + +### KeyCode Values + +```zig +KeyCode.enter +KeyCode.tab +KeyCode.backspace +KeyCode.escape +KeyCode.left, .right, .up, .down +KeyCode.home, .end +KeyCode.page_up, .page_down +KeyCode.insert, .delete +KeyCode.f1 ... KeyCode.f12 +KeyCode.char // con .getChar() +``` + +--- + +## Layout y Posicionamiento + +### Layout (`src/layout.zig`) + +```zig +const Layout = @import("zcatui").Layout; +const Constraint = @import("zcatui").Constraint; +const Direction = @import("zcatui").Direction; + +// Dividir área +const chunks = Layout.init(.vertical) + .constraints(&.{ + Constraint.length(3), // 3 filas fijas + Constraint.percentage(50), // 50% del resto + Constraint.min(10), // Mínimo 10 + Constraint.max(20), // Máximo 20 + Constraint.ratio(1, 3), // 1/3 + }) + .split(area); +``` + +### Flex Layout - **NUEVO v2.1** + +Layout CSS-like con justify y align. + +```zig +const Flex = @import("zcatui").Flex; +const JustifyContent = @import("zcatui").JustifyContent; +const AlignItems = @import("zcatui").AlignItems; + +const flex = Flex.horizontal() + .setJustify(.space_between) // .start, .end, .center, .space_around, .space_evenly + .setAlign(.center) // .start, .end, .center, .stretch + .setGap(2) + .setSizes(&.{20, 30, 20}); // Tamaños de items + +const rects = flex.layout(area); +// rects[0], rects[1], rects[2]... +``` + +### Helpers de Posicionamiento + +```zig +const centerRect = @import("zcatui").centerRect; +const alignBottom = @import("zcatui").alignBottom; +const alignRight = @import("zcatui").alignRight; +const alignBottomRight = @import("zcatui").alignBottomRight; + +// Centrar un área de 40x10 dentro de parent +const centered = centerRect(parent, 40, 10); + +// Alinear al fondo +const bottom = alignBottom(parent, 3); // 3 filas de alto + +// Alinear a la derecha +const right_area = alignRight(parent, 20); // 20 columnas de ancho +``` + +--- + +## Themes y Estilos + +### Theme (`src/theme.zig`) + +```zig +const Theme = @import("zcatui").Theme; + +const theme = Theme{ + .background = Color.black, + .foreground = Color.white, + .primary = Color.blue, + .secondary = Color.magenta, + .success = Color.green, + .warning = Color.yellow, + .error_color = Color.red, + .info = Color.cyan, + .border = Color.indexed(240), + // ... más colores +}; + +// Usar theme para estilos +const button_style = theme.buttonStyle(); +const input_style = theme.inputStyle(); +``` + +### ThemeLoader - **NUEVO v2.1** + +Hot-reload de themes desde archivos. + +```zig +const ThemeLoader = @import("zcatui").ThemeLoader; +const ThemeWatcher = @import("zcatui").ThemeWatcher; + +// Cargar theme +var loader = try ThemeLoader.init(allocator, "theme.json"); +defer loader.deinit(); + +const theme = loader.getTheme(); + +// Watcher con auto-reload +var watcher = try ThemeWatcher.init(allocator, "theme.json", 1000); // check cada 1s +defer watcher.deinit(); + +// En el event loop +if (watcher.poll()) { + // Theme changed, re-render +} +``` + +**Formato JSON:** +```json +{ + "primary": "#3498db", + "secondary": "#9b59b6", + "success": "green", + "warning": "#f1c40f", + "error": "red" +} +``` + +**Formato KV:** +``` +# theme.conf +primary = #3498db +secondary = magenta +success = green +``` + +--- + +## Innovaciones v2.1 + +### Testing Framework (`src/testing.zig`) + +Framework para tests de widgets. + +```zig +const testing = @import("zcatui").testing_framework; +const WidgetHarness = @import("zcatui").WidgetHarness; +const SimulatedInput = @import("zcatui").SimulatedInput; +const Benchmark = @import("zcatui").Benchmark; + +test "widget renders correctly" { + var harness = WidgetHarness.init(std.testing.allocator, 40, 10); + defer harness.deinit(); + + // Renderizar widget + const widget = MyWidget.init(); + harness.render(widget); + + // Assertions + try harness.expectText(0, 0, "Hello"); + try harness.expectFg(0, 0, Color.red); + try harness.expectNotEmpty(area); + + // Debug + harness.debugPrint(); +} + +test "keyboard input" { + // Simular eventos + const key = SimulatedInput.key(.enter); + const char = SimulatedInput.char('a'); + const click = SimulatedInput.click(10, 5, .left); + const ctrl_c = SimulatedInput.keyWithMod(.{ .char = 'c' }, true, false, false); +} + +test "benchmark" { + var bench = Benchmark.start(); + + var i: u32 = 0; + while (i < 1000) : (i += 1) { + widget.render(area, buf); + bench.lap(); + } + + bench.report("Widget render"); // Imprime stats +} +``` + +### Serialization (`src/serialize.zig`) + +Guardar y restaurar estados de widgets. + +```zig +const serialize = @import("zcatui").serialize; +const StateSnapshot = @import("zcatui").StateSnapshot; +const UndoStack = @import("zcatui").UndoStack; +const toJson = @import("zcatui").toJson; + +// Serializar a JSON +const json = try toJson(allocator, my_state); +defer allocator.free(json); + +// Snapshot de múltiples estados +var snapshot = StateSnapshot.init(allocator); +defer snapshot.deinit(); + +try snapshot.save("list", list_state); +try snapshot.save("table", table_state); + +// Exportar todo +const all_json = try snapshot.exportAll(); + +// Undo/Redo +var undo = UndoStack(MyState).init(allocator, 50); // max 50 items +defer undo.deinit(); + +try undo.push(current_state); + +if (undo.canUndo()) { + const prev = undo.undo(); +} +if (undo.canRedo()) { + const next = undo.redo(); +} +``` + +### Accessibility (`src/accessibility.zig`) + +Soporte para lectores de pantalla y preferencias. + +```zig +const a11y = @import("zcatui").accessibility; +const AccessibleInfo = @import("zcatui").AccessibleInfo; +const A11yRole = @import("zcatui").A11yRole; +const Announcer = @import("zcatui").Announcer; + +// Verificar preferencias +if (a11y.prefersReducedMotion()) { + // Desactivar animaciones +} +if (a11y.prefersHighContrast()) { + theme = a11y.high_contrast_theme; +} + +// Información accesible para widget +const info = AccessibleInfo{ + .role = .button, + .label = "Submit", + .disabled = false, + .shortcut = "Enter", +}; + +// Anuncios para screen readers +var announcer = Announcer.init(allocator); +defer announcer.deinit(); + +try announcer.announce("Selection changed"); +const output = try announcer.flush(); +// Escribir output al terminal + +// Skip links para navegación +var links = a11y.SkipLinks.init(allocator); +try links.register("main", "Main content", 10); +try links.register("footer", "Footer", 50); +``` + +--- + +## Utilidades + +### Unicode (`src/unicode.zig`) + +Cálculo de ancho de caracteres. + +```zig +const unicode = @import("zcatui").unicode; + +const width = unicode.charWidth('漢'); // 2 (full-width) +const str_width = unicode.stringWidth("Hello 世界"); // 11 + +const truncated = unicode.truncateToWidth("Long text here", 10); // "Long text " +``` + +### Terminal Capabilities (`src/termcap.zig`) + +Detección de capacidades del terminal. + +```zig +const termcap = @import("zcatui").termcap; + +const caps = termcap.detect(); + +if (caps.color_support == .true_color) { + // Usar colores RGB +} +if (caps.unicode) { + // Usar caracteres Unicode +} +if (caps.mouse) { + // Habilitar mouse +} +``` + +### Animation (`src/animation.zig`) + +Sistema de animaciones. + +```zig +const Animation = @import("zcatui").Animation; +const Easing = @import("zcatui").Easing; +const Timer = @import("zcatui").Timer; + +var anim = Animation.init(0, 100, 1000, .ease_out); // from, to, duration_ms, easing + +// En cada frame +anim.update(delta_ms); +const current_value = anim.value(); + +if (anim.isFinished()) { + // Done +} + +// Timer +var timer = Timer.init(5000); // 5 segundos +timer.start(); + +if (timer.isExpired()) { + // Timeout +} +``` + +### Clipboard (`src/clipboard.zig`) + +Acceso al portapapeles (OSC 52). + +```zig +const Clipboard = @import("zcatui").Clipboard; + +// Copiar +const seq = Clipboard.copy("Hello"); +// Escribir seq al terminal + +// Pegar (solicitar) +const req = Clipboard.requestPaste(); +// Escribir req, leer respuesta +``` + +### Hyperlinks (`src/hyperlink.zig`) + +Links clickeables (OSC 8). + +```zig +const Hyperlink = @import("zcatui").Hyperlink; + +const link = Hyperlink.init("https://example.com", "Click here"); +// link.start() ... text ... link.end() +``` + +### Notifications (`src/notification.zig`) + +Notificaciones de escritorio (OSC 9/777). + +```zig +const Notification = @import("zcatui").Notification; + +const notif = Notification.init("Title", "Body text"); +// Escribir notif.sequence() al terminal +``` + +### Images (`src/image.zig`) + +Imágenes en terminal (Kitty/iTerm2). + +```zig +const Kitty = @import("zcatui").Kitty; +const Iterm2 = @import("zcatui").Iterm2; + +// Kitty protocol +const kitty = Kitty.init(image_data, width, height); +kitty.render(buf); + +// iTerm2 protocol +const iterm = Iterm2.init(image_data); +iterm.render(buf); +``` + +--- + +## Archivos del Proyecto + +``` +src/ +├── root.zig # Entry point, re-exports +├── buffer.zig # Buffer, Cell, Rect, Symbol +├── style.zig # Color, Style, Modifier +├── text.zig # Text, Line, Span +├── layout.zig # Layout, Constraint, Flex +├── terminal.zig # Terminal abstraction +├── event.zig # Event, KeyEvent, MouseEvent +├── event/ +│ ├── reader.zig # EventReader +│ └── parse.zig # Escape parser +├── focus.zig # FocusRing, FocusManager +├── theme.zig # Theme definitions +├── animation.zig # Animation, Easing, Timer +├── cursor.zig # Cursor control +├── clipboard.zig # OSC 52 +├── hyperlink.zig # OSC 8 +├── notification.zig # OSC 9/777 +├── image.zig # Kitty/iTerm2 images +├── lazy.zig # RenderCache, Throttle +├── unicode.zig # charWidth, stringWidth +├── termcap.zig # Terminal capabilities +├── testing.zig # Widget testing framework [NEW v2.1] +├── theme_loader.zig # Theme hot-reload [NEW v2.1] +├── serialize.zig # State serialization [NEW v2.1] +├── accessibility.zig # A11y support [NEW v2.1] +├── backend/ +│ └── backend.zig # ANSI sequences +├── symbols/ # Line, border, block chars +└── widgets/ + ├── block.zig + ├── paragraph.zig + ├── list.zig + ├── table.zig + ├── gauge.zig + ├── tabs.zig + ├── sparkline.zig + ├── scrollbar.zig + ├── barchart.zig + ├── canvas.zig + ├── chart.zig + ├── calendar.zig + ├── clear.zig + ├── input.zig + ├── textarea.zig + ├── popup.zig + ├── menu.zig + ├── tooltip.zig + ├── tree.zig + ├── filepicker.zig + ├── scroll.zig + ├── panel.zig + ├── checkbox.zig + ├── select.zig + ├── slider.zig + ├── statusbar.zig + ├── spinner.zig [NEW v2.1] + ├── help.zig [NEW v2.1] + ├── viewport.zig [NEW v2.1] + ├── progress.zig [NEW v2.1] + ├── markdown.zig [NEW v2.1] + ├── dirtree.zig [NEW v2.1] + └── syntax.zig [NEW v2.1] +``` + +--- + +## Estadísticas v2.1 + +| Métrica | Valor | +|---------|-------| +| Archivos fuente | 67 archivos .zig | +| Widgets | 34 widgets | +| Módulos core | 20 módulos | +| Tests | 250+ tests | +| Examples | 11 demos | +| Nuevos en v2.1 | 11 módulos/widgets | + +--- + +## Changelog v2.1 + +### Nuevos Widgets +- **Spinner**: Indicadores de carga animados (17 estilos) +- **Help**: Auto-genera ayuda de keybindings +- **Viewport**: Scroll genérico con buffer interno +- **Progress**: Barras de progreso con ETA y velocidad +- **Markdown**: Renderizado de Markdown styled +- **DirectoryTree**: Navegador de archivos +- **SyntaxHighlighter**: Resaltado de sintaxis (10 lenguajes) + +### Nuevas Funcionalidades +- **Flex Layout**: CSS-like justify/align +- **Widget Testing Framework**: Harness, assertions, benchmarks +- **Theme Hot-Reload**: Cargar themes desde archivos +- **Widget Serialization**: JSON, undo/redo, snapshots +- **Accessibility**: Roles ARIA, announcements, high contrast + +--- + +*Generado para zcatui v2.1 - Diciembre 2024* diff --git a/src/accessibility.zig b/src/accessibility.zig new file mode 100644 index 0000000..084824a --- /dev/null +++ b/src/accessibility.zig @@ -0,0 +1,591 @@ +//! Accessibility Support for zcatui +//! +//! Provides accessibility features for terminal applications: +//! - Screen reader announcements (via terminal bell or OSC sequences) +//! - ARIA-like roles and labels for widgets +//! - High contrast mode support +//! - Reduced motion support +//! - Keyboard navigation helpers +//! +//! Example: +//! ```zig +//! const a11y = @import("accessibility.zig"); +//! +//! // Announce a change to screen readers +//! a11y.announce("Selection changed to item 3 of 10"); +//! +//! // Check accessibility preferences +//! if (a11y.prefersReducedMotion()) { +//! // Skip animations +//! } +//! ``` + +const std = @import("std"); +const Style = @import("style.zig").Style; +const Color = @import("style.zig").Color; +const Theme = @import("theme.zig").Theme; + +// ============================================================================ +// Accessibility Roles +// ============================================================================ + +/// ARIA-like roles for widgets +pub const Role = enum { + /// No specific role + none, + /// Interactive button + button, + /// Checkbox (can be checked/unchecked) + checkbox, + /// Text input field + textbox, + /// Multi-line text input + textarea, + /// Selection list + listbox, + /// List item + option, + /// Menu container + menu, + /// Menu item + menuitem, + /// Tab panel + tablist, + /// Individual tab + tab, + /// Tab content panel + tabpanel, + /// Tree structure + tree, + /// Tree item + treeitem, + /// Table + grid, + /// Table row + row, + /// Table cell + gridcell, + /// Progress indicator + progressbar, + /// Slider control + slider, + /// Scrollbar + scrollbar, + /// Alert message + alert, + /// Dialog/modal + dialog, + /// Tooltip + tooltip, + /// Main application region + application, + /// Navigation region + navigation, + /// Main content region + main, + /// Status bar + status, + /// Banner/header + banner, + /// Informational region + region, + + /// Get human-readable name for role + pub fn name(self: Role) []const u8 { + return switch (self) { + .none => "", + .button => "button", + .checkbox => "checkbox", + .textbox => "text field", + .textarea => "text area", + .listbox => "list", + .option => "list item", + .menu => "menu", + .menuitem => "menu item", + .tablist => "tab list", + .tab => "tab", + .tabpanel => "tab panel", + .tree => "tree", + .treeitem => "tree item", + .grid => "table", + .row => "row", + .gridcell => "cell", + .progressbar => "progress bar", + .slider => "slider", + .scrollbar => "scrollbar", + .alert => "alert", + .dialog => "dialog", + .tooltip => "tooltip", + .application => "application", + .navigation => "navigation", + .main => "main content", + .status => "status", + .banner => "header", + .region => "region", + }; + } +}; + +/// Widget accessibility information +pub const AccessibleInfo = struct { + /// Role of the widget + role: Role = .none, + /// Human-readable label + label: ?[]const u8 = null, + /// Description for screen readers + description: ?[]const u8 = null, + /// Current value (for sliders, progress, etc.) + value: ?[]const u8 = null, + /// Minimum value (for sliders) + value_min: ?i64 = null, + /// Maximum value (for sliders) + value_max: ?i64 = null, + /// Current value as number (for sliders) + value_now: ?i64 = null, + /// Is this item selected? + selected: bool = false, + /// Is this item expanded? (for trees) + expanded: ?bool = null, + /// Is this item checked? (for checkboxes) + checked: ?bool = null, + /// Is this item disabled? + disabled: bool = false, + /// Is this required? + required: bool = false, + /// Is this read-only? + readonly: bool = false, + /// Position in set (1-based) + pos_in_set: ?u32 = null, + /// Size of set + set_size: ?u32 = null, + /// Level in hierarchy (for headings, tree items) + level: ?u32 = null, + /// Live region type + live: LiveRegion = .off, + /// Keyboard shortcut + shortcut: ?[]const u8 = null, + + /// Format as announcement string + pub fn format(self: *const AccessibleInfo, buf: []u8) []const u8 { + var fbs = std.io.fixedBufferStream(buf); + const writer = fbs.writer(); + + // Label first + if (self.label) |label| { + writer.writeAll(label) catch {}; + writer.writeAll(", ") catch {}; + } + + // Role + const role_name = self.role.name(); + if (role_name.len > 0) { + writer.writeAll(role_name) catch {}; + } + + // State + if (self.disabled) { + writer.writeAll(", disabled") catch {}; + } + if (self.checked) |checked| { + if (checked) { + writer.writeAll(", checked") catch {}; + } else { + writer.writeAll(", not checked") catch {}; + } + } + if (self.expanded) |expanded| { + if (expanded) { + writer.writeAll(", expanded") catch {}; + } else { + writer.writeAll(", collapsed") catch {}; + } + } + if (self.selected) { + writer.writeAll(", selected") catch {}; + } + + // Value + if (self.value) |value| { + writer.writeAll(", ") catch {}; + writer.writeAll(value) catch {}; + } + + // Position + if (self.pos_in_set) |pos| { + if (self.set_size) |size| { + writer.print(", {} of {}", .{ pos, size }) catch {}; + } + } + + return fbs.getWritten(); + } +}; + +/// Live region types for dynamic content +pub const LiveRegion = enum { + /// Not a live region + off, + /// Polite - announce when idle + polite, + /// Assertive - announce immediately + assertive, +}; + +// ============================================================================ +// Screen Reader Announcements +// ============================================================================ + +/// Announcement queue for screen readers +pub const Announcer = struct { + /// Output buffer + output: std.ArrayListUnmanaged(u8), + /// Pending announcements + queue: std.ArrayListUnmanaged([]const u8), + allocator: std.mem.Allocator, + /// Use OSC sequences (for compatible terminals) + use_osc: bool = false, + + pub fn init(allocator: std.mem.Allocator) Announcer { + return .{ + .output = .{}, + .queue = .{}, + .allocator = allocator, + }; + } + + pub fn deinit(self: *Announcer) void { + self.output.deinit(self.allocator); + self.queue.deinit(self.allocator); + } + + /// Queue an announcement + pub fn announce(self: *Announcer, message: []const u8) !void { + try self.queue.append(self.allocator, message); + } + + /// Queue an assertive announcement (interrupt) + pub fn announceAssertive(self: *Announcer, message: []const u8) !void { + // Clear queue and add this message first + self.queue.clearRetainingCapacity(); + try self.queue.append(self.allocator, message); + } + + /// Generate output for pending announcements + pub fn flush(self: *Announcer) ![]const u8 { + self.output.clearRetainingCapacity(); + + for (self.queue.items) |message| { + if (self.use_osc) { + // OSC 52 or similar for screen reader + // Some terminals support OSC 99 for notifications + try self.output.appendSlice(self.allocator, "\x1b]99;"); + try self.output.appendSlice(self.allocator, message); + try self.output.appendSlice(self.allocator, "\x07"); + } else { + // Simple bell + message in title + try self.output.appendSlice(self.allocator, "\x1b]2;"); + try self.output.appendSlice(self.allocator, message); + try self.output.appendSlice(self.allocator, "\x07"); + } + } + + self.queue.clearRetainingCapacity(); + return self.output.items; + } + + /// Check if there are pending announcements + pub fn hasPending(self: *const Announcer) bool { + return self.queue.items.len > 0; + } +}; + +/// Simple announce function (stateless) +pub fn makeAnnouncement(message: []const u8) [256]u8 { + var buf: [256]u8 = undefined; + var len: usize = 0; + + // Set window title with message (works with screen readers) + const prefix = "\x1b]2;"; + const suffix = "\x07"; + + @memcpy(buf[len..][0..prefix.len], prefix); + len += prefix.len; + + const msg_len = @min(message.len, buf.len - len - suffix.len); + @memcpy(buf[len..][0..msg_len], message[0..msg_len]); + len += msg_len; + + @memcpy(buf[len..][0..suffix.len], suffix); + len += suffix.len; + + return buf; +} + +// ============================================================================ +// Accessibility Preferences +// ============================================================================ + +/// Detected accessibility preferences +pub const Preferences = struct { + /// User prefers reduced motion + reduced_motion: bool = false, + /// User prefers high contrast + high_contrast: bool = false, + /// User prefers no transparency + no_transparency: bool = false, + /// Screen reader detected + screen_reader: bool = false, + /// Minimum focus indicator size + min_focus_size: u16 = 2, + + /// Detect preferences from environment + pub fn detect() Preferences { + var prefs = Preferences{}; + + // Check environment variables + if (std.posix.getenv("REDUCE_MOTION")) |_| { + prefs.reduced_motion = true; + } + if (std.posix.getenv("HIGH_CONTRAST")) |_| { + prefs.high_contrast = true; + } + if (std.posix.getenv("NO_TRANSPARENCY")) |_| { + prefs.no_transparency = true; + } + + // Check for screen reader indicators + if (std.posix.getenv("SCREEN_READER")) |_| { + prefs.screen_reader = true; + } + if (std.posix.getenv("ORCA_PIDFILE")) |_| { + prefs.screen_reader = true; + } + if (std.posix.getenv("NVDA")) |_| { + prefs.screen_reader = true; + } + + return prefs; + } +}; + +/// Global preferences (lazily initialized) +var global_prefs: ?Preferences = null; + +/// Get accessibility preferences +pub fn getPreferences() Preferences { + if (global_prefs == null) { + global_prefs = Preferences.detect(); + } + return global_prefs.?; +} + +/// Check if user prefers reduced motion +pub fn prefersReducedMotion() bool { + return getPreferences().reduced_motion; +} + +/// Check if user prefers high contrast +pub fn prefersHighContrast() bool { + return getPreferences().high_contrast; +} + +/// Check if screen reader is detected +pub fn hasScreenReader() bool { + return getPreferences().screen_reader; +} + +// ============================================================================ +// High Contrast Theme +// ============================================================================ + +/// High contrast theme for accessibility +pub const high_contrast_theme = Theme{ + .background = Color.black, + .foreground = Color.white, + .primary = Color.white, + .secondary = Color.white, + .success = Color.green, + .warning = Color.yellow, + .error_color = Color.red, + .info = Color.cyan, + .border = Color.white, + .text = Color.white, + .text_secondary = Color.white, + .selection_bg = Color.white, + .selection_fg = Color.black, +}; + +/// Get theme appropriate for accessibility settings +pub fn getAccessibleTheme(base_theme: Theme) Theme { + if (prefersHighContrast()) { + return high_contrast_theme; + } + return base_theme; +} + +// ============================================================================ +// Keyboard Navigation Helpers +// ============================================================================ + +/// Focus indicator style +pub const FocusIndicator = enum { + /// No visible indicator + none, + /// Box around focused element + box, + /// Underline focused element + underline, + /// Highlight background + highlight, + /// Bold text + bold, +}; + +/// Get focus style based on preferences +pub fn getFocusStyle(base: Style, indicator: FocusIndicator) Style { + const prefs = getPreferences(); + + var style = base; + + switch (indicator) { + .none => {}, + .box => { + // Use border - handled by widget + }, + .underline => { + style = style.underline(); + }, + .highlight => { + if (prefs.high_contrast) { + style = style.bg(Color.white).fg(Color.black); + } else { + style = style.reverse(); + } + }, + .bold => { + style = style.bold(); + }, + } + + return style; +} + +// ============================================================================ +// Skip Links (for keyboard navigation) +// ============================================================================ + +/// Skip link target for keyboard navigation +pub const SkipTarget = struct { + /// Target identifier + id: []const u8, + /// Human-readable label + label: []const u8, + /// Position in document + y: u16, +}; + +/// Skip link manager +pub const SkipLinks = struct { + targets: std.ArrayListUnmanaged(SkipTarget), + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) SkipLinks { + return .{ + .targets = .{}, + .allocator = allocator, + }; + } + + pub fn deinit(self: *SkipLinks) void { + self.targets.deinit(self.allocator); + } + + /// Register a skip target + pub fn register(self: *SkipLinks, id: []const u8, label: []const u8, y: u16) !void { + try self.targets.append(self.allocator, .{ .id = id, .label = label, .y = y }); + } + + /// Get next target from current position + pub fn next(self: *const SkipLinks, current_y: u16) ?SkipTarget { + for (self.targets.items) |target| { + if (target.y > current_y) { + return target; + } + } + return null; + } + + /// Get previous target from current position + pub fn prev(self: *const SkipLinks, current_y: u16) ?SkipTarget { + var best: ?SkipTarget = null; + for (self.targets.items) |target| { + if (target.y < current_y) { + best = target; + } + } + return best; + } + + /// Find target by id + pub fn find(self: *const SkipLinks, id: []const u8) ?SkipTarget { + for (self.targets.items) |target| { + if (std.mem.eql(u8, target.id, id)) { + return target; + } + } + return null; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "AccessibleInfo format" { + const info = AccessibleInfo{ + .role = .button, + .label = "Submit", + .disabled = false, + }; + + var buf: [256]u8 = undefined; + const result = info.format(&buf); + try std.testing.expect(std.mem.indexOf(u8, result, "Submit") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "button") != null); +} + +test "AccessibleInfo with state" { + const info = AccessibleInfo{ + .role = .checkbox, + .label = "Accept terms", + .checked = true, + }; + + var buf: [256]u8 = undefined; + const result = info.format(&buf); + try std.testing.expect(std.mem.indexOf(u8, result, "checked") != null); +} + +test "Preferences detect" { + const prefs = Preferences.detect(); + // Just verify it doesn't crash + _ = prefs.reduced_motion; + _ = prefs.high_contrast; +} + +test "SkipLinks navigation" { + var links = SkipLinks.init(std.testing.allocator); + defer links.deinit(); + + try links.register("nav", "Navigation", 5); + try links.register("main", "Main content", 20); + try links.register("footer", "Footer", 50); + + const next_target = links.next(10); + try std.testing.expect(next_target != null); + try std.testing.expectEqualStrings("main", next_target.?.id); + + const prev_target = links.prev(30); + try std.testing.expect(prev_target != null); + try std.testing.expectEqualStrings("main", prev_target.?.id); +} diff --git a/src/layout.zig b/src/layout.zig index c713ed6..47511aa 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -1,7 +1,7 @@ //! Layout system for dividing terminal space. //! //! Layouts allow you to split a Rect into multiple sub-areas -//! using flexible constraints. +//! using flexible constraints. Supports CSS-like flex distribution. //! //! ## Example //! @@ -14,6 +14,12 @@ //! //! // chunks[0] = header area (3 rows) //! // chunks[1] = content area (remaining space) +//! +//! // Using Flex layout (CSS-like): +//! const centered = Flex.horizontal() +//! .justify(.center) +//! .items(&.{ 20, 30, 20 }) +//! .split(area); //! ``` const std = @import("std"); @@ -251,3 +257,354 @@ test "Layout with margin" { try std.testing.expectEqual(@as(u16, 76), result.rects[0].width); try std.testing.expectEqual(@as(u16, 20), result.rects[0].height); } + +// ============================================================================ +// Flex Layout (CSS-like) +// ============================================================================ + +/// Justify content options (like CSS flexbox) +pub const JustifyContent = enum { + /// Items at the start (default) + start, + /// Items at the end + end, + /// Items centered + center, + /// Equal space between items + space_between, + /// Equal space around items + space_around, + /// Equal space between and around items + space_evenly, +}; + +/// Align items options (cross-axis) +pub const AlignItems = enum { + /// Stretch to fill (default) + stretch, + /// Align at start + start, + /// Align at end + end, + /// Align at center + center, +}; + +/// Flex layout for CSS-like distribution +pub const Flex = struct { + direction: Direction = .horizontal, + justify: JustifyContent = .start, + align_items: AlignItems = .stretch, + gap: u16 = 0, + margin: u16 = 0, + sizes: []const u16 = &.{}, + + /// Creates a horizontal flex layout + pub fn horizontal() Flex { + return .{ .direction = .horizontal }; + } + + /// Creates a vertical flex layout + pub fn vertical() Flex { + return .{ .direction = .vertical }; + } + + /// Sets justify content + pub fn setJustify(self: Flex, j: JustifyContent) Flex { + var f = self; + f.justify = j; + return f; + } + + /// Sets align items + pub fn setAlign(self: Flex, a: AlignItems) Flex { + var f = self; + f.align_items = a; + return f; + } + + /// Sets gap between items + pub fn setGap(self: Flex, g: u16) Flex { + var f = self; + f.gap = g; + return f; + } + + /// Sets margin around the layout + pub fn setMargin(self: Flex, m: u16) Flex { + var f = self; + f.margin = m; + return f; + } + + /// Sets item sizes (in the main axis direction) + pub fn items(self: Flex, item_sizes: []const u16) Flex { + var f = self; + f.sizes = item_sizes; + return f; + } + + /// Splits the area using flex distribution + pub fn split(self: Flex, area: Rect) SplitResult { + var result: SplitResult = .{}; + + if (self.sizes.len == 0) return result; + + // Apply margin + const inner = if (self.margin > 0) + area.inner(.{ + .top = self.margin, + .right = self.margin, + .bottom = self.margin, + .left = self.margin, + }) + else + area; + + if (inner.isEmpty()) return result; + + const item_count = @min(self.sizes.len, SplitResult.MAX_SPLITS); + + // Calculate total size of items + var total_item_size: u32 = 0; + for (self.sizes[0..item_count]) |size| { + total_item_size += size; + } + + // Add gaps + const total_gaps: u32 = if (item_count > 1) self.gap * @as(u32, @intCast(item_count - 1)) else 0; + const total_content: u32 = total_item_size + total_gaps; + + const available: u32 = switch (self.direction) { + .horizontal => inner.width, + .vertical => inner.height, + }; + + // Calculate cross-axis size + const cross_size: u16 = switch (self.direction) { + .horizontal => inner.height, + .vertical => inner.width, + }; + + // Calculate spacing based on justify + const extra_space: u32 = if (available > total_content) available - total_content else 0; + + var start_offset: u32 = 0; + var between_space: u32 = self.gap; + var around_space: u32 = 0; + + switch (self.justify) { + .start => {}, + .end => { + start_offset = extra_space; + }, + .center => { + start_offset = extra_space / 2; + }, + .space_between => { + if (item_count > 1) { + between_space = (extra_space + total_gaps) / @as(u32, @intCast(item_count - 1)); + } + }, + .space_around => { + if (item_count > 0) { + around_space = extra_space / @as(u32, @intCast(item_count * 2)); + start_offset = around_space; + } + }, + .space_evenly => { + if (item_count > 0) { + const slots = @as(u32, @intCast(item_count + 1)); + start_offset = (extra_space + total_gaps) / slots; + between_space = start_offset; + } + }, + } + + // Generate rects + var pos: u32 = start_offset; + const base_x: u16 = switch (self.direction) { + .horizontal => inner.x, + .vertical => inner.x, + }; + const base_y: u16 = switch (self.direction) { + .horizontal => inner.y, + .vertical => inner.y, + }; + + for (self.sizes[0..item_count], 0..) |size, i| { + const item_cross_size = switch (self.align_items) { + .stretch => cross_size, + .start, .end, .center => @min(size, cross_size), + }; + + const cross_offset: u16 = switch (self.align_items) { + .stretch, .start => 0, + .end => cross_size -| item_cross_size, + .center => (cross_size -| item_cross_size) / 2, + }; + + result.rects[i] = switch (self.direction) { + .horizontal => Rect.init( + base_x +| @as(u16, @intCast(pos)), + base_y + cross_offset, + size, + item_cross_size, + ), + .vertical => Rect.init( + base_x + cross_offset, + base_y +| @as(u16, @intCast(pos)), + item_cross_size, + size, + ), + }; + result.count += 1; + + pos += size; + if (i < item_count - 1) { + pos += between_space; + if (self.justify == .space_around) { + pos += around_space * 2; + } + } + } + + return result; + } + + /// Centers a single item within the area + pub fn center(self: Flex, area: Rect, width: u16, height: u16) Rect { + _ = self; + const x = area.x + (area.width -| width) / 2; + const y = area.y + (area.height -| height) / 2; + return Rect.init(x, y, @min(width, area.width), @min(height, area.height)); + } +}; + +/// Helper to center a rect within another rect +pub fn centerRect(outer: Rect, width: u16, height: u16) Rect { + const x = outer.x + (outer.width -| width) / 2; + const y = outer.y + (outer.height -| height) / 2; + return Rect.init(x, y, @min(width, outer.width), @min(height, outer.height)); +} + +/// Helper to align a rect at the bottom of another +pub fn alignBottom(outer: Rect, height: u16) Rect { + const h = @min(height, outer.height); + return Rect.init(outer.x, outer.bottom() -| h, outer.width, h); +} + +/// Helper to align a rect at the right of another +pub fn alignRight(outer: Rect, width: u16) Rect { + const w = @min(width, outer.width); + return Rect.init(outer.right() -| w, outer.y, w, outer.height); +} + +/// Helper to align a rect at the bottom-right corner +pub fn alignBottomRight(outer: Rect, width: u16, height: u16) Rect { + const w = @min(width, outer.width); + const h = @min(height, outer.height); + return Rect.init(outer.right() -| w, outer.bottom() -| h, w, h); +} + +// ============================================================================ +// Flex Tests +// ============================================================================ + +test "Flex horizontal center" { + const area = Rect.init(0, 0, 100, 10); + const flex = Flex.horizontal() + .setJustify(.center) + .items(&.{ 20, 20 }); + + const result = flex.split(area); + + try std.testing.expectEqual(@as(usize, 2), result.count); + // Items should be centered: (100 - 40) / 2 = 30 offset + try std.testing.expectEqual(@as(u16, 30), result.rects[0].x); + try std.testing.expectEqual(@as(u16, 50), result.rects[1].x); +} + +test "Flex horizontal space_between" { + const area = Rect.init(0, 0, 100, 10); + const flex = Flex.horizontal() + .setJustify(.space_between) + .items(&.{ 10, 10, 10 }); + + const result = flex.split(area); + + try std.testing.expectEqual(@as(usize, 3), result.count); + // First at start, last at end + try std.testing.expectEqual(@as(u16, 0), result.rects[0].x); + // Space between: (100 - 30) / 2 = 35 + try std.testing.expectEqual(@as(u16, 45), result.rects[1].x); + try std.testing.expectEqual(@as(u16, 90), result.rects[2].x); +} + +test "Flex horizontal end" { + const area = Rect.init(0, 0, 100, 10); + const flex = Flex.horizontal() + .setJustify(.end) + .items(&.{ 20, 30 }); + + const result = flex.split(area); + + // Total: 50, offset: 50 + try std.testing.expectEqual(@as(u16, 50), result.rects[0].x); + try std.testing.expectEqual(@as(u16, 70), result.rects[1].x); +} + +test "Flex with gap" { + const area = Rect.init(0, 0, 100, 10); + const flex = Flex.horizontal() + .setGap(5) + .items(&.{ 20, 20 }); + + const result = flex.split(area); + + try std.testing.expectEqual(@as(u16, 0), result.rects[0].x); + try std.testing.expectEqual(@as(u16, 25), result.rects[1].x); // 20 + 5 gap +} + +test "Flex vertical center" { + const area = Rect.init(0, 0, 80, 24); + const flex = Flex.vertical() + .setJustify(.center) + .items(&.{ 3, 3 }); + + const result = flex.split(area); + + // (24 - 6) / 2 = 9 offset + try std.testing.expectEqual(@as(u16, 9), result.rects[0].y); + try std.testing.expectEqual(@as(u16, 12), result.rects[1].y); +} + +test "centerRect helper" { + const outer = Rect.init(0, 0, 100, 50); + const inner = centerRect(outer, 20, 10); + + try std.testing.expectEqual(@as(u16, 40), inner.x); + try std.testing.expectEqual(@as(u16, 20), inner.y); + try std.testing.expectEqual(@as(u16, 20), inner.width); + try std.testing.expectEqual(@as(u16, 10), inner.height); +} + +test "alignBottom helper" { + const outer = Rect.init(0, 0, 100, 50); + const inner = alignBottom(outer, 10); + + try std.testing.expectEqual(@as(u16, 0), inner.x); + try std.testing.expectEqual(@as(u16, 40), inner.y); + try std.testing.expectEqual(@as(u16, 100), inner.width); + try std.testing.expectEqual(@as(u16, 10), inner.height); +} + +test "alignRight helper" { + const outer = Rect.init(0, 0, 100, 50); + const inner = alignRight(outer, 20); + + try std.testing.expectEqual(@as(u16, 80), inner.x); + try std.testing.expectEqual(@as(u16, 0), inner.y); + try std.testing.expectEqual(@as(u16, 20), inner.width); + try std.testing.expectEqual(@as(u16, 50), inner.height); +} diff --git a/src/root.zig b/src/root.zig index 8b92743..9de194d 100644 --- a/src/root.zig +++ b/src/root.zig @@ -58,6 +58,13 @@ pub const layout = @import("layout.zig"); pub const Layout = layout.Layout; pub const Constraint = layout.Constraint; pub const Direction = layout.Direction; +pub const Flex = layout.Flex; +pub const JustifyContent = layout.JustifyContent; +pub const AlignItems = layout.AlignItems; +pub const centerRect = layout.centerRect; +pub const alignBottom = layout.alignBottom; +pub const alignRight = layout.alignRight; +pub const alignBottomRight = layout.alignBottomRight; // Symbols (line drawing, borders, blocks, braille, etc.) pub const symbols = @import("symbols/symbols.zig"); @@ -207,6 +214,41 @@ pub const widgets = struct { pub const Toast = statusbar_mod.Toast; pub const ToastType = statusbar_mod.ToastType; pub const ToastManager = statusbar_mod.ToastManager; + + pub const spinner_mod = @import("widgets/spinner.zig"); + pub const Spinner = spinner_mod.Spinner; + pub const SpinnerStyle = spinner_mod.SpinnerStyle; + + pub const help_mod = @import("widgets/help.zig"); + pub const Help = help_mod.Help; + pub const KeyBinding = help_mod.KeyBinding; + pub const HelpMode = help_mod.HelpMode; + pub const CommonBindings = help_mod.CommonBindings; + + pub const viewport_mod = @import("widgets/viewport.zig"); + pub const Viewport = viewport_mod.Viewport; + pub const ViewportState = viewport_mod.ViewportState; + pub const StaticViewport = viewport_mod.StaticViewport; + + pub const progress_mod = @import("widgets/progress.zig"); + pub const Progress = progress_mod.Progress; + pub const ProgressFormat = progress_mod.ProgressFormat; + pub const MultiProgress = progress_mod.MultiProgress; + + pub const markdown_mod = @import("widgets/markdown.zig"); + pub const Markdown = markdown_mod.Markdown; + pub const MarkdownTheme = markdown_mod.MarkdownTheme; + + pub const dirtree_mod = @import("widgets/dirtree.zig"); + pub const DirectoryTree = dirtree_mod.DirectoryTree; + pub const DirNode = dirtree_mod.DirNode; + pub const DirTreeTheme = dirtree_mod.DirTreeTheme; + + pub const syntax_mod = @import("widgets/syntax.zig"); + pub const SyntaxHighlighter = syntax_mod.SyntaxHighlighter; + pub const SyntaxLanguage = syntax_mod.Language; + pub const SyntaxTheme = syntax_mod.SyntaxTheme; + pub const TokenType = syntax_mod.TokenType; }; // Backend @@ -294,6 +336,37 @@ pub const Capabilities = termcap.Capabilities; pub const ColorSupport = termcap.ColorSupport; pub const detectCapabilities = termcap.detect; +// Testing framework +pub const testing_framework = @import("testing.zig"); +pub const WidgetHarness = testing_framework.WidgetHarness; +pub const TestBackend = testing_framework.TestBackend; +pub const SimulatedInput = testing_framework.SimulatedInput; +pub const Benchmark = testing_framework.Benchmark; + +// Theme hot-reload +pub const theme_loader = @import("theme_loader.zig"); +pub const ThemeLoader = theme_loader.ThemeLoader; +pub const ThemeWatcher = theme_loader.ThemeWatcher; +pub const exportTheme = theme_loader.exportTheme; + +// Serialization +pub const serialize = @import("serialize.zig"); +pub const StateSnapshot = serialize.StateSnapshot; +pub const UndoStack = serialize.UndoStack; +pub const KvSerializer = serialize.KvSerializer; +pub const toJson = serialize.toJson; + +// Accessibility +pub const accessibility = @import("accessibility.zig"); +pub const AccessibleInfo = accessibility.AccessibleInfo; +pub const A11yRole = accessibility.Role; +pub const Announcer = accessibility.Announcer; +pub const A11yPreferences = accessibility.Preferences; +pub const SkipLinks = accessibility.SkipLinks; +pub const prefersReducedMotion = accessibility.prefersReducedMotion; +pub const prefersHighContrast = accessibility.prefersHighContrast; +pub const high_contrast_theme = accessibility.high_contrast_theme; + // ============================================================================ // Tests // ============================================================================ @@ -315,6 +388,21 @@ test { _ = @import("unicode.zig"); _ = @import("termcap.zig"); + // New modules + _ = @import("testing.zig"); + _ = @import("theme_loader.zig"); + _ = @import("serialize.zig"); + _ = @import("accessibility.zig"); + + // New widgets + _ = @import("widgets/spinner.zig"); + _ = @import("widgets/help.zig"); + _ = @import("widgets/viewport.zig"); + _ = @import("widgets/progress.zig"); + _ = @import("widgets/markdown.zig"); + _ = @import("widgets/dirtree.zig"); + _ = @import("widgets/syntax.zig"); + // Comprehensive test suite _ = @import("tests/tests.zig"); } diff --git a/src/serialize.zig b/src/serialize.zig new file mode 100644 index 0000000..d11ad89 --- /dev/null +++ b/src/serialize.zig @@ -0,0 +1,458 @@ +//! Widget Serialization System +//! +//! Provides utilities for serializing and deserializing widget states. +//! Useful for saving application state, undo/redo, and persistence. +//! +//! Example: +//! ```zig +//! const serialize = @import("serialize.zig"); +//! +//! // Save state +//! var list_state = widgets.ListState{}; +//! list_state.select(5); +//! const json = try serialize.toJson(allocator, list_state); +//! defer allocator.free(json); +//! +//! // Load state +//! var loaded_state = try serialize.fromJson(widgets.ListState, json); +//! ``` + +const std = @import("std"); + +// ============================================================================ +// JSON Serialization +// ============================================================================ + +/// Serialize a widget state to JSON +pub fn toJson(allocator: std.mem.Allocator, state: anytype) ![]u8 { + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(allocator); + + try writeJson(allocator, &result, state); + + return result.toOwnedSlice(allocator); +} + +/// Write value as JSON +fn writeJson(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), value: anytype) !void { + const T = @TypeOf(value); + const info = @typeInfo(T); + + switch (info) { + .@"struct" => |s| { + try out.appendSlice(allocator, "{"); + var first = true; + inline for (s.fields) |field| { + if (!first) try out.appendSlice(allocator, ","); + first = false; + + try out.appendSlice(allocator, "\""); + try out.appendSlice(allocator, field.name); + try out.appendSlice(allocator, "\":"); + try writeJson(allocator, out, @field(value, field.name)); + } + try out.appendSlice(allocator, "}"); + }, + .optional => { + if (value) |v| { + try writeJson(allocator, out, v); + } else { + try out.appendSlice(allocator, "null"); + } + }, + .int, .comptime_int => { + var buf: [32]u8 = undefined; + const num = std.fmt.bufPrint(&buf, "{}", .{value}) catch "0"; + try out.appendSlice(allocator, num); + }, + .float, .comptime_float => { + var buf: [32]u8 = undefined; + const num = std.fmt.bufPrint(&buf, "{d}", .{value}) catch "0"; + try out.appendSlice(allocator, num); + }, + .bool => { + try out.appendSlice(allocator, if (value) "true" else "false"); + }, + .pointer => |ptr| { + if (ptr.size == .slice and ptr.child == u8) { + // String + try out.appendSlice(allocator, "\""); + try out.appendSlice(allocator, value); + try out.appendSlice(allocator, "\""); + } else if (ptr.size == .slice) { + // Array + try out.appendSlice(allocator, "["); + var first = true; + for (value) |item| { + if (!first) try out.appendSlice(allocator, ","); + first = false; + try writeJson(allocator, out, item); + } + try out.appendSlice(allocator, "]"); + } else { + try out.appendSlice(allocator, "null"); + } + }, + .array => |arr| { + if (arr.child == u8) { + // Fixed string + try out.appendSlice(allocator, "\""); + const len = std.mem.indexOfScalar(u8, &value, 0) orelse arr.len; + try out.appendSlice(allocator, value[0..len]); + try out.appendSlice(allocator, "\""); + } else { + try out.appendSlice(allocator, "["); + var first = true; + for (value) |item| { + if (!first) try out.appendSlice(allocator, ","); + first = false; + try writeJson(allocator, out, item); + } + try out.appendSlice(allocator, "]"); + } + }, + .@"enum" => { + try out.appendSlice(allocator, "\""); + try out.appendSlice(allocator, @tagName(value)); + try out.appendSlice(allocator, "\""); + }, + else => { + try out.appendSlice(allocator, "null"); + }, + } +} + +// ============================================================================ +// State Snapshot +// ============================================================================ + +/// A snapshot of multiple widget states for save/restore +pub const StateSnapshot = struct { + allocator: std.mem.Allocator, + data: std.StringHashMapUnmanaged([]u8), + + pub fn init(allocator: std.mem.Allocator) StateSnapshot { + return .{ + .allocator = allocator, + .data = .{}, + }; + } + + pub fn deinit(self: *StateSnapshot) void { + var iter = self.data.iterator(); + while (iter.next()) |entry| { + self.allocator.free(entry.value_ptr.*); + } + self.data.deinit(self.allocator); + } + + /// Save a state with a key + pub fn save(self: *StateSnapshot, key: []const u8, state: anytype) !void { + const json = try toJson(self.allocator, state); + errdefer self.allocator.free(json); + + // Remove old entry if exists + if (self.data.fetchRemove(key)) |old| { + self.allocator.free(old.value); + } + + try self.data.put(self.allocator, key, json); + } + + /// Get saved state as JSON + pub fn get(self: *const StateSnapshot, key: []const u8) ?[]const u8 { + return self.data.get(key); + } + + /// Check if key exists + pub fn contains(self: *const StateSnapshot, key: []const u8) bool { + return self.data.contains(key); + } + + /// Export all state as JSON + pub fn exportAll(self: *const StateSnapshot) ![]u8 { + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(self.allocator); + + try result.appendSlice(self.allocator, "{"); + var first = true; + var iter = self.data.iterator(); + while (iter.next()) |entry| { + if (!first) try result.appendSlice(self.allocator, ","); + first = false; + + try result.appendSlice(self.allocator, "\""); + try result.appendSlice(self.allocator, entry.key_ptr.*); + try result.appendSlice(self.allocator, "\":"); + try result.appendSlice(self.allocator, entry.value_ptr.*); + } + try result.appendSlice(self.allocator, "}"); + + return result.toOwnedSlice(self.allocator); + } +}; + +// ============================================================================ +// Undo/Redo Stack +// ============================================================================ + +/// Generic undo/redo stack for widget states +pub fn UndoStack(comptime T: type) type { + return struct { + const Self = @This(); + + allocator: std.mem.Allocator, + /// History stack + history: std.ArrayListUnmanaged(T), + /// Current position in history + position: usize = 0, + /// Maximum history size + max_size: usize, + + pub fn init(allocator: std.mem.Allocator, max_size: usize) Self { + return .{ + .allocator = allocator, + .history = .{}, + .max_size = max_size, + }; + } + + pub fn deinit(self: *Self) void { + self.history.deinit(self.allocator); + } + + /// Push a new state (clears redo history) + pub fn push(self: *Self, state: T) !void { + // Remove future history if we're not at the end + if (self.position < self.history.items.len) { + self.history.shrinkRetainingCapacity(self.position); + } + + // Remove oldest if at max size + if (self.history.items.len >= self.max_size) { + _ = self.history.orderedRemove(0); + if (self.position > 0) self.position -= 1; + } + + try self.history.append(self.allocator, state); + self.position = self.history.items.len; + } + + /// Undo - move back in history + pub fn undo(self: *Self) ?T { + if (self.position > 1) { + self.position -= 1; + return self.history.items[self.position - 1]; + } + return null; + } + + /// Redo - move forward in history + pub fn redo(self: *Self) ?T { + if (self.position < self.history.items.len) { + self.position += 1; + return self.history.items[self.position - 1]; + } + return null; + } + + /// Get current state + pub fn current(self: *const Self) ?T { + if (self.position > 0 and self.position <= self.history.items.len) { + return self.history.items[self.position - 1]; + } + return null; + } + + /// Check if undo is available + pub fn canUndo(self: *const Self) bool { + return self.position > 1; + } + + /// Check if redo is available + pub fn canRedo(self: *const Self) bool { + return self.position < self.history.items.len; + } + + /// Clear all history + pub fn clear(self: *Self) void { + self.history.clearRetainingCapacity(); + self.position = 0; + } + + /// Get history length + pub fn len(self: *const Self) usize { + return self.history.items.len; + } + }; +} + +// ============================================================================ +// Key-Value Serialization (simpler format) +// ============================================================================ + +/// Simple key-value format serializer +pub const KvSerializer = struct { + allocator: std.mem.Allocator, + data: std.StringHashMapUnmanaged([]const u8), + + pub fn init(allocator: std.mem.Allocator) KvSerializer { + return .{ + .allocator = allocator, + .data = .{}, + }; + } + + pub fn deinit(self: *KvSerializer) void { + var iter = self.data.iterator(); + while (iter.next()) |entry| { + self.allocator.free(entry.value_ptr.*); + } + self.data.deinit(self.allocator); + } + + /// Set an integer value + pub fn setInt(self: *KvSerializer, key: []const u8, value: anytype) !void { + var buf: [32]u8 = undefined; + const str = std.fmt.bufPrint(&buf, "{}", .{value}) catch return error.FormatError; + const owned = try self.allocator.dupe(u8, str); + try self.data.put(self.allocator, key, owned); + } + + /// Set a string value + pub fn setStr(self: *KvSerializer, key: []const u8, value: []const u8) !void { + const owned = try self.allocator.dupe(u8, value); + try self.data.put(self.allocator, key, owned); + } + + /// Set a boolean value + pub fn setBool(self: *KvSerializer, key: []const u8, value: bool) !void { + const str = if (value) "true" else "false"; + const owned = try self.allocator.dupe(u8, str); + try self.data.put(self.allocator, key, owned); + } + + /// Get an integer value + pub fn getInt(self: *const KvSerializer, comptime T: type, key: []const u8) ?T { + if (self.data.get(key)) |str| { + return std.fmt.parseInt(T, str, 10) catch null; + } + return null; + } + + /// Get a string value + pub fn getStr(self: *const KvSerializer, key: []const u8) ?[]const u8 { + return self.data.get(key); + } + + /// Get a boolean value + pub fn getBool(self: *const KvSerializer, key: []const u8) ?bool { + if (self.data.get(key)) |str| { + if (std.mem.eql(u8, str, "true")) return true; + if (std.mem.eql(u8, str, "false")) return false; + } + return null; + } + + /// Export to string format + pub fn exportToString(self: *const KvSerializer) ![]u8 { + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(self.allocator); + + var iter = self.data.iterator(); + while (iter.next()) |entry| { + try result.appendSlice(self.allocator, entry.key_ptr.*); + try result.appendSlice(self.allocator, "="); + try result.appendSlice(self.allocator, entry.value_ptr.*); + try result.appendSlice(self.allocator, "\n"); + } + + return result.toOwnedSlice(self.allocator); + } + + /// Import from string format + pub fn importFromString(self: *KvSerializer, content: []const u8) !void { + var lines = std.mem.tokenizeAny(u8, content, "\n\r"); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t"); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + + if (std.mem.indexOf(u8, trimmed, "=")) |eq_pos| { + const key = std.mem.trim(u8, trimmed[0..eq_pos], " \t"); + const value = std.mem.trim(u8, trimmed[eq_pos + 1 ..], " \t"); + try self.setStr(key, value); + } + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "toJson basic types" { + const allocator = std.testing.allocator; + + // Test struct + const TestStruct = struct { + a: u32, + b: bool, + c: []const u8, + }; + + const test_val = TestStruct{ .a = 42, .b = true, .c = "hello" }; + const json = try toJson(allocator, test_val); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"a\":42") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"b\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"c\":\"hello\"") != null); +} + +test "StateSnapshot save and get" { + const allocator = std.testing.allocator; + var snapshot = StateSnapshot.init(allocator); + defer snapshot.deinit(); + + const state = struct { x: u32, y: u32 }{ .x = 10, .y = 20 }; + try snapshot.save("position", state); + + try std.testing.expect(snapshot.contains("position")); + const json = snapshot.get("position").?; + try std.testing.expect(std.mem.indexOf(u8, json, "10") != null); +} + +test "UndoStack operations" { + const allocator = std.testing.allocator; + var stack = UndoStack(u32).init(allocator, 10); + defer stack.deinit(); + + try stack.push(1); + try stack.push(2); + try stack.push(3); + + try std.testing.expectEqual(@as(u32, 3), stack.current().?); + + const undone = stack.undo(); + try std.testing.expectEqual(@as(u32, 2), undone.?); + + try std.testing.expect(stack.canUndo()); + try std.testing.expect(stack.canRedo()); + + const redone = stack.redo(); + try std.testing.expectEqual(@as(u32, 3), redone.?); +} + +test "KvSerializer" { + const allocator = std.testing.allocator; + var kv = KvSerializer.init(allocator); + defer kv.deinit(); + + try kv.setInt("count", @as(u32, 42)); + try kv.setStr("name", "test"); + try kv.setBool("enabled", true); + + try std.testing.expectEqual(@as(?u32, 42), kv.getInt(u32, "count")); + try std.testing.expectEqualStrings("test", kv.getStr("name").?); + try std.testing.expectEqual(@as(?bool, true), kv.getBool("enabled")); +} diff --git a/src/testing.zig b/src/testing.zig new file mode 100644 index 0000000..f8f6d61 --- /dev/null +++ b/src/testing.zig @@ -0,0 +1,566 @@ +//! Widget Testing Framework for zcatui +//! +//! Provides utilities for testing widgets in isolation: +//! - TestBackend: Mock backend for capturing output +//! - TestTerminal: Terminal that captures frames +//! - Assertions: Test helpers for widget behavior +//! - Snapshots: Golden file testing +//! +//! Example: +//! ```zig +//! const testing = @import("testing.zig"); +//! const widgets = @import("root.zig").widgets; +//! +//! test "paragraph renders correctly" { +//! var harness = testing.WidgetHarness.init(testing.testing_allocator, 40, 10); +//! defer harness.deinit(); +//! +//! const para = widgets.Paragraph.init("Hello, World!"); +//! harness.render(para); +//! +//! try harness.expectText(0, 0, "Hello, World!"); +//! try harness.expectStyle(0, 0, .{ .foreground = .white }); +//! } +//! ``` + +const std = @import("std"); +const Buffer = @import("buffer.zig").Buffer; +const Cell = @import("buffer.zig").Cell; +const Rect = @import("buffer.zig").Rect; +const Style = @import("style.zig").Style; +const Color = @import("style.zig").Color; + +/// Test allocator for use in tests +pub const testing_allocator = std.testing.allocator; + +// ============================================================================ +// Test Backend +// ============================================================================ + +/// A mock backend that captures all output for testing +pub const TestBackend = struct { + /// Captured ANSI sequences + output: std.ArrayListUnmanaged(u8), + allocator: std.mem.Allocator, + /// Cursor position + cursor_x: u16 = 0, + cursor_y: u16 = 0, + /// Cursor visible + cursor_visible: bool = true, + /// Terminal size + width: u16, + height: u16, + /// Alternate screen active + alternate_screen: bool = false, + /// Raw mode active + raw_mode: bool = false, + /// Mouse capture active + mouse_capture: bool = false, + + pub fn init(allocator: std.mem.Allocator, width: u16, height: u16) TestBackend { + return .{ + .output = .{}, + .allocator = allocator, + .width = width, + .height = height, + }; + } + + pub fn deinit(self: *TestBackend) void { + self.output.deinit(self.allocator); + } + + /// Write output (captured) + pub fn write(self: *TestBackend, data: []const u8) !void { + try self.output.appendSlice(self.allocator, data); + } + + /// Clear captured output + pub fn clearOutput(self: *TestBackend) void { + self.output.clearRetainingCapacity(); + } + + /// Get captured output as string + pub fn getOutput(self: *const TestBackend) []const u8 { + return self.output.items; + } + + /// Check if output contains a string + pub fn outputContains(self: *const TestBackend, needle: []const u8) bool { + return std.mem.indexOf(u8, self.output.items, needle) != null; + } + + /// Simulate terminal resize + pub fn resize(self: *TestBackend, width: u16, height: u16) void { + self.width = width; + self.height = height; + } + + /// Get terminal size + pub fn size(self: *const TestBackend) struct { width: u16, height: u16 } { + return .{ .width = self.width, .height = self.height }; + } +}; + +// ============================================================================ +// Widget Test Harness +// ============================================================================ + +/// Test harness for widget testing +pub const WidgetHarness = struct { + allocator: std.mem.Allocator, + buffer: Buffer, + area: Rect, + /// Previous buffer for diff testing + prev_buffer: ?Buffer = null, + /// Frame counter + frame_count: u32 = 0, + + /// Initialize test harness with given dimensions + pub fn init(allocator: std.mem.Allocator, width: u16, height: u16) WidgetHarness { + const area = Rect.init(0, 0, width, height); + return .{ + .allocator = allocator, + .buffer = Buffer.init(allocator, area) catch unreachable, + .area = area, + }; + } + + /// Deinitialize + pub fn deinit(self: *WidgetHarness) void { + self.buffer.deinit(); + if (self.prev_buffer) |*prev| { + prev.deinit(); + } + } + + /// Reset buffer for new test + pub fn reset(self: *WidgetHarness) void { + self.buffer.clear(); + self.frame_count = 0; + } + + /// Render a widget to the test buffer + pub fn render(self: *WidgetHarness, widget: anytype) void { + widget.render(self.area, &self.buffer); + self.frame_count += 1; + } + + /// Render a widget with state + pub fn renderWithState(self: *WidgetHarness, widget: anytype, state: anytype) void { + widget.render(self.area, &self.buffer, state); + self.frame_count += 1; + } + + /// Render to a specific area within the harness + pub fn renderTo(self: *WidgetHarness, widget: anytype, area: Rect) void { + widget.render(area, &self.buffer); + self.frame_count += 1; + } + + // ======================================================================== + // Assertions + // ======================================================================== + + /// Get cell at position + pub fn getCell(self: *const WidgetHarness, x: u16, y: u16) ?*const Cell { + return self.buffer.get(x, y); + } + + /// Get text at position (single character) + pub fn getChar(self: *const WidgetHarness, x: u16, y: u16) ?[]const u8 { + if (self.buffer.get(x, y)) |cell| { + return cell.symbol.slice(); + } + return null; + } + + /// Get text from a row + pub fn getRowText(self: *const WidgetHarness, y: u16) []const u8 { + var result: [256]u8 = undefined; + var len: usize = 0; + + var x: u16 = 0; + while (x < self.area.width) : (x += 1) { + if (self.buffer.get(x, y)) |cell| { + const sym = cell.symbol.slice(); + if (len + sym.len <= result.len) { + @memcpy(result[len..][0..sym.len], sym); + len += sym.len; + } + } + } + + // Trim trailing spaces + while (len > 0 and result[len - 1] == ' ') { + len -= 1; + } + + return result[0..len]; + } + + /// Expect text at position + pub fn expectText(self: *const WidgetHarness, x: u16, y: u16, expected: []const u8) !void { + var pos_x = x; + for (expected) |char| { + if (self.buffer.get(pos_x, y)) |cell| { + const sym = cell.symbol.slice(); + if (sym.len > 0) { + try std.testing.expectEqual(char, sym[0]); + } + } + pos_x += 1; + } + } + + /// Expect style at position + pub fn expectStyle(self: *const WidgetHarness, x: u16, y: u16, expected_style: Style) !void { + if (self.buffer.get(x, y)) |cell| { + try std.testing.expectEqual(expected_style.foreground, cell.style.foreground); + try std.testing.expectEqual(expected_style.background, cell.style.background); + } else { + return error.CellNotFound; + } + } + + /// Expect foreground color at position + pub fn expectFg(self: *const WidgetHarness, x: u16, y: u16, expected: Color) !void { + if (self.buffer.get(x, y)) |cell| { + try std.testing.expectEqual(expected, cell.style.foreground); + } else { + return error.CellNotFound; + } + } + + /// Expect background color at position + pub fn expectBg(self: *const WidgetHarness, x: u16, y: u16, expected: Color) !void { + if (self.buffer.get(x, y)) |cell| { + try std.testing.expectEqual(expected, cell.style.background); + } else { + return error.CellNotFound; + } + } + + /// Expect area to be empty (all spaces) + pub fn expectEmpty(self: *const WidgetHarness, area: Rect) !void { + var y = area.y; + while (y < area.bottom()) : (y += 1) { + var x = area.x; + while (x < area.right()) : (x += 1) { + if (self.buffer.get(x, y)) |cell| { + const sym = cell.symbol.slice(); + if (sym.len > 0 and sym[0] != ' ') { + std.debug.print("Expected empty at ({}, {}), found: '{s}'\n", .{ x, y, sym }); + return error.NotEmpty; + } + } + } + } + } + + /// Expect area to not be empty + pub fn expectNotEmpty(self: *const WidgetHarness, area: Rect) !void { + var y = area.y; + while (y < area.bottom()) : (y += 1) { + var x = area.x; + while (x < area.right()) : (x += 1) { + if (self.buffer.get(x, y)) |cell| { + const sym = cell.symbol.slice(); + if (sym.len > 0 and sym[0] != ' ') { + return; // Found non-empty + } + } + } + } + return error.IsEmpty; + } + + /// Check if a character exists anywhere in the buffer + pub fn containsChar(self: *const WidgetHarness, char: u8) bool { + var y: u16 = 0; + while (y < self.area.height) : (y += 1) { + var x: u16 = 0; + while (x < self.area.width) : (x += 1) { + if (self.buffer.get(x, y)) |cell| { + const sym = cell.symbol.slice(); + if (sym.len > 0 and sym[0] == char) { + return true; + } + } + } + } + return false; + } + + /// Check if text exists anywhere in the buffer + pub fn containsText(self: *const WidgetHarness, text: []const u8) bool { + var y: u16 = 0; + while (y < self.area.height) : (y += 1) { + var x: u16 = 0; + while (x < self.area.width -| @as(u16, @intCast(text.len))) : (x += 1) { + var matches = true; + for (text, 0..) |char, i| { + if (self.buffer.get(x + @as(u16, @intCast(i)), y)) |cell| { + const sym = cell.symbol.slice(); + if (sym.len == 0 or sym[0] != char) { + matches = false; + break; + } + } else { + matches = false; + break; + } + } + if (matches) return true; + } + } + return false; + } + + // ======================================================================== + // Snapshot Testing + // ======================================================================== + + /// Render buffer to string for snapshot comparison + pub fn toString(self: *const WidgetHarness) []const u8 { + var result: [4096]u8 = undefined; + var len: usize = 0; + + var y: u16 = 0; + while (y < self.area.height) : (y += 1) { + var x: u16 = 0; + while (x < self.area.width) : (x += 1) { + if (self.buffer.get(x, y)) |cell| { + const sym = cell.symbol.slice(); + if (sym.len > 0 and len + sym.len <= result.len) { + @memcpy(result[len..][0..sym.len], sym); + len += sym.len; + } else if (len < result.len) { + result[len] = ' '; + len += 1; + } + } else if (len < result.len) { + result[len] = ' '; + len += 1; + } + } + if (len < result.len) { + result[len] = '\n'; + len += 1; + } + } + + return result[0..len]; + } + + /// Print buffer for debugging + pub fn debugPrint(self: *const WidgetHarness) void { + std.debug.print("\n=== Buffer ({} x {}) ===\n", .{ self.area.width, self.area.height }); + + var y: u16 = 0; + while (y < self.area.height) : (y += 1) { + var x: u16 = 0; + while (x < self.area.width) : (x += 1) { + if (self.buffer.get(x, y)) |cell| { + const sym = cell.symbol.slice(); + if (sym.len > 0) { + std.debug.print("{s}", .{sym}); + } else { + std.debug.print(" ", .{}); + } + } else { + std.debug.print(" ", .{}); + } + } + std.debug.print("|\n", .{}); + } + std.debug.print("======================\n", .{}); + } +}; + +// ============================================================================ +// Event Simulation +// ============================================================================ + +const Event = @import("event.zig").Event; +const KeyEvent = @import("event.zig").KeyEvent; +const KeyCode = @import("event.zig").KeyCode; +const MouseEvent = @import("event.zig").MouseEvent; +const MouseButton = @import("event.zig").MouseButton; +const MouseEventKind = @import("event.zig").MouseEventKind; + +/// Helper to create key events for testing +pub const SimulatedInput = struct { + /// Create a key press event + pub fn key(code: KeyCode) Event { + return .{ + .key = .{ + .code = code, + .modifiers = .{}, + .kind = .press, + }, + }; + } + + /// Create a key press with char + pub fn char(c: u21) Event { + return .{ + .key = .{ + .code = .{ .char = c }, + .modifiers = .{}, + .kind = .press, + }, + }; + } + + /// Create a key with modifiers + pub fn keyWithMod(code: KeyCode, ctrl: bool, alt: bool, shift: bool) Event { + return .{ + .key = .{ + .code = code, + .modifiers = .{ + .ctrl = ctrl, + .alt = alt, + .shift = shift, + }, + .kind = .press, + }, + }; + } + + /// Create mouse click event + pub fn click(col: u16, row: u16, button: MouseButton) Event { + return .{ + .mouse = .{ + .column = col, + .row = row, + .kind = .down, + .button = button, + .modifiers = .{}, + }, + }; + } + + /// Create mouse scroll event + pub fn scroll(col: u16, row: u16, down: bool) Event { + return .{ + .mouse = .{ + .column = col, + .row = row, + .kind = if (down) .scroll_down else .scroll_up, + .button = .none, + .modifiers = .{}, + }, + }; + } + + /// Create resize event + pub fn resize(width: u16, height: u16) Event { + return .{ + .resize = .{ + .width = width, + .height = height, + }, + }; + } +}; + +// ============================================================================ +// Benchmark Utilities +// ============================================================================ + +/// Simple timing utility for widget benchmarks +pub const Benchmark = struct { + start_time: i128, + iterations: u32 = 0, + total_ns: i128 = 0, + + pub fn start() Benchmark { + return .{ + .start_time = std.time.nanoTimestamp(), + }; + } + + pub fn lap(self: *Benchmark) void { + const now = std.time.nanoTimestamp(); + self.total_ns += now - self.start_time; + self.iterations += 1; + self.start_time = now; + } + + pub fn avgNs(self: *const Benchmark) i128 { + if (self.iterations == 0) return 0; + return @divTrunc(self.total_ns, self.iterations); + } + + pub fn avgUs(self: *const Benchmark) f64 { + return @as(f64, @floatFromInt(self.avgNs())) / 1000.0; + } + + pub fn avgMs(self: *const Benchmark) f64 { + return @as(f64, @floatFromInt(self.avgNs())) / 1_000_000.0; + } + + pub fn report(self: *const Benchmark, name: []const u8) void { + std.debug.print( + "{s}: {} iterations, avg {d:.2}µs ({d:.2}ms total)\n", + .{ + name, + self.iterations, + self.avgUs(), + @as(f64, @floatFromInt(self.total_ns)) / 1_000_000.0, + }, + ); + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "TestBackend captures output" { + var backend = TestBackend.init(testing_allocator, 80, 24); + defer backend.deinit(); + + try backend.write("Hello"); + try backend.write(" World"); + + try std.testing.expectEqualStrings("Hello World", backend.getOutput()); + try std.testing.expect(backend.outputContains("World")); + try std.testing.expect(!backend.outputContains("Foo")); +} + +test "WidgetHarness basic operations" { + var harness = WidgetHarness.init(testing_allocator, 20, 5); + defer harness.deinit(); + + // Test basic harness creation + try std.testing.expectEqual(@as(u16, 20), harness.area.width); + try std.testing.expectEqual(@as(u16, 5), harness.area.height); +} + +test "SimulatedInput creates events" { + const key_event = SimulatedInput.key(.enter); + try std.testing.expectEqual(KeyCode.enter, key_event.key.code); + + const char_event = SimulatedInput.char('a'); + try std.testing.expectEqual(KeyCode{ .char = 'a' }, char_event.key.code); + + const click_event = SimulatedInput.click(10, 5, .left); + try std.testing.expectEqual(@as(u16, 10), click_event.mouse.column); + try std.testing.expectEqual(@as(u16, 5), click_event.mouse.row); +} + +test "Benchmark timing" { + var bench = Benchmark.start(); + + var i: u32 = 0; + while (i < 100) : (i += 1) { + // Simulate some work + _ = @as(u32, 0) +% @as(u32, 1); + bench.lap(); + } + + try std.testing.expectEqual(@as(u32, 100), bench.iterations); + try std.testing.expect(bench.total_ns > 0); +} diff --git a/src/theme_loader.zig b/src/theme_loader.zig new file mode 100644 index 0000000..887f820 --- /dev/null +++ b/src/theme_loader.zig @@ -0,0 +1,439 @@ +//! Theme Hot-Reload System +//! +//! Allows loading themes from files and watching for changes. +//! Supports JSON and simple key-value formats. +//! +//! Example: +//! ```zig +//! var loader = try ThemeLoader.init(allocator, "~/.config/zcatui/theme.json"); +//! defer loader.deinit(); +//! +//! // Get the current theme +//! const theme = loader.getTheme(); +//! +//! // Check for changes periodically +//! if (loader.hasChanged()) { +//! try loader.reload(); +//! // Re-render UI with new theme +//! } +//! ``` + +const std = @import("std"); +const Theme = @import("theme.zig").Theme; +const Style = @import("style.zig").Style; +const Color = @import("style.zig").Color; + +/// Theme file format +pub const ThemeFormat = enum { + /// JSON format + json, + /// Simple key=value format + kv, + /// Auto-detect from extension + auto, +}; + +/// Theme loader with hot-reload support +pub const ThemeLoader = struct { + allocator: std.mem.Allocator, + /// Path to theme file + path: []const u8, + /// Current loaded theme + theme: Theme, + /// Last modification time + last_mtime: i128 = 0, + /// Format + format: ThemeFormat, + /// Error message from last load attempt + last_error: ?[]const u8 = null, + + /// Initialize with a file path + pub fn init(allocator: std.mem.Allocator, path: []const u8) !ThemeLoader { + var loader = ThemeLoader{ + .allocator = allocator, + .path = try allocator.dupe(u8, path), + .theme = .{}, // Default theme + .format = detectFormat(path), + }; + + // Try to load initial theme + loader.reload() catch |err| { + // If file doesn't exist, use default + if (err == error.FileNotFound) { + return loader; + } + return err; + }; + + return loader; + } + + /// Deinitialize + pub fn deinit(self: *ThemeLoader) void { + self.allocator.free(self.path); + if (self.last_error) |err| { + self.allocator.free(err); + } + } + + /// Detect format from file extension + fn detectFormat(path: []const u8) ThemeFormat { + if (std.mem.endsWith(u8, path, ".json")) { + return .json; + } else if (std.mem.endsWith(u8, path, ".kv") or std.mem.endsWith(u8, path, ".conf")) { + return .kv; + } + return .json; // Default to JSON + } + + /// Check if the theme file has changed + pub fn hasChanged(self: *ThemeLoader) bool { + const stat = std.fs.cwd().statFile(self.path) catch return false; + return stat.mtime > self.last_mtime; + } + + /// Reload theme from file + pub fn reload(self: *ThemeLoader) !void { + // Clear previous error + if (self.last_error) |err| { + self.allocator.free(err); + self.last_error = null; + } + + // Get file stats + const stat = try std.fs.cwd().statFile(self.path); + self.last_mtime = stat.mtime; + + // Read file + const file = try std.fs.cwd().openFile(self.path, .{}); + defer file.close(); + + const content = try file.readToEndAlloc(self.allocator, 1024 * 1024); + defer self.allocator.free(content); + + // Parse based on format + self.theme = switch (self.format) { + .json => try parseJson(content), + .kv => try parseKv(content), + .auto => blk: { + // Try JSON first, then KV + break :blk parseJson(content) catch parseKv(content) catch Theme{}; + }, + }; + } + + /// Get current theme + pub fn getTheme(self: *const ThemeLoader) Theme { + return self.theme; + } + + /// Get last error message + pub fn getError(self: *const ThemeLoader) ?[]const u8 { + return self.last_error; + } + + /// Parse JSON format + fn parseJson(content: []const u8) !Theme { + var theme = Theme{}; + + // Simple JSON parser for theme + // Format: {"name": "...", "primary": "#RRGGBB", ...} + var iter = std.mem.tokenizeAny(u8, content, "{}:,\"\n\t\r "); + + var key: ?[]const u8 = null; + while (iter.next()) |token| { + if (key == null) { + key = token; + } else { + // Apply value to theme + if (std.mem.eql(u8, key.?, "name")) { + // Name is stored but not used + } else if (std.mem.eql(u8, key.?, "primary")) { + if (parseHexColor(token)) |color| { + theme.primary = color; + } + } else if (std.mem.eql(u8, key.?, "secondary")) { + if (parseHexColor(token)) |color| { + theme.secondary = color; + } + } else if (std.mem.eql(u8, key.?, "background")) { + if (parseHexColor(token)) |color| { + theme.background = color; + } + } else if (std.mem.eql(u8, key.?, "foreground")) { + if (parseHexColor(token)) |color| { + theme.foreground = color; + } + } else if (std.mem.eql(u8, key.?, "success")) { + if (parseHexColor(token)) |color| { + theme.success = color; + } + } else if (std.mem.eql(u8, key.?, "warning")) { + if (parseHexColor(token)) |color| { + theme.warning = color; + } + } else if (std.mem.eql(u8, key.?, "error")) { + if (parseHexColor(token)) |color| { + theme.error_color = color; + } + } else if (std.mem.eql(u8, key.?, "info")) { + if (parseHexColor(token)) |color| { + theme.info = color; + } + } else if (std.mem.eql(u8, key.?, "border")) { + if (parseHexColor(token)) |color| { + theme.border = color; + } + } + key = null; + } + } + + return theme; + } + + /// Parse key=value format + fn parseKv(content: []const u8) !Theme { + var theme = Theme{}; + + var lines = std.mem.tokenizeAny(u8, content, "\n\r"); + while (lines.next()) |line| { + // Skip comments and empty lines + const trimmed = std.mem.trim(u8, line, " \t"); + if (trimmed.len == 0 or trimmed[0] == '#' or trimmed[0] == ';') { + continue; + } + + // Parse key=value + if (std.mem.indexOf(u8, trimmed, "=")) |eq_pos| { + const key = std.mem.trim(u8, trimmed[0..eq_pos], " \t"); + const value = std.mem.trim(u8, trimmed[eq_pos + 1 ..], " \t\"'"); + + if (std.mem.eql(u8, key, "primary")) { + if (parseHexColor(value)) |color| { + theme.primary = color; + } + } else if (std.mem.eql(u8, key, "secondary")) { + if (parseHexColor(value)) |color| { + theme.secondary = color; + } + } else if (std.mem.eql(u8, key, "background")) { + if (parseHexColor(value)) |color| { + theme.background = color; + } + } else if (std.mem.eql(u8, key, "foreground")) { + if (parseHexColor(value)) |color| { + theme.foreground = color; + } + } else if (std.mem.eql(u8, key, "success")) { + if (parseHexColor(value)) |color| { + theme.success = color; + } + } else if (std.mem.eql(u8, key, "warning")) { + if (parseHexColor(value)) |color| { + theme.warning = color; + } + } else if (std.mem.eql(u8, key, "error")) { + if (parseHexColor(value)) |color| { + theme.error_color = color; + } + } else if (std.mem.eql(u8, key, "info")) { + if (parseHexColor(value)) |color| { + theme.info = color; + } + } else if (std.mem.eql(u8, key, "border")) { + if (parseHexColor(value)) |color| { + theme.border = color; + } + } + } + } + + return theme; + } + + /// Parse hex color (#RRGGBB or #RGB) or named color + fn parseHexColor(value: []const u8) ?Color { + // Try named colors first + if (std.mem.eql(u8, value, "red")) return Color.red; + if (std.mem.eql(u8, value, "green")) return Color.green; + if (std.mem.eql(u8, value, "blue")) return Color.blue; + if (std.mem.eql(u8, value, "yellow")) return Color.yellow; + if (std.mem.eql(u8, value, "cyan")) return Color.cyan; + if (std.mem.eql(u8, value, "magenta")) return Color.magenta; + if (std.mem.eql(u8, value, "white")) return Color.white; + if (std.mem.eql(u8, value, "black")) return Color.black; + + // Try hex format + var hex = value; + if (hex.len > 0 and hex[0] == '#') { + hex = hex[1..]; + } + + if (hex.len == 6) { + // #RRGGBB + const r = std.fmt.parseInt(u8, hex[0..2], 16) catch return null; + const g = std.fmt.parseInt(u8, hex[2..4], 16) catch return null; + const b = std.fmt.parseInt(u8, hex[4..6], 16) catch return null; + return Color.rgb(r, g, b); + } else if (hex.len == 3) { + // #RGB -> expand to #RRGGBB + const r = std.fmt.parseInt(u8, hex[0..1], 16) catch return null; + const g = std.fmt.parseInt(u8, hex[1..2], 16) catch return null; + const b = std.fmt.parseInt(u8, hex[2..3], 16) catch return null; + return Color.rgb(r * 17, g * 17, b * 17); + } + + return null; + } +}; + +/// Theme watcher for automatic hot-reload +pub const ThemeWatcher = struct { + loader: ThemeLoader, + /// Check interval in nanoseconds + check_interval_ns: u64, + /// Last check time + last_check: i128 = 0, + /// Callback on theme change + on_change: ?*const fn (Theme) void = null, + + /// Initialize watcher + pub fn init(allocator: std.mem.Allocator, path: []const u8, check_interval_ms: u32) !ThemeWatcher { + return .{ + .loader = try ThemeLoader.init(allocator, path), + .check_interval_ns = @as(u64, check_interval_ms) * 1_000_000, + }; + } + + /// Deinitialize + pub fn deinit(self: *ThemeWatcher) void { + self.loader.deinit(); + } + + /// Set callback for theme changes + pub fn setOnChange(self: *ThemeWatcher, callback: *const fn (Theme) void) void { + self.on_change = callback; + } + + /// Poll for changes (call this in your event loop) + pub fn poll(self: *ThemeWatcher) bool { + const now = std.time.nanoTimestamp(); + if (now - self.last_check < self.check_interval_ns) { + return false; + } + self.last_check = now; + + if (self.loader.hasChanged()) { + self.loader.reload() catch return false; + if (self.on_change) |callback| { + callback(self.loader.theme); + } + return true; + } + return false; + } + + /// Get current theme + pub fn getTheme(self: *const ThemeWatcher) Theme { + return self.loader.getTheme(); + } +}; + +/// Export theme to JSON format +pub fn exportTheme(theme: Theme, allocator: std.mem.Allocator) ![]u8 { + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(allocator); + + try result.appendSlice(allocator, "{\n"); + + // Helper to write color + const writeColor = struct { + fn write(list: *std.ArrayListUnmanaged(u8), alloc: std.mem.Allocator, name: []const u8, color: Color, last: bool) !void { + try list.appendSlice(alloc, " \""); + try list.appendSlice(alloc, name); + try list.appendSlice(alloc, "\": \""); + + switch (color) { + .true_color => |rgb| { + var buf: [8]u8 = undefined; + const hex = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch "#000000"; + try list.appendSlice(alloc, hex); + }, + .ansi => |ansi| { + try list.appendSlice(alloc, @tagName(ansi)); + }, + .idx => |idx| { + var buf: [8]u8 = undefined; + const num = std.fmt.bufPrint(&buf, "{}", .{idx}) catch "0"; + try list.appendSlice(alloc, num); + }, + .reset => try list.appendSlice(alloc, "reset"), + } + + try list.appendSlice(alloc, "\""); + if (!last) try list.appendSlice(alloc, ","); + try list.appendSlice(alloc, "\n"); + } + }.write; + + try writeColor(&result, allocator, "primary", theme.primary, false); + try writeColor(&result, allocator, "secondary", theme.secondary, false); + try writeColor(&result, allocator, "success", theme.success, false); + try writeColor(&result, allocator, "warning", theme.warning, false); + try writeColor(&result, allocator, "error", theme.error_color, false); + try writeColor(&result, allocator, "info", theme.info, false); + try writeColor(&result, allocator, "border", theme.border, true); + + try result.appendSlice(allocator, "}\n"); + + return result.toOwnedSlice(allocator); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "parseHexColor" { + // Test #RRGGBB + const c1 = ThemeLoader.parseHexColor("#FF5500"); + try std.testing.expect(c1 != null); + try std.testing.expectEqual(Color.rgb(0xFF, 0x55, 0x00), c1.?); + + // Test #RGB + const c2 = ThemeLoader.parseHexColor("#F50"); + try std.testing.expect(c2 != null); + try std.testing.expectEqual(Color.rgb(0xFF, 0x55, 0x00), c2.?); + + // Test named color - verify it's not null and is ansi red + const c3 = ThemeLoader.parseHexColor("red"); + try std.testing.expect(c3 != null); + // Color.red is defined as .{ .ansi = .red } + switch (c3.?) { + .ansi => |a| try std.testing.expectEqual(Color.Ansi.red, a), + else => return error.UnexpectedColorType, + } +} + +test "parseKv" { + const content = + \\# Theme configuration + \\primary = #FF0000 + \\secondary = #00FF00 + \\info = blue + ; + + const theme = try ThemeLoader.parseKv(content); + try std.testing.expectEqual(Color.rgb(0xFF, 0x00, 0x00), theme.primary); + try std.testing.expectEqual(Color.rgb(0x00, 0xFF, 0x00), theme.secondary); + try std.testing.expectEqual(Color.blue, theme.info); +} + +test "exportTheme" { + const theme = Theme{}; + const json = try exportTheme(theme, std.testing.allocator); + defer std.testing.allocator.free(json); + + try std.testing.expect(json.len > 0); + try std.testing.expect(std.mem.indexOf(u8, json, "primary") != null); +} diff --git a/src/widgets/dirtree.zig b/src/widgets/dirtree.zig new file mode 100644 index 0000000..6f174df --- /dev/null +++ b/src/widgets/dirtree.zig @@ -0,0 +1,633 @@ +//! Directory Tree widget for file system navigation. +//! +//! A specialized tree view for browsing directories and files. +//! Features auto-expansion, filtering, icons, and file info display. +//! +//! ## Example +//! +//! ```zig +//! var tree = try DirectoryTree.init(allocator, "/home/user"); +//! defer tree.deinit(); +//! +//! // Navigate +//! tree.moveDown(); +//! tree.toggleExpand(); +//! +//! // Render +//! tree.render(area, buf); +//! ``` + +const std = @import("std"); +const fs = std.fs; +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; + +/// File type for styling and icons +pub const FileKind = enum { + directory, + file, + symlink, + executable, + hidden, + special, + + pub fn fromEntry(entry: fs.Dir.Entry) FileKind { + return switch (entry.kind) { + .directory => .directory, + .sym_link => .symlink, + .file => .file, + else => .special, + }; + } +}; + +/// Icons for different file types +pub const FileIcons = struct { + directory: []const u8 = "📁", + directory_open: []const u8 = "📂", + file: []const u8 = "📄", + symlink: []const u8 = "🔗", + executable: []const u8 = "⚙️", + hidden: []const u8 = "👁", + special: []const u8 = "❓", + + // File extension icons + zig: []const u8 = "⚡", + rust: []const u8 = "🦀", + python: []const u8 = "🐍", + javascript: []const u8 = "📜", + markdown: []const u8 = "📝", + image: []const u8 = "🖼", + archive: []const u8 = "📦", + config: []const u8 = "⚙️", + git: []const u8 = "🔀", + + pub const default: FileIcons = .{}; + + pub const ascii: FileIcons = .{ + .directory = "[D]", + .directory_open = "[D]", + .file = " ", + .symlink = "[@]", + .executable = "[*]", + .hidden = "[.]", + .special = "[?]", + .zig = "[Z]", + .rust = "[R]", + .python = "[P]", + .javascript = "[J]", + .markdown = "[M]", + .image = "[I]", + .archive = "[A]", + .config = "[C]", + .git = "[G]", + }; + + pub fn forFile(self: FileIcons, name: []const u8, kind: FileKind, expanded: bool) []const u8 { + // Check for hidden files + if (name.len > 0 and name[0] == '.') { + // Git directory + if (std.mem.eql(u8, name, ".git")) return self.git; + } + + // By file kind + switch (kind) { + .directory => return if (expanded) self.directory_open else self.directory, + .symlink => return self.symlink, + .executable => return self.executable, + .hidden => return self.hidden, + .special => return self.special, + .file => { + // By extension + if (getExtension(name)) |ext| { + if (std.mem.eql(u8, ext, "zig")) return self.zig; + if (std.mem.eql(u8, ext, "rs")) return self.rust; + if (std.mem.eql(u8, ext, "py")) return self.python; + if (std.mem.eql(u8, ext, "js") or std.mem.eql(u8, ext, "ts")) return self.javascript; + if (std.mem.eql(u8, ext, "md")) return self.markdown; + if (std.mem.eql(u8, ext, "png") or std.mem.eql(u8, ext, "jpg") or + std.mem.eql(u8, ext, "gif") or std.mem.eql(u8, ext, "svg")) + return self.image; + if (std.mem.eql(u8, ext, "zip") or std.mem.eql(u8, ext, "tar") or + std.mem.eql(u8, ext, "gz") or std.mem.eql(u8, ext, "7z")) + return self.archive; + if (std.mem.eql(u8, ext, "json") or std.mem.eql(u8, ext, "toml") or + std.mem.eql(u8, ext, "yaml") or std.mem.eql(u8, ext, "yml")) + return self.config; + } + return self.file; + }, + } + } +}; + +fn getExtension(name: []const u8) ?[]const u8 { + const idx = std.mem.lastIndexOfScalar(u8, name, '.'); + if (idx) |i| { + // Must have content after the dot, and dot can't be at start (hidden files) + if (i > 0 and i + 1 < name.len) return name[i + 1 ..]; + } + return null; +} + +/// A node in the directory tree +pub const DirNode = struct { + name: []const u8, + path: []const u8, + kind: FileKind, + depth: u16, + expanded: bool = false, + loaded: bool = false, + children_start: usize = 0, + children_count: usize = 0, + size: u64 = 0, +}; + +/// Theme for directory tree +pub const DirTreeTheme = struct { + directory: Style = Style.default.fg(Color.blue).add_modifier(.{ .bold = true }), + file: Style = Style.default, + symlink: Style = Style.default.fg(Color.cyan), + executable: Style = Style.default.fg(Color.green), + hidden: Style = Style.default.fg(Color.indexed(245)), + special: Style = Style.default.fg(Color.yellow), + selected: Style = Style.default.bg(Color.indexed(236)), + tree_guide: Style = Style.default.fg(Color.indexed(240)), + size: Style = Style.default.fg(Color.indexed(245)), + + pub const default: DirTreeTheme = .{}; +}; + +/// Tree drawing symbols +pub const TreeSymbols = struct { + branch: []const u8 = "├── ", + last_branch: []const u8 = "└── ", + vertical: []const u8 = "│ ", + space: []const u8 = " ", + collapsed: []const u8 = "▸ ", + expanded: []const u8 = "▾ ", + + pub const default: TreeSymbols = .{}; + + pub const ascii: TreeSymbols = .{ + .branch = "|-- ", + .last_branch = "`-- ", + .vertical = "| ", + .space = " ", + .collapsed = "+ ", + .expanded = "- ", + }; +}; + +/// Directory tree widget +pub const DirectoryTree = struct { + allocator: std.mem.Allocator, + root_path: []const u8, + nodes: std.ArrayList(DirNode), + flat_view: std.ArrayList(usize), // Indices into nodes for visible items + selected: usize = 0, + scroll_offset: u16 = 0, + theme: DirTreeTheme = DirTreeTheme.default, + symbols: TreeSymbols = TreeSymbols.default, + icons: FileIcons = FileIcons.default, + show_hidden: bool = false, + show_icons: bool = true, + show_size: bool = false, + filter: ?[]const u8 = null, + + /// Creates a new directory tree rooted at the given path + pub fn init(allocator: std.mem.Allocator, root_path: []const u8) !DirectoryTree { + var tree = DirectoryTree{ + .allocator = allocator, + .root_path = try allocator.dupe(u8, root_path), + .nodes = std.ArrayList(DirNode).init(allocator), + .flat_view = std.ArrayList(usize).init(allocator), + }; + + // Add root node + try tree.nodes.append(.{ + .name = try allocator.dupe(u8, std.fs.path.basename(root_path)), + .path = tree.root_path, + .kind = .directory, + .depth = 0, + .expanded = true, + .loaded = false, + }); + + // Load root directory + try tree.loadChildren(0); + try tree.rebuildFlatView(); + + return tree; + } + + /// Frees all resources + pub fn deinit(self: *DirectoryTree) void { + for (self.nodes.items) |node| { + if (node.name.ptr != node.path.ptr) { + self.allocator.free(node.name); + } + if (node.depth > 0) { + self.allocator.free(node.path); + } + } + self.nodes.deinit(); + self.flat_view.deinit(); + self.allocator.free(self.root_path); + } + + /// Loads children for a directory node + fn loadChildren(self: *DirectoryTree, node_idx: usize) !void { + var node = &self.nodes.items[node_idx]; + if (node.loaded or node.kind != .directory) return; + + const dir = fs.openDirAbsolute(node.path, .{ .iterate = true }) catch |err| { + _ = err; + node.loaded = true; + return; + }; + defer dir.close(); + + const children_start = self.nodes.items.len; + var children_count: usize = 0; + + var iter = dir.iterate(); + while (try iter.next()) |entry| { + // Filter hidden files + if (!self.show_hidden and entry.name.len > 0 and entry.name[0] == '.') { + continue; + } + + // Apply filter if set + if (self.filter) |f| { + if (std.mem.indexOf(u8, entry.name, f) == null) { + continue; + } + } + + const full_path = try fs.path.join(self.allocator, &.{ node.path, entry.name }); + const name = try self.allocator.dupe(u8, entry.name); + + try self.nodes.append(.{ + .name = name, + .path = full_path, + .kind = FileKind.fromEntry(entry), + .depth = node.depth + 1, + }); + children_count += 1; + } + + // Sort children: directories first, then alphabetically + const children = self.nodes.items[children_start..]; + std.mem.sort(DirNode, children, {}, struct { + fn lessThan(_: void, a: DirNode, b: DirNode) bool { + // Directories first + if (a.kind == .directory and b.kind != .directory) return true; + if (a.kind != .directory and b.kind == .directory) return false; + // Then alphabetical (case-insensitive) + return std.ascii.lessThanIgnoreCase(a.name, b.name); + } + }.lessThan); + + node.children_start = children_start; + node.children_count = children_count; + node.loaded = true; + } + + /// Rebuilds the flat view based on expanded state + fn rebuildFlatView(self: *DirectoryTree) !void { + self.flat_view.clearRetainingCapacity(); + try self.addToFlatView(0); + } + + fn addToFlatView(self: *DirectoryTree, node_idx: usize) !void { + try self.flat_view.append(node_idx); + + const node = self.nodes.items[node_idx]; + if (node.expanded and node.loaded) { + const children_end = node.children_start + node.children_count; + for (node.children_start..children_end) |child_idx| { + try self.addToFlatView(child_idx); + } + } + } + + // Navigation methods + pub fn moveUp(self: *DirectoryTree) void { + if (self.selected > 0) { + self.selected -= 1; + self.ensureVisible(); + } + } + + pub fn moveDown(self: *DirectoryTree) void { + if (self.selected + 1 < self.flat_view.items.len) { + self.selected += 1; + self.ensureVisible(); + } + } + + pub fn pageUp(self: *DirectoryTree, page_size: u16) void { + if (self.selected > page_size) { + self.selected -= page_size; + } else { + self.selected = 0; + } + self.ensureVisible(); + } + + pub fn pageDown(self: *DirectoryTree, page_size: u16) void { + self.selected = @min(self.selected + page_size, self.flat_view.items.len -| 1); + self.ensureVisible(); + } + + pub fn goToTop(self: *DirectoryTree) void { + self.selected = 0; + self.scroll_offset = 0; + } + + pub fn goToBottom(self: *DirectoryTree) void { + self.selected = self.flat_view.items.len -| 1; + self.ensureVisible(); + } + + fn ensureVisible(self: *DirectoryTree) void { + const sel = @as(u16, @intCast(self.selected)); + if (sel < self.scroll_offset) { + self.scroll_offset = sel; + } + // Will be adjusted during render based on area height + } + + /// Toggles expansion of the selected directory + pub fn toggleExpand(self: *DirectoryTree) !void { + if (self.flat_view.items.len == 0) return; + + const node_idx = self.flat_view.items[self.selected]; + var node = &self.nodes.items[node_idx]; + + if (node.kind != .directory) return; + + if (!node.loaded) { + try self.loadChildren(node_idx); + } + + node.expanded = !node.expanded; + try self.rebuildFlatView(); + + // Adjust selected if it's now out of range + if (self.selected >= self.flat_view.items.len) { + self.selected = self.flat_view.items.len -| 1; + } + } + + /// Expands the selected directory + pub fn expand(self: *DirectoryTree) !void { + if (self.flat_view.items.len == 0) return; + + const node_idx = self.flat_view.items[self.selected]; + var node = &self.nodes.items[node_idx]; + + if (node.kind != .directory or node.expanded) return; + + if (!node.loaded) { + try self.loadChildren(node_idx); + } + + node.expanded = true; + try self.rebuildFlatView(); + } + + /// Collapses the selected directory + pub fn collapse(self: *DirectoryTree) !void { + if (self.flat_view.items.len == 0) return; + + const node_idx = self.flat_view.items[self.selected]; + var node = &self.nodes.items[node_idx]; + + if (node.kind == .directory and node.expanded) { + node.expanded = false; + try self.rebuildFlatView(); + } else if (node.depth > 0) { + // Go to parent + self.goToParent(); + } + } + + /// Navigates to the parent directory + pub fn goToParent(self: *DirectoryTree) void { + if (self.flat_view.items.len == 0) return; + + const node_idx = self.flat_view.items[self.selected]; + const node = self.nodes.items[node_idx]; + + if (node.depth == 0) return; + + // Find parent in flat view + for (self.flat_view.items, 0..) |idx, i| { + const n = self.nodes.items[idx]; + if (n.depth == node.depth - 1 and + n.children_start <= node_idx and + node_idx < n.children_start + n.children_count) + { + self.selected = i; + self.ensureVisible(); + break; + } + } + } + + /// Returns the currently selected node + pub fn getSelected(self: *const DirectoryTree) ?DirNode { + if (self.flat_view.items.len == 0) return null; + return self.nodes.items[self.flat_view.items[self.selected]]; + } + + /// Returns the path of the selected item + pub fn getSelectedPath(self: *const DirectoryTree) ?[]const u8 { + if (self.getSelected()) |node| { + return node.path; + } + return null; + } + + /// Toggles hidden file visibility + pub fn toggleHidden(self: *DirectoryTree) !void { + self.show_hidden = !self.show_hidden; + // Reload all expanded directories + for (self.nodes.items) |*node| { + if (node.expanded) { + node.loaded = false; + } + } + // Clear and reload + self.nodes.shrinkRetainingCapacity(1); + self.nodes.items[0].loaded = false; + self.nodes.items[0].children_count = 0; + try self.loadChildren(0); + try self.rebuildFlatView(); + self.selected = @min(self.selected, self.flat_view.items.len -| 1); + } + + // Builder methods + pub fn setTheme(self: DirectoryTree, t: DirTreeTheme) DirectoryTree { + var tree = self; + tree.theme = t; + return tree; + } + + pub fn setSymbols(self: DirectoryTree, s: TreeSymbols) DirectoryTree { + var tree = self; + tree.symbols = s; + return tree; + } + + pub fn setIcons(self: DirectoryTree, i: FileIcons) DirectoryTree { + var tree = self; + tree.icons = i; + return tree; + } + + pub fn setShowHidden(self: DirectoryTree, show: bool) DirectoryTree { + var tree = self; + tree.show_hidden = show; + return tree; + } + + pub fn setShowIcons(self: DirectoryTree, show: bool) DirectoryTree { + var tree = self; + tree.show_icons = show; + return tree; + } + + pub fn setShowSize(self: DirectoryTree, show: bool) DirectoryTree { + var tree = self; + tree.show_size = show; + return tree; + } + + /// Renders the directory tree + pub fn render(self: *DirectoryTree, area: Rect, buf: *Buffer) void { + if (area.isEmpty() or self.flat_view.items.len == 0) return; + + // Adjust scroll to keep selected visible + const sel = @as(u16, @intCast(self.selected)); + if (sel >= self.scroll_offset + area.height) { + self.scroll_offset = sel - area.height + 1; + } + if (sel < self.scroll_offset) { + self.scroll_offset = sel; + } + + var y: u16 = 0; + var visible_idx: u16 = 0; + + for (self.flat_view.items) |node_idx| { + if (visible_idx < self.scroll_offset) { + visible_idx += 1; + continue; + } + + if (y >= area.height) break; + + const node = self.nodes.items[node_idx]; + const is_selected = visible_idx == @as(u16, @intCast(self.selected)); + + self.renderNode(node, is_selected, area.x, area.y + y, area.width, buf); + + y += 1; + visible_idx += 1; + } + } + + fn renderNode( + self: *const DirectoryTree, + node: DirNode, + is_selected: bool, + x: u16, + y: u16, + width: u16, + buf: *Buffer, + ) void { + var pos = x; + + // Selection highlight (fill entire line) + if (is_selected) { + var fill_x = x; + while (fill_x < x + width) : (fill_x += 1) { + if (buf.getPtr(fill_x, y)) |cell| { + cell.bg = self.theme.selected.background orelse Color.indexed(236); + } + } + } + + // Indentation with tree guides + const indent = node.depth * 4; + pos += @intCast(indent); + + // Expand/collapse indicator for directories + if (node.kind == .directory) { + const indicator = if (node.expanded) self.symbols.expanded else self.symbols.collapsed; + pos = buf.setString(pos, y, indicator, self.theme.tree_guide); + } + + // Icon + if (self.show_icons) { + const icon = self.icons.forFile(node.name, node.kind, node.expanded); + pos = buf.setString(pos, y, icon, self.getStyleForKind(node.kind)); + pos = buf.setString(pos, y, " ", Style.default); + } + + // Name + const name_style = if (is_selected) + self.getStyleForKind(node.kind).bg(self.theme.selected.background orelse Color.indexed(236)) + else + self.getStyleForKind(node.kind); + + _ = buf.setString(pos, y, node.name, name_style); + } + + fn getStyleForKind(self: *const DirectoryTree, kind: FileKind) Style { + return switch (kind) { + .directory => self.theme.directory, + .file => self.theme.file, + .symlink => self.theme.symlink, + .executable => self.theme.executable, + .hidden => self.theme.hidden, + .special => self.theme.special, + }; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "FileIcons forFile" { + const icons = FileIcons.default; + + try std.testing.expectEqualStrings("📁", icons.forFile("src", .directory, false)); + try std.testing.expectEqualStrings("📂", icons.forFile("src", .directory, true)); + try std.testing.expectEqualStrings("⚡", icons.forFile("main.zig", .file, false)); + try std.testing.expectEqualStrings("🐍", icons.forFile("app.py", .file, false)); + try std.testing.expectEqualStrings("📄", icons.forFile("readme.txt", .file, false)); +} + +test "getExtension" { + try std.testing.expectEqualStrings("zig", getExtension("main.zig").?); + try std.testing.expectEqualStrings("rs", getExtension("lib.rs").?); + try std.testing.expectEqualStrings("gz", getExtension("archive.tar.gz").?); + try std.testing.expect(getExtension("noextension") == null); + try std.testing.expect(getExtension(".hidden") == null); +} + +test "FileKind fromEntry" { + // Basic type detection (can't easily test without real fs entries) + _ = FileKind.directory; + _ = FileKind.file; + _ = FileKind.symlink; +} diff --git a/src/widgets/help.zig b/src/widgets/help.zig new file mode 100644 index 0000000..6b35c78 --- /dev/null +++ b/src/widgets/help.zig @@ -0,0 +1,435 @@ +//! Help widget for displaying keybinding help. +//! +//! Auto-generates a help view from a list of keybindings. +//! Supports single-line and multi-line modes with optional toggle. +//! +//! Inspired by Bubble Tea's help component. +//! +//! ## Example +//! +//! ```zig +//! const bindings = [_]KeyBinding{ +//! .{ .key = "q", .description = "Quit" }, +//! .{ .key = "↑/↓", .description = "Navigate" }, +//! .{ .key = "Enter", .description = "Select" }, +//! .{ .key = "?", .description = "Toggle help" }, +//! }; +//! +//! const help = Help.init(&bindings); +//! help.render(area, buf); +//! ``` + +const std = @import("std"); +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; + +/// A keybinding with its description +pub const KeyBinding = struct { + /// The key or key combination (e.g., "Ctrl+C", "↑/↓", "Enter") + key: []const u8, + /// Description of what the key does + description: []const u8, + /// Optional group for organizing bindings + group: ?[]const u8 = null, + /// Whether this binding is currently active/enabled + enabled: bool = true, +}; + +/// Help display mode +pub const HelpMode = enum { + /// Single line, truncated if too long + single_line, + /// Multiple lines, one binding per line + multi_line, + /// Compact: key and description on same line, multiple bindings per row + compact, + /// Full: grouped bindings with headers + full, +}; + +/// Help widget for displaying keybindings +pub const Help = struct { + /// The keybindings to display + bindings: []const KeyBinding, + /// Display mode + mode: HelpMode = .single_line, + /// Style for the key part + key_style: Style = Style.default.add_modifier(.{ .bold = true }), + /// Style for the description part + desc_style: Style = Style.default, + /// Style for the separator between key and description + sep_style: Style = Style.default.fg(Color.indexed(240)), + /// Style for group headers + group_style: Style = Style.default.add_modifier(.{ .bold = true, .underlined = true }), + /// Style for disabled bindings + disabled_style: Style = Style.default.fg(Color.indexed(240)), + /// Separator between key and description + separator: []const u8 = " ", + /// Separator between bindings (single line mode) + binding_separator: []const u8 = " • ", + /// Show only enabled bindings + show_only_enabled: bool = false, + /// Maximum width (0 = no limit) + max_width: u16 = 0, + /// Ellipsis for truncation + ellipsis: []const u8 = "…", + + /// Creates a new Help widget with the given bindings + pub fn init(bindings: []const KeyBinding) Help { + return .{ .bindings = bindings }; + } + + /// Sets the display mode + pub fn setMode(self: Help, m: HelpMode) Help { + var help = self; + help.mode = m; + return help; + } + + /// Sets the key style + pub fn setKeyStyle(self: Help, s: Style) Help { + var help = self; + help.key_style = s; + return help; + } + + /// Sets the description style + pub fn setDescStyle(self: Help, s: Style) Help { + var help = self; + help.desc_style = s; + return help; + } + + /// Sets the separator between key and description + pub fn setSeparator(self: Help, sep: []const u8) Help { + var help = self; + help.separator = sep; + return help; + } + + /// Sets the separator between bindings + pub fn setBindingSeparator(self: Help, sep: []const u8) Help { + var help = self; + help.binding_separator = sep; + return help; + } + + /// Show only enabled bindings + pub fn showOnlyEnabled(self: Help, only: bool) Help { + var help = self; + help.show_only_enabled = only; + return help; + } + + /// Sets the maximum width + pub fn setMaxWidth(self: Help, width: u16) Help { + var help = self; + help.max_width = width; + return help; + } + + /// Toggle between single line and multi-line mode + pub fn toggleMode(self: *Help) void { + self.mode = switch (self.mode) { + .single_line => .multi_line, + .multi_line => .full, + .full => .single_line, + .compact => .single_line, + }; + } + + /// Returns the number of lines needed for rendering + pub fn height(self: *const Help) u16 { + return switch (self.mode) { + .single_line => 1, + .multi_line, .compact => @intCast(self.countEnabledBindings()), + .full => blk: { + var h: u16 = 0; + var last_group: ?[]const u8 = null; + for (self.bindings) |binding| { + if (self.show_only_enabled and !binding.enabled) continue; + if (binding.group) |g| { + if (last_group == null or !std.mem.eql(u8, last_group.?, g)) { + h += 2; // Group header + spacing + last_group = g; + } + } + h += 1; + } + break :blk h; + }, + }; + } + + fn countEnabledBindings(self: *const Help) usize { + if (!self.show_only_enabled) return self.bindings.len; + var count: usize = 0; + for (self.bindings) |b| { + if (b.enabled) count += 1; + } + return count; + } + + /// Renders the help widget + pub fn render(self: *const Help, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + switch (self.mode) { + .single_line => self.renderSingleLine(area, buf), + .multi_line => self.renderMultiLine(area, buf), + .compact => self.renderCompact(area, buf), + .full => self.renderFull(area, buf), + } + } + + fn renderSingleLine(self: *const Help, area: Rect, buf: *Buffer) void { + const max_w = if (self.max_width > 0) @min(self.max_width, area.width) else area.width; + var x = area.x; + var first = true; + + for (self.bindings) |binding| { + if (self.show_only_enabled and !binding.enabled) continue; + + // Add separator between bindings + if (!first) { + if (x + self.binding_separator.len > area.x + max_w) { + // No space for separator + more content, add ellipsis + if (x + self.ellipsis.len <= area.x + area.width) { + _ = buf.setString(x, area.y, self.ellipsis, self.sep_style); + } + return; + } + _ = buf.setString(x, area.y, self.binding_separator, self.sep_style); + x += @intCast(self.binding_separator.len); + } + first = false; + + const binding_style = if (binding.enabled) self.key_style else self.disabled_style; + const desc_style_actual = if (binding.enabled) self.desc_style else self.disabled_style; + + // Calculate space needed + const key_len = binding.key.len; + const sep_len = self.separator.len; + const desc_len = binding.description.len; + const total_len = key_len + sep_len + desc_len; + + if (x + total_len > area.x + max_w) { + // Truncate or skip + if (x + key_len + sep_len <= area.x + max_w) { + // Can fit key + separator, truncate description + x = buf.setString(x, area.y, binding.key, binding_style); + x = buf.setString(x, area.y, self.separator, self.sep_style); + const remaining = (area.x + max_w) -| x -| @as(u16, @intCast(self.ellipsis.len)); + if (remaining > 0 and remaining < desc_len) { + _ = buf.setString(x, area.y, binding.description[0..remaining], desc_style_actual); + x += @intCast(remaining); + _ = buf.setString(x, area.y, self.ellipsis, self.sep_style); + } + } + return; + } + + // Render binding + x = buf.setString(x, area.y, binding.key, binding_style); + x = buf.setString(x, area.y, self.separator, self.sep_style); + x = buf.setString(x, area.y, binding.description, desc_style_actual); + } + } + + fn renderMultiLine(self: *const Help, area: Rect, buf: *Buffer) void { + var y = area.y; + + for (self.bindings) |binding| { + if (y >= area.bottom()) break; + if (self.show_only_enabled and !binding.enabled) continue; + + const binding_style = if (binding.enabled) self.key_style else self.disabled_style; + const desc_style_actual = if (binding.enabled) self.desc_style else self.disabled_style; + + var x = area.x; + x = buf.setString(x, y, binding.key, binding_style); + x = buf.setString(x, y, self.separator, self.sep_style); + _ = buf.setString(x, y, binding.description, desc_style_actual); + + y += 1; + } + } + + fn renderCompact(self: *const Help, area: Rect, buf: *Buffer) void { + // Same as multi-line but try to fit multiple on one line + var y = area.y; + var x = area.x; + + for (self.bindings) |binding| { + if (y >= area.bottom()) break; + if (self.show_only_enabled and !binding.enabled) continue; + + const binding_style = if (binding.enabled) self.key_style else self.disabled_style; + const desc_style_actual = if (binding.enabled) self.desc_style else self.disabled_style; + + const key_len = binding.key.len; + const sep_len = self.separator.len; + const desc_len = binding.description.len; + const binding_sep_len = self.binding_separator.len; + const total_len = key_len + sep_len + desc_len + binding_sep_len; + + // Check if we need to wrap + if (x > area.x and x + total_len > area.right()) { + y += 1; + x = area.x; + if (y >= area.bottom()) break; + } + + // Add binding separator if not at start + if (x > area.x) { + _ = buf.setString(x, y, self.binding_separator, self.sep_style); + x += @intCast(binding_sep_len); + } + + x = buf.setString(x, y, binding.key, binding_style); + x = buf.setString(x, y, self.separator, self.sep_style); + x = buf.setString(x, y, binding.description, desc_style_actual); + } + } + + fn renderFull(self: *const Help, area: Rect, buf: *Buffer) void { + var y = area.y; + var last_group: ?[]const u8 = null; + + for (self.bindings) |binding| { + if (y >= area.bottom()) break; + if (self.show_only_enabled and !binding.enabled) continue; + + // Check for group change + if (binding.group) |group| { + if (last_group == null or !std.mem.eql(u8, last_group.?, group)) { + if (y > area.y) { + y += 1; // Extra spacing before new group + if (y >= area.bottom()) break; + } + _ = buf.setString(area.x, y, group, self.group_style); + y += 1; + if (y >= area.bottom()) break; + last_group = group; + } + } + + const binding_style = if (binding.enabled) self.key_style else self.disabled_style; + const desc_style_actual = if (binding.enabled) self.desc_style else self.disabled_style; + + // Indent grouped items + const indent: u16 = if (binding.group != null) 2 else 0; + var x = area.x + indent; + + x = buf.setString(x, y, binding.key, binding_style); + x = buf.setString(x, y, self.separator, self.sep_style); + _ = buf.setString(x, y, binding.description, desc_style_actual); + + y += 1; + } + } +}; + +/// Predefined keybinding sets for common patterns +pub const CommonBindings = struct { + pub const quit = KeyBinding{ .key = "q", .description = "Quit" }; + pub const quit_esc = KeyBinding{ .key = "Esc", .description = "Quit" }; + pub const help = KeyBinding{ .key = "?", .description = "Help" }; + pub const navigate = KeyBinding{ .key = "↑/↓", .description = "Navigate" }; + pub const navigate_vim = KeyBinding{ .key = "j/k", .description = "Navigate" }; + pub const select = KeyBinding{ .key = "Enter", .description = "Select" }; + pub const back = KeyBinding{ .key = "Backspace", .description = "Back" }; + pub const tab_next = KeyBinding{ .key = "Tab", .description = "Next" }; + pub const tab_prev = KeyBinding{ .key = "Shift+Tab", .description = "Previous" }; + pub const scroll_up = KeyBinding{ .key = "PgUp", .description = "Page up" }; + pub const scroll_down = KeyBinding{ .key = "PgDn", .description = "Page down" }; + pub const home = KeyBinding{ .key = "Home", .description = "Go to start" }; + pub const end = KeyBinding{ .key = "End", .description = "Go to end" }; + pub const search = KeyBinding{ .key = "/", .description = "Search" }; + pub const copy = KeyBinding{ .key = "Ctrl+C", .description = "Copy" }; + pub const paste = KeyBinding{ .key = "Ctrl+V", .description = "Paste" }; + pub const undo = KeyBinding{ .key = "Ctrl+Z", .description = "Undo" }; + pub const redo = KeyBinding{ .key = "Ctrl+Y", .description = "Redo" }; + pub const save = KeyBinding{ .key = "Ctrl+S", .description = "Save" }; + pub const confirm = KeyBinding{ .key = "y", .description = "Yes" }; + pub const cancel = KeyBinding{ .key = "n", .description = "No" }; +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Help creation" { + const bindings = [_]KeyBinding{ + .{ .key = "q", .description = "Quit" }, + .{ .key = "?", .description = "Help" }, + }; + + const help = Help.init(&bindings); + try std.testing.expectEqual(@as(usize, 2), help.bindings.len); +} + +test "Help mode toggle" { + const bindings = [_]KeyBinding{ + .{ .key = "q", .description = "Quit" }, + }; + + var help = Help.init(&bindings); + try std.testing.expectEqual(HelpMode.single_line, help.mode); + + help.toggleMode(); + try std.testing.expectEqual(HelpMode.multi_line, help.mode); + + help.toggleMode(); + try std.testing.expectEqual(HelpMode.full, help.mode); + + help.toggleMode(); + try std.testing.expectEqual(HelpMode.single_line, help.mode); +} + +test "Help height calculation" { + const bindings = [_]KeyBinding{ + .{ .key = "q", .description = "Quit" }, + .{ .key = "?", .description = "Help" }, + .{ .key = "Enter", .description = "Select" }, + }; + + var help = Help.init(&bindings); + + try std.testing.expectEqual(@as(u16, 1), help.height()); + + help = help.setMode(.multi_line); + try std.testing.expectEqual(@as(u16, 3), help.height()); +} + +test "Help with groups" { + const bindings = [_]KeyBinding{ + .{ .key = "q", .description = "Quit", .group = "General" }, + .{ .key = "?", .description = "Help", .group = "General" }, + .{ .key = "↑", .description = "Up", .group = "Navigation" }, + .{ .key = "↓", .description = "Down", .group = "Navigation" }, + }; + + const help = Help.init(&bindings).setMode(.full); + // 2 groups * 2 (header + space) + 4 bindings = 8, but first group has no leading space + try std.testing.expect(help.height() >= 6); +} + +test "Help only enabled" { + const bindings = [_]KeyBinding{ + .{ .key = "q", .description = "Quit", .enabled = true }, + .{ .key = "x", .description = "Delete", .enabled = false }, + }; + + var help = Help.init(&bindings).showOnlyEnabled(true).setMode(.multi_line); + try std.testing.expectEqual(@as(u16, 1), help.height()); +} + +test "Common bindings exist" { + try std.testing.expectEqualStrings("q", CommonBindings.quit.key); + try std.testing.expectEqualStrings("Quit", CommonBindings.quit.description); +} diff --git a/src/widgets/markdown.zig b/src/widgets/markdown.zig new file mode 100644 index 0000000..516db2f --- /dev/null +++ b/src/widgets/markdown.zig @@ -0,0 +1,549 @@ +//! Markdown rendering widget. +//! +//! Renders markdown text with appropriate styling for terminal display. +//! Supports common markdown elements: headers, bold, italic, code, lists, etc. +//! +//! ## Example +//! +//! ```zig +//! const md = Markdown.init( +//! \\# Hello World +//! \\ +//! \\This is **bold** and *italic* text. +//! \\ +//! \\- Item 1 +//! \\- Item 2 +//! ); +//! md.render(area, buf); +//! ``` + +const std = @import("std"); +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const Modifier = style_mod.Modifier; + +/// Markdown theme for styling different elements +pub const MarkdownTheme = struct { + /// Normal text + text: Style = Style.default, + /// Headers (H1-H6) + h1: Style = Style.default.fg(Color.cyan).add_modifier(.{ .bold = true }), + h2: Style = Style.default.fg(Color.blue).add_modifier(.{ .bold = true }), + h3: Style = Style.default.fg(Color.green).add_modifier(.{ .bold = true }), + h4: Style = Style.default.fg(Color.yellow).add_modifier(.{ .bold = true }), + h5: Style = Style.default.fg(Color.magenta).add_modifier(.{ .bold = true }), + h6: Style = Style.default.fg(Color.white).add_modifier(.{ .bold = true }), + /// Bold text + bold: Style = Style.default.add_modifier(.{ .bold = true }), + /// Italic text + italic: Style = Style.default.add_modifier(.{ .italic = true }), + /// Bold + italic + bold_italic: Style = Style.default.add_modifier(.{ .bold = true, .italic = true }), + /// Inline code + code: Style = Style.default.fg(Color.yellow).bg(Color.indexed(236)), + /// Code block + code_block: Style = Style.default.fg(Color.green).bg(Color.indexed(234)), + /// Block quote + quote: Style = Style.default.fg(Color.indexed(245)).add_modifier(.{ .italic = true }), + /// Quote border + quote_border: Style = Style.default.fg(Color.indexed(240)), + /// Links + link: Style = Style.default.fg(Color.blue).add_modifier(.{ .underlined = true }), + /// Link URL (shown in parens) + link_url: Style = Style.default.fg(Color.indexed(240)), + /// List bullet/number + list_marker: Style = Style.default.fg(Color.cyan), + /// Horizontal rule + hr: Style = Style.default.fg(Color.indexed(240)), + /// Strikethrough + strikethrough: Style = Style.default.add_modifier(.{ .crossed_out = true }), + + pub const default: MarkdownTheme = .{}; + + pub const minimal: MarkdownTheme = .{ + .h1 = Style.default.add_modifier(.{ .bold = true }), + .h2 = Style.default.add_modifier(.{ .bold = true }), + .h3 = Style.default.add_modifier(.{ .bold = true }), + .h4 = Style.default.add_modifier(.{ .bold = true }), + .h5 = Style.default.add_modifier(.{ .bold = true }), + .h6 = Style.default.add_modifier(.{ .bold = true }), + .code = Style.default, + .code_block = Style.default, + .quote = Style.default.add_modifier(.{ .italic = true }), + }; +}; + +/// Line type detected during parsing +const LineType = enum { + empty, + h1, + h2, + h3, + h4, + h5, + h6, + bullet_list, + number_list, + quote, + code_block_fence, + hr, + text, +}; + +/// Markdown rendering widget +pub const Markdown = struct { + /// Source markdown text + source: []const u8, + /// Theme for styling + theme: MarkdownTheme = MarkdownTheme.default, + /// Scroll offset (line-based) + scroll: u16 = 0, + /// Wrap text to width + wrap: bool = true, + /// Show line numbers + show_line_numbers: bool = false, + /// Indent for wrapped lines + wrap_indent: u16 = 2, + + /// Creates a new Markdown widget + pub fn init(source: []const u8) Markdown { + return .{ .source = source }; + } + + /// Sets the theme + pub fn setTheme(self: Markdown, t: MarkdownTheme) Markdown { + var md = self; + md.theme = t; + return md; + } + + /// Sets scroll offset + pub fn setScroll(self: Markdown, s: u16) Markdown { + var md = self; + md.scroll = s; + return md; + } + + /// Enables/disables text wrapping + pub fn setWrap(self: Markdown, w: bool) Markdown { + var md = self; + md.wrap = w; + return md; + } + + /// Renders the markdown to the buffer + pub fn render(self: *const Markdown, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + var y: u16 = 0; + var line_iter = std.mem.splitScalar(u8, self.source, '\n'); + var current_line: u16 = 0; + var in_code_block = false; + + while (line_iter.next()) |line| { + if (current_line < self.scroll) { + current_line += 1; + // Track code block state even when scrolled + if (isCodeFence(line)) in_code_block = !in_code_block; + continue; + } + + if (y >= area.height) break; + + const line_type = if (in_code_block and !isCodeFence(line)) + LineType.text + else + detectLineType(line); + + // Handle code block fence + if (line_type == .code_block_fence) { + in_code_block = !in_code_block; + current_line += 1; + continue; // Don't render the fence itself + } + + const lines_rendered = self.renderLine( + line, + line_type, + in_code_block, + Rect.init(area.x, area.y + y, area.width, area.height - y), + buf, + ); + + y += lines_rendered; + current_line += 1; + } + } + + fn renderLine( + self: *const Markdown, + line: []const u8, + line_type: LineType, + in_code_block: bool, + area: Rect, + buf: *Buffer, + ) u16 { + if (area.isEmpty()) return 0; + + const style = self.getStyleForType(line_type, in_code_block); + const content = self.getContentForType(line, line_type); + const prefix = self.getPrefixForType(line_type); + const prefix_style = self.getPrefixStyleForType(line_type); + + var x = area.x; + var lines_used: u16 = 1; + + // Render prefix (bullet, number, quote marker, etc.) + if (prefix.len > 0) { + x = buf.setString(x, area.y, prefix, prefix_style); + } + + // Render content with inline formatting + if (in_code_block) { + // Code blocks are rendered literally + _ = buf.setString(x, area.y, content, self.theme.code_block); + } else if (line_type == .hr) { + // Horizontal rule + var hr_x = area.x; + while (hr_x < area.right()) { + _ = buf.setString(hr_x, area.y, "─", self.theme.hr); + hr_x += 1; + } + } else { + // Parse and render inline formatting + lines_used = self.renderInlineFormatted(content, style, x, area, buf); + } + + return lines_used; + } + + fn renderInlineFormatted( + self: *const Markdown, + text: []const u8, + base_style: Style, + start_x: u16, + area: Rect, + buf: *Buffer, + ) u16 { + var x = start_x; + var y = area.y; + var i: usize = 0; + var lines_used: u16 = 1; + + while (i < text.len) { + // Check for inline formatting + if (i + 1 < text.len) { + // Bold + Italic: ***text*** or ___text___ + if ((text[i] == '*' or text[i] == '_') and + i + 2 < text.len and + text[i + 1] == text[i] and + text[i + 2] == text[i]) + { + if (self.findClosing(text[i + 3 ..], text[i .. i + 3])) |end| { + const inner = text[i + 3 .. i + 3 + end]; + x = buf.setString(x, y, inner, self.theme.bold_italic); + i += 6 + end; + continue; + } + } + + // Bold: **text** or __text__ + if ((text[i] == '*' or text[i] == '_') and text[i + 1] == text[i]) { + if (self.findClosing(text[i + 2 ..], text[i .. i + 2])) |end| { + const inner = text[i + 2 .. i + 2 + end]; + x = buf.setString(x, y, inner, self.theme.bold); + i += 4 + end; + continue; + } + } + + // Italic: *text* or _text_ + if (text[i] == '*' or text[i] == '_') { + if (self.findClosing(text[i + 1 ..], text[i .. i + 1])) |end| { + const inner = text[i + 1 .. i + 1 + end]; + x = buf.setString(x, y, inner, self.theme.italic); + i += 2 + end; + continue; + } + } + + // Strikethrough: ~~text~~ + if (text[i] == '~' and text[i + 1] == '~') { + if (self.findClosing(text[i + 2 ..], "~~")) |end| { + const inner = text[i + 2 .. i + 2 + end]; + x = buf.setString(x, y, inner, self.theme.strikethrough); + i += 4 + end; + continue; + } + } + } + + // Inline code: `text` + if (text[i] == '`') { + if (self.findClosing(text[i + 1 ..], "`")) |end| { + const inner = text[i + 1 .. i + 1 + end]; + x = buf.setString(x, y, inner, self.theme.code); + i += 2 + end; + continue; + } + } + + // Link: [text](url) + if (text[i] == '[') { + if (self.parseLink(text[i..])) |link| { + x = buf.setString(x, y, link.text, self.theme.link); + i += link.total_len; + continue; + } + } + + // Regular character + const char_end = i + getUtf8Len(text[i]); + x = buf.setString(x, y, text[i..@min(char_end, text.len)], base_style); + i = @min(char_end, text.len); + + // Handle wrapping + if (self.wrap and x >= area.right() and i < text.len) { + y += 1; + lines_used += 1; + if (y >= area.y + area.height) break; + x = area.x + self.wrap_indent; + } + } + + return lines_used; + } + + fn findClosing(self: *const Markdown, text: []const u8, marker: []const u8) ?usize { + _ = self; + var i: usize = 0; + while (i + marker.len <= text.len) { + if (std.mem.eql(u8, text[i .. i + marker.len], marker)) { + return i; + } + i += 1; + } + return null; + } + + const LinkInfo = struct { + text: []const u8, + url: []const u8, + total_len: usize, + }; + + fn parseLink(self: *const Markdown, text: []const u8) ?LinkInfo { + _ = self; + if (text.len < 4 or text[0] != '[') return null; + + // Find ] + var i: usize = 1; + while (i < text.len and text[i] != ']') : (i += 1) {} + if (i >= text.len - 2 or text[i + 1] != '(') return null; + + const link_text = text[1..i]; + const url_start = i + 2; + + // Find ) + var j = url_start; + while (j < text.len and text[j] != ')') : (j += 1) {} + if (j >= text.len) return null; + + return .{ + .text = link_text, + .url = text[url_start..j], + .total_len = j + 1, + }; + } + + fn getStyleForType(self: *const Markdown, line_type: LineType, in_code_block: bool) Style { + if (in_code_block) return self.theme.code_block; + + return switch (line_type) { + .h1 => self.theme.h1, + .h2 => self.theme.h2, + .h3 => self.theme.h3, + .h4 => self.theme.h4, + .h5 => self.theme.h5, + .h6 => self.theme.h6, + .quote => self.theme.quote, + .bullet_list, .number_list, .text, .empty => self.theme.text, + .code_block_fence, .hr => self.theme.text, + }; + } + + fn getContentForType(self: *const Markdown, line: []const u8, line_type: LineType) []const u8 { + _ = self; + return switch (line_type) { + .h1 => std.mem.trimLeft(u8, line, "# "), + .h2 => std.mem.trimLeft(u8, line, "# "), + .h3 => std.mem.trimLeft(u8, line, "# "), + .h4 => std.mem.trimLeft(u8, line, "# "), + .h5 => std.mem.trimLeft(u8, line, "# "), + .h6 => std.mem.trimLeft(u8, line, "# "), + .bullet_list => blk: { + const trimmed = std.mem.trimLeft(u8, line, " \t"); + break :blk if (trimmed.len > 2) trimmed[2..] else ""; + }, + .number_list => blk: { + const trimmed = std.mem.trimLeft(u8, line, " \t"); + // Skip "1. " etc + var i: usize = 0; + while (i < trimmed.len and (std.ascii.isDigit(trimmed[i]) or trimmed[i] == '.')) : (i += 1) {} + break :blk if (i < trimmed.len) std.mem.trimLeft(u8, trimmed[i..], " ") else ""; + }, + .quote => std.mem.trimLeft(u8, std.mem.trimLeft(u8, line, " \t"), "> "), + else => line, + }; + } + + fn getPrefixForType(self: *const Markdown, line_type: LineType) []const u8 { + _ = self; + return switch (line_type) { + .h1 => "# ", + .h2 => "## ", + .h3 => "### ", + .bullet_list => "• ", + .number_list => " ", + .quote => "│ ", + else => "", + }; + } + + fn getPrefixStyleForType(self: *const Markdown, line_type: LineType) Style { + return switch (line_type) { + .h1, .h2, .h3, .h4, .h5, .h6 => self.getStyleForType(line_type, false), + .bullet_list, .number_list => self.theme.list_marker, + .quote => self.theme.quote_border, + else => self.theme.text, + }; + } +}; + +fn detectLineType(line: []const u8) LineType { + const trimmed = std.mem.trimLeft(u8, line, " \t"); + + if (trimmed.len == 0) return .empty; + + // Headers + if (std.mem.startsWith(u8, trimmed, "######")) return .h6; + if (std.mem.startsWith(u8, trimmed, "#####")) return .h5; + if (std.mem.startsWith(u8, trimmed, "####")) return .h4; + if (std.mem.startsWith(u8, trimmed, "###")) return .h3; + if (std.mem.startsWith(u8, trimmed, "##")) return .h2; + if (std.mem.startsWith(u8, trimmed, "#")) return .h1; + + // Code fence + if (isCodeFence(line)) return .code_block_fence; + + // Horizontal rule + if (isHorizontalRule(trimmed)) return .hr; + + // Block quote + if (trimmed[0] == '>') return .quote; + + // Lists + if ((trimmed[0] == '-' or trimmed[0] == '*' or trimmed[0] == '+') and + trimmed.len > 1 and trimmed[1] == ' ') + { + return .bullet_list; + } + + // Numbered list + if (std.ascii.isDigit(trimmed[0])) { + var i: usize = 0; + while (i < trimmed.len and std.ascii.isDigit(trimmed[i])) : (i += 1) {} + if (i < trimmed.len and trimmed[i] == '.' and i + 1 < trimmed.len and trimmed[i + 1] == ' ') { + return .number_list; + } + } + + return .text; +} + +fn isCodeFence(line: []const u8) bool { + const trimmed = std.mem.trimLeft(u8, line, " \t"); + return std.mem.startsWith(u8, trimmed, "```") or std.mem.startsWith(u8, trimmed, "~~~"); +} + +fn isHorizontalRule(line: []const u8) bool { + if (line.len < 3) return false; + + var count: usize = 0; + var char: u8 = 0; + + for (line) |c| { + if (c == ' ' or c == '\t') continue; + if (c == '-' or c == '*' or c == '_') { + if (char == 0) char = c; + if (c == char) count += 1 else return false; + } else { + return false; + } + } + + return count >= 3; +} + +fn getUtf8Len(first_byte: u8) usize { + if (first_byte < 0x80) return 1; + if (first_byte < 0xE0) return 2; + if (first_byte < 0xF0) return 3; + return 4; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "detectLineType headers" { + try std.testing.expectEqual(LineType.h1, detectLineType("# Header")); + try std.testing.expectEqual(LineType.h2, detectLineType("## Header")); + try std.testing.expectEqual(LineType.h3, detectLineType("### Header")); + try std.testing.expectEqual(LineType.h4, detectLineType("#### Header")); + try std.testing.expectEqual(LineType.h5, detectLineType("##### Header")); + try std.testing.expectEqual(LineType.h6, detectLineType("###### Header")); +} + +test "detectLineType lists" { + try std.testing.expectEqual(LineType.bullet_list, detectLineType("- Item")); + try std.testing.expectEqual(LineType.bullet_list, detectLineType("* Item")); + try std.testing.expectEqual(LineType.bullet_list, detectLineType("+ Item")); + try std.testing.expectEqual(LineType.number_list, detectLineType("1. Item")); + try std.testing.expectEqual(LineType.number_list, detectLineType("10. Item")); +} + +test "detectLineType special" { + try std.testing.expectEqual(LineType.quote, detectLineType("> Quote")); + try std.testing.expectEqual(LineType.code_block_fence, detectLineType("```")); + try std.testing.expectEqual(LineType.code_block_fence, detectLineType("~~~")); + try std.testing.expectEqual(LineType.hr, detectLineType("---")); + try std.testing.expectEqual(LineType.hr, detectLineType("***")); + try std.testing.expectEqual(LineType.hr, detectLineType("___")); + try std.testing.expectEqual(LineType.empty, detectLineType("")); + try std.testing.expectEqual(LineType.text, detectLineType("Normal text")); +} + +test "isHorizontalRule" { + try std.testing.expect(isHorizontalRule("---")); + try std.testing.expect(isHorizontalRule("***")); + try std.testing.expect(isHorizontalRule("___")); + try std.testing.expect(isHorizontalRule("- - -")); + try std.testing.expect(isHorizontalRule("* * *")); + try std.testing.expect(!isHorizontalRule("--")); + try std.testing.expect(!isHorizontalRule("-*-")); +} + +test "Markdown creation" { + const md = Markdown.init("# Hello"); + try std.testing.expectEqualStrings("# Hello", md.source); +} + +test "Markdown theme" { + const md = Markdown.init("# Test").setTheme(MarkdownTheme.minimal); + try std.testing.expect(md.theme.h1.add_modifiers.bold); +} diff --git a/src/widgets/progress.zig b/src/widgets/progress.zig new file mode 100644 index 0000000..f5673a5 --- /dev/null +++ b/src/widgets/progress.zig @@ -0,0 +1,544 @@ +//! Enhanced progress bar widget with ETA and speed calculation. +//! +//! Extends the basic Gauge with: +//! - ETA (Estimated Time of Arrival) +//! - Speed calculation (items/second) +//! - Elapsed time display +//! - Multiple display formats +//! - Indeterminate mode +//! +//! ## Example +//! +//! ```zig +//! var progress = Progress.init(); +//! progress.start(100); // 100 total items +//! +//! // Update progress +//! progress.set(50); // 50% complete +//! progress.render(area, buf); +//! +//! // Shows: [████████████░░░░░░░░░░░░] 50% (50/100) ETA: 0:30 +//! ``` + +const std = @import("std"); +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const block_mod = @import("block.zig"); +const Block = block_mod.Block; + +/// Progress display format +pub const ProgressFormat = enum { + /// Just the bar: [████░░░░] + bar_only, + /// Bar with percentage: [████░░░░] 50% + percentage, + /// Bar with ratio: [████░░░░] 50/100 + ratio, + /// Bar with all info: [████░░░░] 50% (50/100) ETA: 0:30 + full, + /// Minimal: percentage only + minimal, + /// Custom (use setCustomFormat) + custom, +}; + +/// Progress bar widget with ETA +pub const Progress = struct { + /// Current progress value + current: u64 = 0, + /// Total value (0 for indeterminate) + total: u64 = 0, + /// Start timestamp (nanoseconds) + start_time: i128 = 0, + /// Last update timestamp + last_update: i128 = 0, + /// Samples for speed calculation (ring buffer) + samples: [16]Sample = [_]Sample{.{}} ** 16, + sample_idx: usize = 0, + sample_count: usize = 0, + + // Display options + /// Display format + format: ProgressFormat = .full, + /// Bar style (filled part) + bar_style: Style = Style.default.fg(Color.green), + /// Background style (empty part) + bg_style: Style = Style.default.fg(Color.indexed(240)), + /// Text style + text_style: Style = Style.default, + /// Filled character + filled_char: []const u8 = "█", + /// Empty character + empty_char: []const u8 = "░", + /// Half-filled character (for smoother progress) + half_char: []const u8 = "▌", + /// Optional block wrapper + block: ?Block = null, + /// Show spinner for indeterminate + show_spinner: bool = true, + /// Spinner frame for indeterminate mode + spinner_frame: usize = 0, + /// Minimum bar width + min_bar_width: u16 = 10, + /// Hide ETA when not meaningful + hide_eta_threshold: u64 = 2, // Hide if total < this + + const Sample = struct { + value: u64 = 0, + time: i128 = 0, + }; + + const spinner_frames = [_][]const u8{ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; + + /// Creates a new progress bar + pub fn init() Progress { + return .{}; + } + + /// Starts tracking progress with a total value + pub fn start(self: *Progress, total_val: u64) void { + self.total = total_val; + self.current = 0; + self.start_time = std.time.nanoTimestamp(); + self.last_update = self.start_time; + self.sample_idx = 0; + self.sample_count = 0; + } + + /// Sets the current progress value + pub fn set(self: *Progress, value: u64) void { + self.current = if (self.total > 0) @min(value, self.total) else value; + self.recordSample(); + } + + /// Increments progress by amount + pub fn increment(self: *Progress, amount: u64) void { + self.set(self.current +| amount); + } + + /// Increments progress by 1 + pub fn tick(self: *Progress) void { + self.increment(1); + } + + /// Returns true if progress is complete + pub fn isComplete(self: *const Progress) bool { + return self.total > 0 and self.current >= self.total; + } + + /// Returns progress as percentage (0-100) + pub fn percentage(self: *const Progress) u8 { + if (self.total == 0) return 0; + return @intCast((self.current * 100) / self.total); + } + + /// Returns progress as ratio (0.0-1.0) + pub fn ratio(self: *const Progress) f64 { + if (self.total == 0) return 0; + return @as(f64, @floatFromInt(self.current)) / @as(f64, @floatFromInt(self.total)); + } + + /// Returns elapsed time in seconds + pub fn elapsedSeconds(self: *const Progress) f64 { + if (self.start_time == 0) return 0; + const now = std.time.nanoTimestamp(); + const elapsed_ns = now - self.start_time; + return @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000_000.0; + } + + /// Returns estimated items per second + pub fn itemsPerSecond(self: *const Progress) f64 { + if (self.sample_count < 2) return 0; + + // Calculate from recent samples + const oldest_idx = if (self.sample_count >= 16) + (self.sample_idx + 1) % 16 + else + 0; + + const oldest = self.samples[oldest_idx]; + const newest = self.samples[if (self.sample_idx == 0) 15 else self.sample_idx - 1]; + + if (newest.time <= oldest.time) return 0; + + const value_diff = newest.value -| oldest.value; + const time_diff_ns = newest.time - oldest.time; + const time_diff_s = @as(f64, @floatFromInt(time_diff_ns)) / 1_000_000_000.0; + + if (time_diff_s == 0) return 0; + return @as(f64, @floatFromInt(value_diff)) / time_diff_s; + } + + /// Returns ETA in seconds (null if indeterminate or complete) + pub fn etaSeconds(self: *const Progress) ?f64 { + if (self.total == 0 or self.current >= self.total) return null; + + const speed = self.itemsPerSecond(); + if (speed <= 0) { + // Fall back to simple calculation + const elapsed = self.elapsedSeconds(); + if (elapsed <= 0 or self.current == 0) return null; + const rate = @as(f64, @floatFromInt(self.current)) / elapsed; + if (rate <= 0) return null; + return @as(f64, @floatFromInt(self.total - self.current)) / rate; + } + + return @as(f64, @floatFromInt(self.total - self.current)) / speed; + } + + fn recordSample(self: *Progress) void { + const now = std.time.nanoTimestamp(); + self.samples[self.sample_idx] = .{ + .value = self.current, + .time = now, + }; + self.sample_idx = (self.sample_idx + 1) % 16; + if (self.sample_count < 16) self.sample_count += 1; + self.last_update = now; + } + + // Builder methods + pub fn setFormat(self: Progress, fmt: ProgressFormat) Progress { + var p = self; + p.format = fmt; + return p; + } + + pub fn setBarStyle(self: Progress, s: Style) Progress { + var p = self; + p.bar_style = s; + return p; + } + + pub fn setBgStyle(self: Progress, s: Style) Progress { + var p = self; + p.bg_style = s; + return p; + } + + pub fn setTextStyle(self: Progress, s: Style) Progress { + var p = self; + p.text_style = s; + return p; + } + + pub fn setBlock(self: Progress, b: Block) Progress { + var p = self; + p.block = b; + return p; + } + + pub fn setChars(self: Progress, filled: []const u8, empty: []const u8) Progress { + var p = self; + p.filled_char = filled; + p.empty_char = empty; + return p; + } + + /// Advances spinner frame (call in render loop for indeterminate) + pub fn advanceSpinner(self: *Progress) void { + self.spinner_frame = (self.spinner_frame + 1) % spinner_frames.len; + } + + /// Renders the progress bar + pub fn render(self: *const Progress, 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; + + if (self.total == 0) { + self.renderIndeterminate(inner, buf); + return; + } + + // Calculate bar width based on format + const info_str = self.formatInfo(); + const info_width: u16 = @intCast(info_str.len); + const bar_width = if (inner.width > info_width + 3) + inner.width - info_width - 1 + else + @min(self.min_bar_width, inner.width); + + // Render bar + self.renderBar(Rect.init(inner.x, inner.y, bar_width, 1), buf); + + // Render info text + if (bar_width < inner.width) { + _ = buf.setString(inner.x + bar_width + 1, inner.y, &info_str, self.text_style); + } + } + + fn renderBar(self: *const Progress, area: Rect, buf: *Buffer) void { + const r = self.ratio(); + const filled_width = @as(u16, @intFromFloat(@as(f64, @floatFromInt(area.width)) * r)); + const remainder = (@as(f64, @floatFromInt(area.width)) * r) - @as(f64, @floatFromInt(filled_width)); + const show_half = remainder >= 0.5; + + var x = area.x; + + // Filled portion + var i: u16 = 0; + while (i < filled_width and x < area.right()) : (i += 1) { + _ = buf.setString(x, area.y, self.filled_char, self.bar_style); + x += 1; + } + + // Half-filled (if applicable) + if (show_half and x < area.right()) { + _ = buf.setString(x, area.y, self.half_char, self.bar_style); + x += 1; + } + + // Empty portion + while (x < area.right()) { + _ = buf.setString(x, area.y, self.empty_char, self.bg_style); + x += 1; + } + } + + fn renderIndeterminate(self: *const Progress, area: Rect, buf: *Buffer) void { + if (self.show_spinner) { + const frame = spinner_frames[self.spinner_frame % spinner_frames.len]; + _ = buf.setString(area.x, area.y, frame, self.bar_style); + + if (area.width > 2) { + _ = buf.setString(area.x + 2, area.y, "Loading...", self.text_style); + } + } else { + // Bouncing bar animation + const pos = @as(u16, @intCast(self.spinner_frame % @as(usize, @intCast(area.width)))); + var x = area.x; + while (x < area.right()) { + const char = if (x == area.x + pos) self.filled_char else self.empty_char; + const style = if (x == area.x + pos) self.bar_style else self.bg_style; + _ = buf.setString(x, area.y, char, style); + x += 1; + } + } + } + + fn formatInfo(self: *const Progress) [64]u8 { + var result: [64]u8 = [_]u8{' '} ** 64; + var stream = std.io.fixedBufferStream(&result); + const writer = stream.writer(); + + switch (self.format) { + .bar_only => {}, + .minimal, .percentage => { + writer.print("{d}%", .{self.percentage()}) catch {}; + }, + .ratio => { + writer.print("{d}/{d}", .{ self.current, self.total }) catch {}; + }, + .full => { + writer.print("{d}%", .{self.percentage()}) catch {}; + writer.print(" ({d}/{d})", .{ self.current, self.total }) catch {}; + + if (self.total >= self.hide_eta_threshold) { + if (self.etaSeconds()) |eta| { + const eta_int: u64 = @intFromFloat(eta); + const mins = eta_int / 60; + const secs = eta_int % 60; + writer.print(" ETA: {d}:{d:0>2}", .{ mins, secs }) catch {}; + } + } + }, + .custom => {}, // User handles this + } + + // Trim trailing spaces + var len: usize = 64; + while (len > 0 and result[len - 1] == ' ') len -= 1; + + return result; + } +}; + +/// Multi-progress tracker for concurrent operations +pub const MultiProgress = struct { + bars: [8]?ProgressEntry = [_]?ProgressEntry{null} ** 8, + count: usize = 0, + + const ProgressEntry = struct { + name: []const u8, + progress: Progress, + }; + + /// Adds a new progress bar + pub fn add(self: *MultiProgress, name: []const u8, total: u64) ?*Progress { + if (self.count >= 8) return null; + + self.bars[self.count] = .{ + .name = name, + .progress = Progress.init(), + }; + self.bars[self.count].?.progress.start(total); + + const idx = self.count; + self.count += 1; + return &self.bars[idx].?.progress; + } + + /// Gets a progress bar by name + pub fn get(self: *MultiProgress, name: []const u8) ?*Progress { + for (&self.bars) |*entry| { + if (entry.*) |*e| { + if (std.mem.eql(u8, e.name, name)) { + return &e.progress; + } + } + } + return null; + } + + /// Removes a progress bar by name + pub fn remove(self: *MultiProgress, name: []const u8) bool { + for (&self.bars, 0..) |*entry, i| { + if (entry.*) |e| { + if (std.mem.eql(u8, e.name, name)) { + // Shift remaining + var j = i; + while (j < self.count - 1) : (j += 1) { + self.bars[j] = self.bars[j + 1]; + } + self.bars[self.count - 1] = null; + self.count -= 1; + return true; + } + } + } + return false; + } + + /// Returns overall progress (average of all bars) + pub fn overallPercentage(self: *const MultiProgress) u8 { + if (self.count == 0) return 0; + + var total: u64 = 0; + for (self.bars[0..self.count]) |entry| { + if (entry) |e| { + total += e.progress.percentage(); + } + } + return @intCast(total / self.count); + } + + /// Renders all progress bars vertically + pub fn render(self: *const MultiProgress, area: Rect, buf: *Buffer) void { + var y = area.y; + + for (self.bars[0..self.count]) |entry| { + if (y >= area.bottom()) break; + if (entry) |e| { + // Render name + const name_width = @min(e.name.len, 20); + _ = buf.setString(area.x, y, e.name[0..name_width], Style.default); + + // Render progress bar + const bar_x = area.x + @as(u16, @intCast(name_width)) + 1; + const bar_width = area.width -| @as(u16, @intCast(name_width)) -| 1; + if (bar_width > 5) { + e.progress.render(Rect.init(bar_x, y, bar_width, 1), buf); + } + + y += 1; + } + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Progress basic operations" { + var progress = Progress.init(); + progress.start(100); + + try std.testing.expectEqual(@as(u64, 0), progress.current); + try std.testing.expectEqual(@as(u64, 100), progress.total); + + progress.set(50); + try std.testing.expectEqual(@as(u64, 50), progress.current); + try std.testing.expectEqual(@as(u8, 50), progress.percentage()); + + progress.increment(25); + try std.testing.expectEqual(@as(u64, 75), progress.current); + + progress.tick(); + try std.testing.expectEqual(@as(u64, 76), progress.current); +} + +test "Progress completion" { + var progress = Progress.init(); + progress.start(10); + + try std.testing.expect(!progress.isComplete()); + + progress.set(10); + try std.testing.expect(progress.isComplete()); +} + +test "Progress ratio" { + var progress = Progress.init(); + progress.start(100); + progress.set(25); + + try std.testing.expectApproxEqAbs(@as(f64, 0.25), progress.ratio(), 0.001); +} + +test "Progress clamping" { + var progress = Progress.init(); + progress.start(100); + progress.set(150); // Over total + + try std.testing.expectEqual(@as(u64, 100), progress.current); +} + +test "Progress format settings" { + const progress = Progress.init() + .setFormat(.percentage) + .setBarStyle(Style.default.fg(Color.blue)); + + try std.testing.expectEqual(ProgressFormat.percentage, progress.format); +} + +test "MultiProgress basic" { + var mp = MultiProgress{}; + + const p1 = mp.add("Download", 100); + const p2 = mp.add("Extract", 50); + + try std.testing.expect(p1 != null); + try std.testing.expect(p2 != null); + try std.testing.expectEqual(@as(usize, 2), mp.count); + + p1.?.set(50); + p2.?.set(25); + + // Average: (50% + 50%) / 2 = 50% + try std.testing.expectEqual(@as(u8, 50), mp.overallPercentage()); +} + +test "MultiProgress get and remove" { + var mp = MultiProgress{}; + _ = mp.add("Task1", 100); + _ = mp.add("Task2", 100); + + try std.testing.expect(mp.get("Task1") != null); + try std.testing.expect(mp.get("Task3") == null); + + try std.testing.expect(mp.remove("Task1")); + try std.testing.expectEqual(@as(usize, 1), mp.count); + try std.testing.expect(mp.get("Task1") == null); +} diff --git a/src/widgets/spinner.zig b/src/widgets/spinner.zig new file mode 100644 index 0000000..09a6935 --- /dev/null +++ b/src/widgets/spinner.zig @@ -0,0 +1,282 @@ +//! Spinner widget for showing loading/progress animations. +//! +//! Provides animated spinners for indicating ongoing operations. +//! Includes multiple predefined spinner styles inspired by cli-spinners. +//! +//! ## Example +//! +//! ```zig +//! var spinner = Spinner.init(.dots); +//! // In your update loop: +//! spinner.tick(); +//! // In your render: +//! spinner.render(area, buf); +//! ``` + +const std = @import("std"); +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; + +/// Predefined spinner styles +pub const SpinnerStyle = enum { + /// Classic dots: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ + dots, + /// Braille dots variant: ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷ + dots_braille, + /// Line spinner: - \ | / + line, + /// Arrow spinner: ← ↖ ↑ ↗ → ↘ ↓ ↙ + arrows, + /// Box corners: ◰ ◳ ◲ ◱ + box_corners, + /// Circle quarters: ◴ ◷ ◶ ◵ + circle, + /// Growing blocks: ▏ ▎ ▍ ▌ ▋ ▊ ▉ █ + blocks, + /// Bouncing bar: [= ] [ = ] [ = ] [ =] + bounce, + /// Simple ASCII: . o O @ * + ascii, + /// Clock: 🕐 🕑 🕒 ... + clock, + /// Moon phases: 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘 + moon, + /// Hamburger: ☱ ☲ ☴ + hamburger, + /// Growing dots: . .. ... + growing_dots, + /// Toggle: ⊶ ⊷ + toggle, + /// Square corners: ◢ ◣ ◤ ◥ + square_corners, + /// Star: ✶ ✸ ✹ ✺ ✹ ✷ + star, + /// Flip: _ _ _ - ` ` ' ´ - _ _ _ + flip, + /// Pipe: ┤ ┘ ┴ └ ├ ┌ ┬ ┐ + pipe, +}; + +/// Spinner widget for animated loading indicators +pub const Spinner = struct { + /// Current frame index + frame: usize = 0, + /// Spinner style + spinner_style: SpinnerStyle = .dots, + /// Visual style (colors, modifiers) + style: Style = Style.default, + /// Optional label to show next to spinner + label: ?[]const u8 = null, + /// Tick counter for timing + tick_count: u64 = 0, + /// Ticks per frame (controls speed) + ticks_per_frame: u64 = 1, + + /// Creates a new spinner with the specified style + pub fn init(spinner_style: SpinnerStyle) Spinner { + return .{ .spinner_style = spinner_style }; + } + + /// Sets the visual style + pub fn setStyle(self: Spinner, s: Style) Spinner { + var spinner = self; + spinner.style = s; + return spinner; + } + + /// Sets the foreground color + pub fn fg(self: Spinner, color: Color) Spinner { + var spinner = self; + spinner.style = spinner.style.fg(color); + return spinner; + } + + /// Sets the label shown next to the spinner + pub fn setLabel(self: Spinner, label: []const u8) Spinner { + var spinner = self; + spinner.label = label; + return spinner; + } + + /// Sets the animation speed (ticks per frame, higher = slower) + pub fn setSpeed(self: Spinner, ticks: u64) Spinner { + var spinner = self; + spinner.ticks_per_frame = if (ticks == 0) 1 else ticks; + return spinner; + } + + /// Advances the spinner animation by one tick + pub fn tick(self: *Spinner) void { + self.tick_count += 1; + if (self.tick_count >= self.ticks_per_frame) { + self.tick_count = 0; + const frames = getFrames(self.spinner_style); + self.frame = (self.frame + 1) % frames.len; + } + } + + /// Resets the spinner to the first frame + pub fn reset(self: *Spinner) void { + self.frame = 0; + self.tick_count = 0; + } + + /// Gets the current frame string + pub fn currentFrame(self: *const Spinner) []const u8 { + const frames = getFrames(self.spinner_style); + return frames[self.frame % frames.len]; + } + + /// Renders the spinner to the buffer + pub fn render(self: *const Spinner, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + const frame_str = self.currentFrame(); + var x = buf.setString(area.x, area.y, frame_str, self.style); + + // Render label if present + if (self.label) |label| { + if (x < area.right()) { + x = buf.setString(x + 1, area.y, label, self.style); + } + } + } + + /// Returns the recommended interval in milliseconds for this spinner style + pub fn recommendedInterval(self: *const Spinner) u32 { + return switch (self.spinner_style) { + .dots, .dots_braille => 80, + .line => 130, + .arrows => 100, + .box_corners, .circle => 120, + .blocks => 100, + .bounce => 120, + .ascii => 100, + .clock => 100, + .moon => 80, + .hamburger => 100, + .growing_dots => 200, + .toggle => 250, + .square_corners => 180, + .star => 70, + .flip => 70, + .pipe => 100, + }; + } +}; + +/// Returns the frames for a given spinner style +fn getFrames(spinner_style: SpinnerStyle) []const []const u8 { + return switch (spinner_style) { + .dots => &dots_frames, + .dots_braille => &dots_braille_frames, + .line => &line_frames, + .arrows => &arrows_frames, + .box_corners => &box_corners_frames, + .circle => &circle_frames, + .blocks => &blocks_frames, + .bounce => &bounce_frames, + .ascii => &ascii_frames, + .clock => &clock_frames, + .moon => &moon_frames, + .hamburger => &hamburger_frames, + .growing_dots => &growing_dots_frames, + .toggle => &toggle_frames, + .square_corners => &square_corners_frames, + .star => &star_frames, + .flip => &flip_frames, + .pipe => &pipe_frames, + }; +} + +// Frame definitions +const dots_frames = [_][]const u8{ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; +const dots_braille_frames = [_][]const u8{ "⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷" }; +const line_frames = [_][]const u8{ "-", "\\", "|", "/" }; +const arrows_frames = [_][]const u8{ "←", "↖", "↑", "↗", "→", "↘", "↓", "↙" }; +const box_corners_frames = [_][]const u8{ "◰", "◳", "◲", "◱" }; +const circle_frames = [_][]const u8{ "◴", "◷", "◶", "◵" }; +const blocks_frames = [_][]const u8{ "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█", "▉", "▊", "▋", "▌", "▍", "▎", "▏" }; +const bounce_frames = [_][]const u8{ "[= ]", "[ = ]", "[ = ]", "[ =]", "[ = ]", "[ = ]" }; +const ascii_frames = [_][]const u8{ ".", "o", "O", "@", "*" }; +const clock_frames = [_][]const u8{ "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛" }; +const moon_frames = [_][]const u8{ "🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘" }; +const hamburger_frames = [_][]const u8{ "☱", "☲", "☴" }; +const growing_dots_frames = [_][]const u8{ ". ", ".. ", "...", " ..", " .", " " }; +const toggle_frames = [_][]const u8{ "⊶", "⊷" }; +const square_corners_frames = [_][]const u8{ "◢", "◣", "◤", "◥" }; +const star_frames = [_][]const u8{ "✶", "✸", "✹", "✺", "✹", "✷" }; +const flip_frames = [_][]const u8{ "_", "_", "_", "-", "`", "`", "'", "´", "-", "_", "_", "_" }; +const pipe_frames = [_][]const u8{ "┤", "┘", "┴", "└", "├", "┌", "┬", "┐" }; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Spinner creation" { + const spinner = Spinner.init(.dots); + try std.testing.expectEqual(SpinnerStyle.dots, spinner.spinner_style); + try std.testing.expectEqual(@as(usize, 0), spinner.frame); +} + +test "Spinner tick advances frame" { + var spinner = Spinner.init(.line); + try std.testing.expectEqualStrings("-", spinner.currentFrame()); + + spinner.tick(); + try std.testing.expectEqualStrings("\\", spinner.currentFrame()); + + spinner.tick(); + try std.testing.expectEqualStrings("|", spinner.currentFrame()); + + spinner.tick(); + try std.testing.expectEqualStrings("/", spinner.currentFrame()); + + // Wraps around + spinner.tick(); + try std.testing.expectEqualStrings("-", spinner.currentFrame()); +} + +test "Spinner with label" { + const spinner = Spinner.init(.dots).setLabel("Loading..."); + try std.testing.expectEqualStrings("Loading...", spinner.label.?); +} + +test "Spinner speed control" { + var spinner = Spinner.init(.dots).setSpeed(2); + + // First tick doesn't advance frame + spinner.tick(); + try std.testing.expectEqual(@as(usize, 0), spinner.frame); + + // Second tick advances frame + spinner.tick(); + try std.testing.expectEqual(@as(usize, 1), spinner.frame); +} + +test "Spinner reset" { + var spinner = Spinner.init(.dots); + spinner.tick(); + spinner.tick(); + try std.testing.expect(spinner.frame > 0); + + spinner.reset(); + try std.testing.expectEqual(@as(usize, 0), spinner.frame); +} + +test "All spinner styles have frames" { + inline for (std.meta.fields(SpinnerStyle)) |field| { + const style = @as(SpinnerStyle, @enumFromInt(field.value)); + const frames = getFrames(style); + try std.testing.expect(frames.len > 0); + } +} + +test "Spinner recommended interval" { + const spinner = Spinner.init(.dots); + try std.testing.expect(spinner.recommendedInterval() > 0); +} diff --git a/src/widgets/syntax.zig b/src/widgets/syntax.zig new file mode 100644 index 0000000..6166e2e --- /dev/null +++ b/src/widgets/syntax.zig @@ -0,0 +1,967 @@ +//! Syntax highlighting for code display. +//! +//! Provides syntax highlighting for various programming languages. +//! Uses simple regex-like patterns for tokenization. +//! +//! ## Example +//! +//! ```zig +//! const highlighter = SyntaxHighlighter.init(.zig); +//! highlighter.renderLine("const x = 42;", 0, area, buf); +//! ``` + +const std = @import("std"); +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; + +/// Supported languages +pub const Language = enum { + plain, + zig, + rust, + python, + javascript, + typescript, + c, + cpp, + go, + bash, + json, + yaml, + toml, + markdown, + sql, + html, + css, + + /// Detects language from file extension + pub fn fromExtension(ext: []const u8) Language { + const ext_lower = blk: { + var buf: [16]u8 = undefined; + const len = @min(ext.len, 16); + for (ext[0..len], 0..) |c, i| { + buf[i] = std.ascii.toLower(c); + } + break :blk buf[0..len]; + }; + + if (std.mem.eql(u8, ext_lower, "zig")) return .zig; + if (std.mem.eql(u8, ext_lower, "rs")) return .rust; + if (std.mem.eql(u8, ext_lower, "py")) return .python; + if (std.mem.eql(u8, ext_lower, "js")) return .javascript; + if (std.mem.eql(u8, ext_lower, "ts")) return .typescript; + if (std.mem.eql(u8, ext_lower, "c") or std.mem.eql(u8, ext_lower, "h")) return .c; + if (std.mem.eql(u8, ext_lower, "cpp") or std.mem.eql(u8, ext_lower, "hpp") or + std.mem.eql(u8, ext_lower, "cc") or std.mem.eql(u8, ext_lower, "cxx")) + return .cpp; + if (std.mem.eql(u8, ext_lower, "go")) return .go; + if (std.mem.eql(u8, ext_lower, "sh") or std.mem.eql(u8, ext_lower, "bash")) return .bash; + if (std.mem.eql(u8, ext_lower, "json")) return .json; + if (std.mem.eql(u8, ext_lower, "yaml") or std.mem.eql(u8, ext_lower, "yml")) return .yaml; + if (std.mem.eql(u8, ext_lower, "toml")) return .toml; + if (std.mem.eql(u8, ext_lower, "md")) return .markdown; + if (std.mem.eql(u8, ext_lower, "sql")) return .sql; + if (std.mem.eql(u8, ext_lower, "html") or std.mem.eql(u8, ext_lower, "htm")) return .html; + if (std.mem.eql(u8, ext_lower, "css")) return .css; + + return .plain; + } + + /// Detects language from filename + pub fn fromFilename(filename: []const u8) Language { + // Special filenames + if (std.mem.eql(u8, filename, "Makefile") or + std.mem.eql(u8, filename, "makefile") or + std.mem.eql(u8, filename, "GNUmakefile")) + return .bash; + + if (std.mem.eql(u8, filename, "Dockerfile")) return .bash; + if (std.mem.eql(u8, filename, ".gitignore")) return .bash; + + // By extension + if (std.mem.lastIndexOfScalar(u8, filename, '.')) |idx| { + if (idx + 1 < filename.len) { + return fromExtension(filename[idx + 1 ..]); + } + } + + return .plain; + } +}; + +/// Token types for highlighting +pub const TokenType = enum { + text, + keyword, + keyword2, // Secondary keywords (types, etc.) + string, + char, + number, + comment, + operator, + punctuation, + function, + type_name, + constant, + variable, + attribute, + preprocessor, + error_token, +}; + +/// Syntax highlighting theme +pub const SyntaxTheme = struct { + text: Style = Style.default, + keyword: Style = Style.default.fg(Color.magenta).add_modifier(.{ .bold = true }), + keyword2: Style = Style.default.fg(Color.blue), + string: Style = Style.default.fg(Color.green), + char: Style = Style.default.fg(Color.green), + number: Style = Style.default.fg(Color.yellow), + comment: Style = Style.default.fg(Color.indexed(245)).add_modifier(.{ .italic = true }), + operator: Style = Style.default.fg(Color.cyan), + punctuation: Style = Style.default.fg(Color.indexed(250)), + function: Style = Style.default.fg(Color.blue), + type_name: Style = Style.default.fg(Color.yellow), + constant: Style = Style.default.fg(Color.red), + variable: Style = Style.default.fg(Color.white), + attribute: Style = Style.default.fg(Color.cyan), + preprocessor: Style = Style.default.fg(Color.magenta), + error_token: Style = Style.default.fg(Color.red).add_modifier(.{ .underlined = true }), + // Line numbers + line_number: Style = Style.default.fg(Color.indexed(240)), + line_number_active: Style = Style.default.fg(Color.yellow), + + pub const default: SyntaxTheme = .{}; + + pub const monokai: SyntaxTheme = .{ + .keyword = Style.default.fg(Color.rgb(249, 38, 114)), + .keyword2 = Style.default.fg(Color.rgb(102, 217, 239)), + .string = Style.default.fg(Color.rgb(230, 219, 116)), + .number = Style.default.fg(Color.rgb(174, 129, 255)), + .comment = Style.default.fg(Color.rgb(117, 113, 94)), + .function = Style.default.fg(Color.rgb(166, 226, 46)), + .type_name = Style.default.fg(Color.rgb(102, 217, 239)), + }; + + pub const dracula: SyntaxTheme = .{ + .keyword = Style.default.fg(Color.rgb(255, 121, 198)), + .keyword2 = Style.default.fg(Color.rgb(139, 233, 253)), + .string = Style.default.fg(Color.rgb(241, 250, 140)), + .number = Style.default.fg(Color.rgb(189, 147, 249)), + .comment = Style.default.fg(Color.rgb(98, 114, 164)), + .function = Style.default.fg(Color.rgb(80, 250, 123)), + .type_name = Style.default.fg(Color.rgb(139, 233, 253)), + }; + + pub fn styleFor(self: SyntaxTheme, token_type: TokenType) Style { + return switch (token_type) { + .text => self.text, + .keyword => self.keyword, + .keyword2 => self.keyword2, + .string => self.string, + .char => self.char, + .number => self.number, + .comment => self.comment, + .operator => self.operator, + .punctuation => self.punctuation, + .function => self.function, + .type_name => self.type_name, + .constant => self.constant, + .variable => self.variable, + .attribute => self.attribute, + .preprocessor => self.preprocessor, + .error_token => self.error_token, + }; + } +}; + +/// A token in the source code +pub const Token = struct { + start: usize, + end: usize, + token_type: TokenType, +}; + +/// Syntax highlighter +pub const SyntaxHighlighter = struct { + language: Language, + theme: SyntaxTheme = SyntaxTheme.default, + show_line_numbers: bool = false, + line_number_width: u16 = 4, + tab_width: u16 = 4, + + /// Creates a new highlighter for the given language + pub fn init(language: Language) SyntaxHighlighter { + return .{ .language = language }; + } + + /// Sets the theme + pub fn setTheme(self: SyntaxHighlighter, t: SyntaxTheme) SyntaxHighlighter { + var h = self; + h.theme = t; + return h; + } + + /// Enables line numbers + pub fn setLineNumbers(self: SyntaxHighlighter, show: bool) SyntaxHighlighter { + var h = self; + h.show_line_numbers = show; + return h; + } + + /// Sets tab width + pub fn setTabWidth(self: SyntaxHighlighter, width: u16) SyntaxHighlighter { + var h = self; + h.tab_width = width; + return h; + } + + /// Tokenizes a line of code + pub fn tokenize(self: *const SyntaxHighlighter, line: []const u8) TokenList { + var tokens = TokenList{}; + + switch (self.language) { + .zig => self.tokenizeZig(line, &tokens), + .rust => self.tokenizeRust(line, &tokens), + .python => self.tokenizePython(line, &tokens), + .javascript, .typescript => self.tokenizeJS(line, &tokens), + .c, .cpp => self.tokenizeC(line, &tokens), + .go => self.tokenizeGo(line, &tokens), + .json => self.tokenizeJSON(line, &tokens), + .bash => self.tokenizeBash(line, &tokens), + else => { + // Plain text + if (line.len > 0) { + tokens.add(.{ .start = 0, .end = line.len, .token_type = .text }); + } + }, + } + + return tokens; + } + + /// Renders a highlighted line + pub fn renderLine( + self: *const SyntaxHighlighter, + line: []const u8, + line_num: usize, + area: Rect, + buf: *Buffer, + ) void { + if (area.isEmpty()) return; + + var x = area.x; + + // Line number + if (self.show_line_numbers) { + var num_buf: [16]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d: >4} ", .{line_num + 1}) catch "???? "; + x = buf.setString(x, area.y, num_str, self.theme.line_number); + } + + // Tokenize and render + const tokens = self.tokenize(line); + var last_end: usize = 0; + + for (tokens.items[0..tokens.count]) |token| { + // Fill gap with plain text + if (token.start > last_end) { + x = buf.setString(x, area.y, line[last_end..token.start], self.theme.text); + } + + // Render token + const style = self.theme.styleFor(token.token_type); + x = buf.setString(x, area.y, line[token.start..token.end], style); + last_end = token.end; + + if (x >= area.right()) break; + } + + // Trailing text + if (last_end < line.len and x < area.right()) { + _ = buf.setString(x, area.y, line[last_end..], self.theme.text); + } + } + + /// Renders multiple lines with highlighting + pub fn render( + self: *const SyntaxHighlighter, + source: []const u8, + scroll: usize, + area: Rect, + buf: *Buffer, + ) void { + var line_iter = std.mem.splitScalar(u8, source, '\n'); + var line_num: usize = 0; + var y: u16 = 0; + + while (line_iter.next()) |line| { + if (line_num < scroll) { + line_num += 1; + continue; + } + + if (y >= area.height) break; + + self.renderLine( + line, + line_num, + Rect.init(area.x, area.y + y, area.width, 1), + buf, + ); + + line_num += 1; + y += 1; + } + } + + // Language-specific tokenizers + fn tokenizeZig(self: *const SyntaxHighlighter, line: []const u8, tokens: *TokenList) void { + _ = self; + const zig_keywords = [_][]const u8{ + "const", "var", "fn", "pub", "return", "if", + "else", "while", "for", "break", "continue", "switch", + "defer", "errdefer", "try", "catch", "error", "unreachable", + "undefined", "null", "true", "false", "and", "or", + "orelse", "comptime", "inline", "extern", "export", "align", + "struct", "enum", "union", "packed", "test", "import", + "async", "await", "suspend", "resume", "nosuspend", + }; + + const zig_types = [_][]const u8{ + "void", "bool", "u8", "u16", "u32", "u64", "u128", "usize", + "i8", "i16", "i32", "i64", "i128", "isize", "f16", "f32", + "f64", "f128", "anytype", "type", "anyframe", "noreturn", + "anyerror", "anyopaque", + }; + + var i: usize = 0; + while (i < line.len) { + const c = line[i]; + + // Comments + if (i + 1 < line.len and line[i] == '/' and line[i + 1] == '/') { + tokens.add(.{ .start = i, .end = line.len, .token_type = .comment }); + return; + } + + // Strings + if (c == '"') { + const end = findStringEnd(line, i + 1, '"'); + tokens.add(.{ .start = i, .end = end, .token_type = .string }); + i = end; + continue; + } + + // Characters + if (c == '\'') { + const end = findStringEnd(line, i + 1, '\''); + tokens.add(.{ .start = i, .end = end, .token_type = .char }); + i = end; + continue; + } + + // Numbers + if (std.ascii.isDigit(c) or (c == '.' and i + 1 < line.len and std.ascii.isDigit(line[i + 1]))) { + const end = findNumberEnd(line, i); + tokens.add(.{ .start = i, .end = end, .token_type = .number }); + i = end; + continue; + } + + // Identifiers and keywords + if (std.ascii.isAlphabetic(c) or c == '_' or c == '@') { + const end = findIdentEnd(line, i); + const ident = line[i..end]; + + var token_type: TokenType = .text; + + // Check keywords + for (zig_keywords) |kw| { + if (std.mem.eql(u8, ident, kw)) { + token_type = .keyword; + break; + } + } + + // Check types + if (token_type == .text) { + for (zig_types) |t| { + if (std.mem.eql(u8, ident, t)) { + token_type = .type_name; + break; + } + } + } + + // Builtins starting with @ + if (token_type == .text and ident.len > 0 and ident[0] == '@') { + token_type = .function; + } + + tokens.add(.{ .start = i, .end = end, .token_type = token_type }); + i = end; + continue; + } + + // Operators + if (isOperator(c)) { + tokens.add(.{ .start = i, .end = i + 1, .token_type = .operator }); + } else if (isPunctuation(c)) { + tokens.add(.{ .start = i, .end = i + 1, .token_type = .punctuation }); + } + + i += 1; + } + } + + fn tokenizeRust(self: *const SyntaxHighlighter, line: []const u8, tokens: *TokenList) void { + _ = self; + const rust_keywords = [_][]const u8{ + "fn", "let", "mut", "const", "static", "pub", "use", + "mod", "crate", "self", "super", "if", "else", "match", + "loop", "while", "for", "in", "break", "continue", "return", + "struct", "enum", "impl", "trait", "type", "where", "as", + "unsafe", "async", "await", "move", "ref", "dyn", "true", + "false", + }; + + var i: usize = 0; + while (i < line.len) { + const c = line[i]; + + // Comments + if (i + 1 < line.len and line[i] == '/' and line[i + 1] == '/') { + tokens.add(.{ .start = i, .end = line.len, .token_type = .comment }); + return; + } + + // Strings + if (c == '"') { + const end = findStringEnd(line, i + 1, '"'); + tokens.add(.{ .start = i, .end = end, .token_type = .string }); + i = end; + continue; + } + + // Numbers + if (std.ascii.isDigit(c)) { + const end = findNumberEnd(line, i); + tokens.add(.{ .start = i, .end = end, .token_type = .number }); + i = end; + continue; + } + + // Identifiers + if (std.ascii.isAlphabetic(c) or c == '_') { + const end = findIdentEnd(line, i); + const ident = line[i..end]; + + var token_type: TokenType = .text; + for (rust_keywords) |kw| { + if (std.mem.eql(u8, ident, kw)) { + token_type = .keyword; + break; + } + } + + // Macros (end with !) + if (token_type == .text and end < line.len and line[end] == '!') { + token_type = .function; + } + + tokens.add(.{ .start = i, .end = end, .token_type = token_type }); + i = end; + continue; + } + + i += 1; + } + } + + fn tokenizePython(self: *const SyntaxHighlighter, line: []const u8, tokens: *TokenList) void { + _ = self; + const py_keywords = [_][]const u8{ + "def", "class", "if", "elif", "else", "for", + "while", "try", "except", "finally", "with", "as", + "import", "from", "return", "yield", "raise", "pass", + "break", "continue", "lambda", "and", "or", "not", + "in", "is", "True", "False", "None", "async", + "await", "global", "nonlocal", + }; + + var i: usize = 0; + while (i < line.len) { + const c = line[i]; + + // Comments + if (c == '#') { + tokens.add(.{ .start = i, .end = line.len, .token_type = .comment }); + return; + } + + // Strings (single, double, triple) + if (c == '"' or c == '\'') { + const end = findStringEnd(line, i + 1, c); + tokens.add(.{ .start = i, .end = end, .token_type = .string }); + i = end; + continue; + } + + // Numbers + if (std.ascii.isDigit(c)) { + const end = findNumberEnd(line, i); + tokens.add(.{ .start = i, .end = end, .token_type = .number }); + i = end; + continue; + } + + // Identifiers + if (std.ascii.isAlphabetic(c) or c == '_') { + const end = findIdentEnd(line, i); + const ident = line[i..end]; + + var token_type: TokenType = .text; + for (py_keywords) |kw| { + if (std.mem.eql(u8, ident, kw)) { + token_type = .keyword; + break; + } + } + + // Decorators + if (i > 0 and line[i - 1] == '@') { + token_type = .attribute; + } + + tokens.add(.{ .start = i, .end = end, .token_type = token_type }); + i = end; + continue; + } + + // Decorator + if (c == '@') { + tokens.add(.{ .start = i, .end = i + 1, .token_type = .attribute }); + } + + i += 1; + } + } + + fn tokenizeJS(self: *const SyntaxHighlighter, line: []const u8, tokens: *TokenList) void { + _ = self; + const js_keywords = [_][]const u8{ + "function", "const", "let", "var", "if", "else", + "for", "while", "do", "switch", "case", "break", + "continue", "return", "try", "catch", "finally", "throw", + "new", "class", "extends", "super", "this", "import", + "export", "default", "from", "as", "async", "await", + "true", "false", "null", "undefined", "typeof", "instanceof", + }; + + var i: usize = 0; + while (i < line.len) { + const c = line[i]; + + // Comments + if (i + 1 < line.len and line[i] == '/' and line[i + 1] == '/') { + tokens.add(.{ .start = i, .end = line.len, .token_type = .comment }); + return; + } + + // Strings + if (c == '"' or c == '\'' or c == '`') { + const end = findStringEnd(line, i + 1, c); + tokens.add(.{ .start = i, .end = end, .token_type = .string }); + i = end; + continue; + } + + // Numbers + if (std.ascii.isDigit(c)) { + const end = findNumberEnd(line, i); + tokens.add(.{ .start = i, .end = end, .token_type = .number }); + i = end; + continue; + } + + // Identifiers + if (std.ascii.isAlphabetic(c) or c == '_' or c == '$') { + const end = findIdentEnd(line, i); + const ident = line[i..end]; + + var token_type: TokenType = .text; + for (js_keywords) |kw| { + if (std.mem.eql(u8, ident, kw)) { + token_type = .keyword; + break; + } + } + + tokens.add(.{ .start = i, .end = end, .token_type = token_type }); + i = end; + continue; + } + + i += 1; + } + } + + fn tokenizeC(self: *const SyntaxHighlighter, line: []const u8, tokens: *TokenList) void { + _ = self; + const c_keywords = [_][]const u8{ + "auto", "break", "case", "char", "const", "continue", + "default", "do", "double", "else", "enum", "extern", + "float", "for", "goto", "if", "int", "long", + "register", "return", "short", "signed", "sizeof", "static", + "struct", "switch", "typedef", "union", "unsigned", "void", + "volatile", "while", "inline", "restrict", "_Bool", "_Complex", + "_Imaginary", + }; + + var i: usize = 0; + while (i < line.len) { + const c = line[i]; + + // Comments + if (i + 1 < line.len and line[i] == '/' and line[i + 1] == '/') { + tokens.add(.{ .start = i, .end = line.len, .token_type = .comment }); + return; + } + + // Preprocessor + if (c == '#') { + tokens.add(.{ .start = i, .end = line.len, .token_type = .preprocessor }); + return; + } + + // Strings + if (c == '"') { + const end = findStringEnd(line, i + 1, '"'); + tokens.add(.{ .start = i, .end = end, .token_type = .string }); + i = end; + continue; + } + + // Characters + if (c == '\'') { + const end = findStringEnd(line, i + 1, '\''); + tokens.add(.{ .start = i, .end = end, .token_type = .char }); + i = end; + continue; + } + + // Numbers + if (std.ascii.isDigit(c)) { + const end = findNumberEnd(line, i); + tokens.add(.{ .start = i, .end = end, .token_type = .number }); + i = end; + continue; + } + + // Identifiers + if (std.ascii.isAlphabetic(c) or c == '_') { + const end = findIdentEnd(line, i); + const ident = line[i..end]; + + var token_type: TokenType = .text; + for (c_keywords) |kw| { + if (std.mem.eql(u8, ident, kw)) { + token_type = .keyword; + break; + } + } + + tokens.add(.{ .start = i, .end = end, .token_type = token_type }); + i = end; + continue; + } + + i += 1; + } + } + + fn tokenizeGo(self: *const SyntaxHighlighter, line: []const u8, tokens: *TokenList) void { + _ = self; + const go_keywords = [_][]const u8{ + "break", "case", "chan", "const", "continue", "default", + "defer", "else", "fallthrough", "for", "func", "go", + "goto", "if", "import", "interface", "map", "package", + "range", "return", "select", "struct", "switch", "type", + "var", "true", "false", "nil", "iota", + }; + + var i: usize = 0; + while (i < line.len) { + const c = line[i]; + + if (i + 1 < line.len and line[i] == '/' and line[i + 1] == '/') { + tokens.add(.{ .start = i, .end = line.len, .token_type = .comment }); + return; + } + + if (c == '"' or c == '`') { + const end = findStringEnd(line, i + 1, c); + tokens.add(.{ .start = i, .end = end, .token_type = .string }); + i = end; + continue; + } + + if (std.ascii.isDigit(c)) { + const end = findNumberEnd(line, i); + tokens.add(.{ .start = i, .end = end, .token_type = .number }); + i = end; + continue; + } + + if (std.ascii.isAlphabetic(c) or c == '_') { + const end = findIdentEnd(line, i); + const ident = line[i..end]; + + var token_type: TokenType = .text; + for (go_keywords) |kw| { + if (std.mem.eql(u8, ident, kw)) { + token_type = .keyword; + break; + } + } + + tokens.add(.{ .start = i, .end = end, .token_type = token_type }); + i = end; + continue; + } + + i += 1; + } + } + + fn tokenizeJSON(self: *const SyntaxHighlighter, line: []const u8, tokens: *TokenList) void { + _ = self; + var i: usize = 0; + while (i < line.len) { + const c = line[i]; + + // Strings (keys and values) + if (c == '"') { + const end = findStringEnd(line, i + 1, '"'); + // Check if it's a key (followed by :) + var j = end; + while (j < line.len and (line[j] == ' ' or line[j] == '\t')) : (j += 1) {} + const token_type: TokenType = if (j < line.len and line[j] == ':') .keyword else .string; + tokens.add(.{ .start = i, .end = end, .token_type = token_type }); + i = end; + continue; + } + + // Numbers + if (std.ascii.isDigit(c) or c == '-') { + const end = findNumberEnd(line, i); + tokens.add(.{ .start = i, .end = end, .token_type = .number }); + i = end; + continue; + } + + // Booleans and null + if (std.ascii.isAlphabetic(c)) { + const end = findIdentEnd(line, i); + const ident = line[i..end]; + if (std.mem.eql(u8, ident, "true") or std.mem.eql(u8, ident, "false") or + std.mem.eql(u8, ident, "null")) + { + tokens.add(.{ .start = i, .end = end, .token_type = .constant }); + } + i = end; + continue; + } + + i += 1; + } + } + + fn tokenizeBash(self: *const SyntaxHighlighter, line: []const u8, tokens: *TokenList) void { + _ = self; + const bash_keywords = [_][]const u8{ + "if", "then", "else", "elif", "fi", "for", + "while", "do", "done", "case", "esac", "in", + "function", "return", "exit", "export", "local", "source", + "echo", "read", "cd", "pwd", "ls", "rm", + }; + + var i: usize = 0; + while (i < line.len) { + const c = line[i]; + + // Comments + if (c == '#') { + tokens.add(.{ .start = i, .end = line.len, .token_type = .comment }); + return; + } + + // Strings + if (c == '"' or c == '\'') { + const end = findStringEnd(line, i + 1, c); + tokens.add(.{ .start = i, .end = end, .token_type = .string }); + i = end; + continue; + } + + // Variables + if (c == '$') { + const end = if (i + 1 < line.len and line[i + 1] == '{') + std.mem.indexOfScalarPos(u8, line, i + 2, '}') orelse line.len + else + findIdentEnd(line, i + 1); + tokens.add(.{ .start = i, .end = end, .token_type = .variable }); + i = end; + continue; + } + + // Identifiers + if (std.ascii.isAlphabetic(c) or c == '_') { + const end = findIdentEnd(line, i); + const ident = line[i..end]; + + var token_type: TokenType = .text; + for (bash_keywords) |kw| { + if (std.mem.eql(u8, ident, kw)) { + token_type = .keyword; + break; + } + } + + tokens.add(.{ .start = i, .end = end, .token_type = token_type }); + i = end; + continue; + } + + i += 1; + } + } +}; + +/// Fixed-size token list (no allocation) +pub const TokenList = struct { + items: [64]Token = undefined, + count: usize = 0, + + pub fn add(self: *TokenList, token: Token) void { + if (self.count < 64) { + self.items[self.count] = token; + self.count += 1; + } + } +}; + +// Helper functions +fn findStringEnd(line: []const u8, start: usize, delimiter: u8) usize { + var i = start; + while (i < line.len) { + if (line[i] == '\\' and i + 1 < line.len) { + i += 2; // Skip escaped char + continue; + } + if (line[i] == delimiter) { + return i + 1; + } + i += 1; + } + return line.len; +} + +fn findNumberEnd(line: []const u8, start: usize) usize { + var i = start; + // Handle hex, binary, octal prefixes + if (i + 1 < line.len and line[i] == '0') { + if (line[i + 1] == 'x' or line[i + 1] == 'X' or + line[i + 1] == 'b' or line[i + 1] == 'B' or + line[i + 1] == 'o' or line[i + 1] == 'O') + { + i += 2; + } + } + + while (i < line.len) { + const c = line[i]; + if (std.ascii.isAlphanumeric(c) or c == '.' or c == '_') { + i += 1; + } else { + break; + } + } + return i; +} + +fn findIdentEnd(line: []const u8, start: usize) usize { + var i = start; + while (i < line.len) { + const c = line[i]; + if (std.ascii.isAlphanumeric(c) or c == '_' or c == '@' or c == '$') { + i += 1; + } else { + break; + } + } + return i; +} + +fn isOperator(c: u8) bool { + return switch (c) { + '+', '-', '*', '/', '%', '=', '<', '>', '!', '&', '|', '^', '~' => true, + else => false, + }; +} + +fn isPunctuation(c: u8) bool { + return switch (c) { + '(', ')', '[', ']', '{', '}', ',', '.', ';', ':' => true, + else => false, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "Language detection from extension" { + try std.testing.expectEqual(Language.zig, Language.fromExtension("zig")); + try std.testing.expectEqual(Language.rust, Language.fromExtension("rs")); + try std.testing.expectEqual(Language.python, Language.fromExtension("py")); + try std.testing.expectEqual(Language.javascript, Language.fromExtension("js")); + try std.testing.expectEqual(Language.plain, Language.fromExtension("xyz")); +} + +test "Language detection from filename" { + try std.testing.expectEqual(Language.zig, Language.fromFilename("main.zig")); + try std.testing.expectEqual(Language.bash, Language.fromFilename("Makefile")); + try std.testing.expectEqual(Language.bash, Language.fromFilename("Dockerfile")); +} + +test "Tokenize Zig line" { + const highlighter = SyntaxHighlighter.init(.zig); + const tokens = highlighter.tokenize("const x = 42;"); + + try std.testing.expect(tokens.count > 0); +} + +test "Tokenize with comment" { + const highlighter = SyntaxHighlighter.init(.zig); + const tokens = highlighter.tokenize("// comment"); + + try std.testing.expectEqual(@as(usize, 1), tokens.count); + try std.testing.expectEqual(TokenType.comment, tokens.items[0].token_type); +} + +test "findStringEnd basic" { + try std.testing.expectEqual(@as(usize, 6), findStringEnd("hello\"", 0, '"')); + try std.testing.expectEqual(@as(usize, 8), findStringEnd("test\\\"x\"", 0, '"')); +} + +test "findNumberEnd" { + try std.testing.expectEqual(@as(usize, 2), findNumberEnd("42", 0)); + try std.testing.expectEqual(@as(usize, 4), findNumberEnd("0x1F", 0)); + try std.testing.expectEqual(@as(usize, 4), findNumberEnd("3.14", 0)); +} diff --git a/src/widgets/viewport.zig b/src/widgets/viewport.zig new file mode 100644 index 0000000..88c4e28 --- /dev/null +++ b/src/widgets/viewport.zig @@ -0,0 +1,527 @@ +//! Viewport widget for scrollable content. +//! +//! A viewport renders content that may be larger than the visible area, +//! allowing vertical and horizontal scrolling. Any content can be rendered +//! to an internal buffer and then displayed through the viewport. +//! +//! ## Example +//! +//! ```zig +//! var viewport = Viewport.init(allocator, 100, 50); // content size +//! defer viewport.deinit(); +//! +//! // Render content to viewport's internal buffer +//! const content_buf = viewport.buffer(); +//! my_widget.render(content_buf.area, content_buf); +//! +//! // Display viewport with scrolling +//! viewport.render(visible_area, screen_buf); +//! +//! // Handle scrolling +//! viewport.scrollDown(5); +//! ``` + +const std = @import("std"); +const buffer_mod = @import("../buffer.zig"); +const Buffer = buffer_mod.Buffer; +const Rect = buffer_mod.Rect; +const Cell = buffer_mod.Cell; +const style_mod = @import("../style.zig"); +const Style = style_mod.Style; +const Color = style_mod.Color; +const scrollbar_mod = @import("scrollbar.zig"); +const Scrollbar = scrollbar_mod.Scrollbar; +const ScrollbarState = scrollbar_mod.ScrollbarState; +const ScrollbarOrientation = scrollbar_mod.ScrollbarOrientation; + +/// Viewport state for tracking scroll position +pub const ViewportState = struct { + /// Current vertical scroll offset + offset_y: u16 = 0, + /// Current horizontal scroll offset + offset_x: u16 = 0, + /// Content width + content_width: u16 = 0, + /// Content height + content_height: u16 = 0, + + /// Scrolls down by the given amount + pub fn scrollDown(self: *ViewportState, amount: u16) void { + self.offset_y = @min(self.offset_y +| amount, self.maxScrollY()); + } + + /// Scrolls up by the given amount + pub fn scrollUp(self: *ViewportState, amount: u16) void { + self.offset_y -|= amount; + } + + /// Scrolls right by the given amount + pub fn scrollRight(self: *ViewportState, amount: u16) void { + self.offset_x = @min(self.offset_x +| amount, self.maxScrollX()); + } + + /// Scrolls left by the given amount + pub fn scrollLeft(self: *ViewportState, amount: u16) void { + self.offset_x -|= amount; + } + + /// Scrolls to the top + pub fn scrollToTop(self: *ViewportState) void { + self.offset_y = 0; + } + + /// Scrolls to the bottom + pub fn scrollToBottom(self: *ViewportState, view_height: u16) void { + self.offset_y = self.content_height -| view_height; + } + + /// Scrolls to a specific line (0-indexed) + pub fn scrollToLine(self: *ViewportState, line: u16, view_height: u16) void { + // Center the line in the viewport if possible + const half_height = view_height / 2; + if (line < half_height) { + self.offset_y = 0; + } else { + self.offset_y = @min(line - half_height, self.content_height -| view_height); + } + } + + /// Ensures a line is visible (scrolls minimally to show it) + pub fn ensureVisible(self: *ViewportState, line: u16, view_height: u16) void { + if (line < self.offset_y) { + self.offset_y = line; + } else if (line >= self.offset_y + view_height) { + self.offset_y = line -| view_height +| 1; + } + } + + /// Page down + pub fn pageDown(self: *ViewportState, view_height: u16) void { + self.scrollDown(view_height -| 1); + } + + /// Page up + pub fn pageUp(self: *ViewportState, view_height: u16) void { + self.scrollUp(view_height -| 1); + } + + /// Half page down + pub fn halfPageDown(self: *ViewportState, view_height: u16) void { + self.scrollDown(view_height / 2); + } + + /// Half page up + pub fn halfPageUp(self: *ViewportState, view_height: u16) void { + self.scrollUp(view_height / 2); + } + + /// Returns the maximum vertical scroll offset + fn maxScrollY(self: *const ViewportState) u16 { + // This will be clamped when we know the view height + return self.content_height; + } + + /// Returns the maximum horizontal scroll offset + fn maxScrollX(self: *const ViewportState) u16 { + return self.content_width; + } + + /// Returns vertical scroll percentage (0.0 to 1.0) + pub fn scrollPercentY(self: *const ViewportState, view_height: u16) f32 { + const max = self.content_height -| view_height; + if (max == 0) return 0; + return @as(f32, @floatFromInt(self.offset_y)) / @as(f32, @floatFromInt(max)); + } + + /// Returns horizontal scroll percentage (0.0 to 1.0) + pub fn scrollPercentX(self: *const ViewportState, view_width: u16) f32 { + const max = self.content_width -| view_width; + if (max == 0) return 0; + return @as(f32, @floatFromInt(self.offset_x)) / @as(f32, @floatFromInt(max)); + } + + /// Returns true if can scroll down + pub fn canScrollDown(self: *const ViewportState, view_height: u16) bool { + return self.offset_y + view_height < self.content_height; + } + + /// Returns true if can scroll up + pub fn canScrollUp(self: *const ViewportState) bool { + return self.offset_y > 0; + } +}; + +/// Viewport widget for scrollable content +pub const Viewport = struct { + allocator: std.mem.Allocator, + /// Internal buffer for content + content_buffer: Buffer, + /// Viewport state + state: ViewportState, + /// Show vertical scrollbar + show_v_scrollbar: bool = true, + /// Show horizontal scrollbar + show_h_scrollbar: bool = false, + /// Scrollbar style + scrollbar_style: Style = Style.default, + /// Enable mouse wheel scrolling + mouse_scroll: bool = true, + /// Lines to scroll per mouse wheel event + scroll_lines: u16 = 3, + + /// Creates a new viewport with the specified content size + pub fn init(allocator: std.mem.Allocator, content_width: u16, content_height: u16) !Viewport { + const content_area = Rect.init(0, 0, content_width, content_height); + var content_buffer = try Buffer.init(allocator, content_area); + content_buffer.clear(); + + return .{ + .allocator = allocator, + .content_buffer = content_buffer, + .state = .{ + .content_width = content_width, + .content_height = content_height, + }, + }; + } + + /// Frees the viewport resources + pub fn deinit(self: *Viewport) void { + self.content_buffer.deinit(); + } + + /// Resizes the content area + pub fn resize(self: *Viewport, new_width: u16, new_height: u16) !void { + self.content_buffer.deinit(); + const content_area = Rect.init(0, 0, new_width, new_height); + self.content_buffer = try Buffer.init(self.allocator, content_area); + self.content_buffer.clear(); + self.state.content_width = new_width; + self.state.content_height = new_height; + } + + /// Returns the content buffer for rendering + pub fn buffer(self: *Viewport) *Buffer { + return &self.content_buffer; + } + + /// Returns the content area + pub fn contentArea(self: *const Viewport) Rect { + return self.content_buffer.area; + } + + /// Clears the content buffer + pub fn clear(self: *Viewport) void { + self.content_buffer.clear(); + } + + /// Sets vertical scrollbar visibility + pub fn setVScrollbar(self: Viewport, show: bool) Viewport { + var vp = self; + vp.show_v_scrollbar = show; + return vp; + } + + /// Sets horizontal scrollbar visibility + pub fn setHScrollbar(self: Viewport, show: bool) Viewport { + var vp = self; + vp.show_h_scrollbar = show; + return vp; + } + + /// Sets scroll lines per wheel event + pub fn setScrollLines(self: Viewport, lines: u16) Viewport { + var vp = self; + vp.scroll_lines = if (lines == 0) 1 else lines; + return vp; + } + + // Scroll delegation methods + pub fn scrollDown(self: *Viewport, amount: u16) void { + self.state.scrollDown(amount); + } + + pub fn scrollUp(self: *Viewport, amount: u16) void { + self.state.scrollUp(amount); + } + + pub fn scrollRight(self: *Viewport, amount: u16) void { + self.state.scrollRight(amount); + } + + pub fn scrollLeft(self: *Viewport, amount: u16) void { + self.state.scrollLeft(amount); + } + + pub fn scrollToTop(self: *Viewport) void { + self.state.scrollToTop(); + } + + pub fn scrollToBottom(self: *Viewport, view_height: u16) void { + self.state.scrollToBottom(view_height); + } + + pub fn pageDown(self: *Viewport, view_height: u16) void { + self.state.pageDown(view_height); + } + + pub fn pageUp(self: *Viewport, view_height: u16) void { + self.state.pageUp(view_height); + } + + /// Renders the viewport to the target buffer + pub fn render(self: *const Viewport, area: Rect, buf: *Buffer) void { + if (area.isEmpty()) return; + + // Calculate visible area (accounting for scrollbars) + const v_scrollbar_width: u16 = if (self.show_v_scrollbar and self.state.content_height > area.height) 1 else 0; + const h_scrollbar_height: u16 = if (self.show_h_scrollbar and self.state.content_width > area.width) 1 else 0; + + const view_width = area.width -| v_scrollbar_width; + const view_height = area.height -| h_scrollbar_height; + + // Copy visible portion of content buffer to target + const offset_y = self.state.offset_y; + const offset_x = self.state.offset_x; + + var y: u16 = 0; + while (y < view_height) : (y += 1) { + const src_y = offset_y + y; + if (src_y >= self.state.content_height) break; + + var x: u16 = 0; + while (x < view_width) : (x += 1) { + const src_x = offset_x + x; + if (src_x >= self.state.content_width) break; + + if (self.content_buffer.getPtr(src_x, src_y)) |src_cell| { + if (buf.getPtr(area.x + x, area.y + y)) |dst_cell| { + dst_cell.* = src_cell.*; + } + } + } + } + + // Render vertical scrollbar + if (v_scrollbar_width > 0) { + var scrollbar_state = ScrollbarState.default; + scrollbar_state.content_length = self.state.content_height; + scrollbar_state.viewport_content_length = view_height; + scrollbar_state.position = self.state.offset_y; + + const scrollbar = Scrollbar.init(.vertical_right) + .setStyle(self.scrollbar_style); + + const scrollbar_area = Rect.init( + area.right() - 1, + area.y, + 1, + view_height, + ); + scrollbar.render(scrollbar_area, buf, &scrollbar_state); + } + + // Render horizontal scrollbar + if (h_scrollbar_height > 0) { + var scrollbar_state = ScrollbarState.default; + scrollbar_state.content_length = self.state.content_width; + scrollbar_state.viewport_content_length = view_width; + scrollbar_state.position = self.state.offset_x; + + const scrollbar = Scrollbar.init(.horizontal_bottom) + .setStyle(self.scrollbar_style); + + const scrollbar_area = Rect.init( + area.x, + area.bottom() - 1, + view_width, + 1, + ); + scrollbar.render(scrollbar_area, buf, &scrollbar_state); + } + } +}; + +/// A simpler viewport that doesn't allocate - just wraps content +pub const StaticViewport = struct { + /// Current scroll position + offset_y: u16 = 0, + offset_x: u16 = 0, + /// Content dimensions (set by user) + content_width: u16 = 0, + content_height: u16 = 0, + /// Show scrollbars + show_v_scrollbar: bool = true, + show_h_scrollbar: bool = false, + + /// Sets content dimensions + pub fn setContentSize(self: StaticViewport, width: u16, height: u16) StaticViewport { + var vp = self; + vp.content_width = width; + vp.content_height = height; + return vp; + } + + /// Scroll down + pub fn scrollDown(self: *StaticViewport, amount: u16, view_height: u16) void { + const max = self.content_height -| view_height; + self.offset_y = @min(self.offset_y +| amount, max); + } + + /// Scroll up + pub fn scrollUp(self: *StaticViewport, amount: u16) void { + self.offset_y -|= amount; + } + + /// Page down + pub fn pageDown(self: *StaticViewport, view_height: u16) void { + self.scrollDown(view_height -| 1, view_height); + } + + /// Page up + pub fn pageUp(self: *StaticViewport, view_height: u16) void { + _ = view_height; + self.scrollUp(self.offset_y); + } + + /// Scroll to top + pub fn scrollToTop(self: *StaticViewport) void { + self.offset_y = 0; + } + + /// Scroll to bottom + pub fn scrollToBottom(self: *StaticViewport, view_height: u16) void { + self.offset_y = self.content_height -| view_height; + } + + /// Returns the visible area offset for rendering + pub fn getVisibleArea(self: *const StaticViewport, view_area: Rect) struct { offset_y: u16, offset_x: u16, width: u16, height: u16 } { + const v_scrollbar = if (self.show_v_scrollbar and self.content_height > view_area.height) @as(u16, 1) else @as(u16, 0); + const h_scrollbar = if (self.show_h_scrollbar and self.content_width > view_area.width) @as(u16, 1) else @as(u16, 0); + + return .{ + .offset_y = self.offset_y, + .offset_x = self.offset_x, + .width = view_area.width -| v_scrollbar, + .height = view_area.height -| h_scrollbar, + }; + } + + /// Renders only the scrollbars (content must be rendered by caller) + pub fn renderScrollbars(self: *const StaticViewport, area: Rect, buf: *Buffer, style: Style) void { + const visible = self.getVisibleArea(area); + + // Vertical scrollbar + if (self.show_v_scrollbar and self.content_height > area.height) { + var state = ScrollbarState.default; + state.content_length = self.content_height; + state.viewport_content_length = visible.height; + state.position = self.offset_y; + + const scrollbar = Scrollbar.init(.vertical_right).setStyle(style); + const sb_area = Rect.init(area.right() - 1, area.y, 1, visible.height); + scrollbar.render(sb_area, buf, &state); + } + + // Horizontal scrollbar + if (self.show_h_scrollbar and self.content_width > area.width) { + var state = ScrollbarState.default; + state.content_length = self.content_width; + state.viewport_content_length = visible.width; + state.position = self.offset_x; + + const scrollbar = Scrollbar.init(.horizontal_bottom).setStyle(style); + const sb_area = Rect.init(area.x, area.bottom() - 1, visible.width, 1); + scrollbar.render(sb_area, buf, &state); + } + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "ViewportState scroll operations" { + var state = ViewportState{ + .content_height = 100, + .content_width = 80, + }; + + state.scrollDown(10); + try std.testing.expectEqual(@as(u16, 10), state.offset_y); + + state.scrollUp(5); + try std.testing.expectEqual(@as(u16, 5), state.offset_y); + + state.scrollToTop(); + try std.testing.expectEqual(@as(u16, 0), state.offset_y); +} + +test "ViewportState page operations" { + var state = ViewportState{ + .content_height = 100, + .content_width = 80, + }; + + state.pageDown(20); + try std.testing.expectEqual(@as(u16, 19), state.offset_y); + + state.pageUp(20); + try std.testing.expectEqual(@as(u16, 0), state.offset_y); +} + +test "ViewportState ensure visible" { + var state = ViewportState{ + .content_height = 100, + .content_width = 80, + }; + + // Line within view - no change + state.ensureVisible(5, 20); + try std.testing.expectEqual(@as(u16, 0), state.offset_y); + + // Line below view - scroll down + state.ensureVisible(25, 20); + try std.testing.expectEqual(@as(u16, 6), state.offset_y); + + // Line above view - scroll up + state.ensureVisible(3, 20); + try std.testing.expectEqual(@as(u16, 3), state.offset_y); +} + +test "ViewportState scroll percentage" { + var state = ViewportState{ + .content_height = 100, + .content_width = 80, + }; + + try std.testing.expectEqual(@as(f32, 0.0), state.scrollPercentY(20)); + + state.offset_y = 40; // 40 / 80 (max scroll) = 0.5 + try std.testing.expectApproxEqAbs(@as(f32, 0.5), state.scrollPercentY(20), 0.01); +} + +test "ViewportState can scroll" { + var state = ViewportState{ + .content_height = 100, + .content_width = 80, + }; + + try std.testing.expect(!state.canScrollUp()); + try std.testing.expect(state.canScrollDown(20)); + + state.offset_y = 80; // At bottom for view height 20 + try std.testing.expect(state.canScrollUp()); + try std.testing.expect(!state.canScrollDown(20)); +} + +test "StaticViewport basic" { + const default_vp = StaticViewport{}; + var vp = default_vp.setContentSize(100, 200); + + try std.testing.expectEqual(@as(u16, 100), vp.content_width); + try std.testing.expectEqual(@as(u16, 200), vp.content_height); + + vp.scrollDown(10, 50); + try std.testing.expectEqual(@as(u16, 10), vp.offset_y); +}