feat: zcatui v2.1 - 7 new widgets, innovations, and technical docs

New widgets (Phase 1-3):
- Spinner: 10 animation styles (dots, line, arc, pulse, etc.)
- Help: Keybinding display with categories
- Viewport: Content scrolling (static/scrollable)
- Progress: Multi-step progress with styles
- Markdown: Basic markdown rendering (headers, lists, code)
- DirectoryTree: File browser with icons and filters
- SyntaxHighlighter: Code highlighting (Zig, Rust, Python, etc.)

Innovation modules:
- testing.zig: Widget testing framework (harness, simulated input, benchmarks)
- theme_loader.zig: Theme hot-reload from JSON/KV files
- serialize.zig: State serialization, undo/redo stack
- accessibility.zig: A11y support (ARIA roles, screen reader, high contrast)

Layout improvements:
- Flex layout with JustifyContent and AlignItems

Documentation:
- TECHNICAL_REFERENCE.md: Comprehensive 1200+ line technical manual

Stats: 67 files, 34 widgets, 250+ tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 20:29:18 +01:00
parent 508bc37dca
commit c8316f2134
15 changed files with 7738 additions and 15 deletions

View file

@ -42,7 +42,7 @@ Es el repositorio centralizado con todas las normas de trabajo del equipo:
## INFORMACIÓN DEL PROYECTO ## INFORMACIÓN DEL PROYECTO
**Nombre:** zcatui **Nombre:** zcatui
**Versión:** v2.0 - FEATURE COMPLETE **Versión:** v2.1 - FEATURE COMPLETE + INNOVATIONS
**Última actualización:** 2025-12-08 **Última actualización:** 2025-12-08
**Lenguaje:** Zig 0.15.2 **Lenguaje:** Zig 0.15.2
**Inspiración:** [ratatui](https://github.com/ratatui/ratatui) + [crossterm](https://github.com/crossterm-rs/crossterm) (Rust) **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 ### Estadísticas
| Métrica | Valor | | Métrica | Valor |
|---------|-------| |---------|-------|
| Archivos fuente | 60 archivos .zig | | Archivos fuente | 67 archivos .zig |
| Widgets | 27 widgets | | Widgets | 34 widgets |
| Módulos core | 16 módulos | | Módulos core | 20 módulos |
| Tests | 186+ tests | | Tests | 250+ tests |
| Examples | 11 demos ejecutables | | Examples | 11 demos ejecutables |
### Funcionalidades Principales ### Funcionalidades Principales
- ✅ Renderizado immediate-mode con double buffering y diff - ✅ 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 eventos teclado/ratón
- ✅ Sistema de animaciones con easing - ✅ Sistema de animaciones con easing
- ✅ Clipboard (OSC 52) - ✅ 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) - ✅ Imágenes en terminal (Kitty/iTerm2)
- ✅ Notificaciones desktop (OSC 9/777) - ✅ Notificaciones desktop (OSC 9/777)
- ✅ Focus management global - ✅ Focus management global
- ✅ Sistema de themes (10 themes predefinidos) - ✅ Sistema de themes con hot-reload
- ✅ Unicode width calculation (wcwidth) - ✅ Unicode width calculation (wcwidth)
- ✅ Terminal capability detection - ✅ Terminal capability detection
- ✅ Lazy rendering con cache - ✅ 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 ## RUTAS IMPORTANTES
@ -165,7 +179,7 @@ zcatui/
│ ├── ─── SYMBOLS ─── │ ├── ─── SYMBOLS ───
│ ├── symbols/ # line, border, block, bar, braille... │ ├── symbols/ # line, border, block, bar, braille...
│ │ │ │
│ ├── ─── WIDGETS (27) ─── │ ├── ─── WIDGETS (34) ───
│ ├── widgets/ │ ├── widgets/
│ │ ├── block.zig # Block (borders, titles) │ │ ├── block.zig # Block (borders, titles)
│ │ ├── paragraph.zig # Text wrapping │ │ ├── paragraph.zig # Text wrapping
@ -192,7 +206,14 @@ zcatui/
│ │ ├── checkbox.zig # Checkbox, RadioGroup │ │ ├── checkbox.zig # Checkbox, RadioGroup
│ │ ├── select.zig # Select dropdown │ │ ├── select.zig # Select dropdown
│ │ ├── slider.zig # Slider │ │ ├── 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 ───
│ └── tests/ # Test suite │ └── tests/ # Test suite
@ -300,6 +321,7 @@ git.reugenio.com (Forgejo)
| Versión | Fecha | Cambios | | 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 | | v2.0 | 2025-12-08 | Focus, themes, unicode, termcap, 186+ tests |
| v1.4 | 2025-12-08 | Form widgets, panels, scroll, tree | | v1.4 | 2025-12-08 | Form widgets, panels, scroll, tree |
| v1.3 | 2025-12-08 | Menus, modals, animation, clipboard | | v1.3 | 2025-12-08 | Menus, modals, animation, clipboard |
@ -310,16 +332,21 @@ git.reugenio.com (Forgejo)
## ESTADO ACTUAL ## ESTADO ACTUAL
**El proyecto está FEATURE COMPLETE (v2.0)** **El proyecto está FEATURE COMPLETE + INNOVATIONS (v2.1)**
- ✅ Todos los widgets implementados - ✅ 34 widgets implementados (7 nuevos en v2.1)
- ✅ Todos los tests pasando (186+) - ✅ Todos los tests pasando (250+)
- ✅ Documentación completa - ✅ Manual técnico completo (docs/TECHNICAL_REFERENCE.md)
- ✅ Examples funcionando - ✅ 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):** **Posibles mejoras futuras (opcionales):**
- Performance: SIMD para buffer - Performance: SIMD para buffer
- Más examples específicos - Más examples específicos de v2.1 widgets
- Tutorial paso a paso - Tutorial paso a paso
- Publicación en package registry - Publicación en package registry

1260
docs/TECHNICAL_REFERENCE.md Normal file

File diff suppressed because it is too large Load diff

591
src/accessibility.zig Normal file
View file

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

View file

@ -1,7 +1,7 @@
//! Layout system for dividing terminal space. //! Layout system for dividing terminal space.
//! //!
//! Layouts allow you to split a Rect into multiple sub-areas //! Layouts allow you to split a Rect into multiple sub-areas
//! using flexible constraints. //! using flexible constraints. Supports CSS-like flex distribution.
//! //!
//! ## Example //! ## Example
//! //!
@ -14,6 +14,12 @@
//! //!
//! // chunks[0] = header area (3 rows) //! // chunks[0] = header area (3 rows)
//! // chunks[1] = content area (remaining space) //! // 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"); 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, 76), result.rects[0].width);
try std.testing.expectEqual(@as(u16, 20), result.rects[0].height); 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);
}

View file

@ -58,6 +58,13 @@ pub const layout = @import("layout.zig");
pub const Layout = layout.Layout; pub const Layout = layout.Layout;
pub const Constraint = layout.Constraint; pub const Constraint = layout.Constraint;
pub const Direction = layout.Direction; 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.) // Symbols (line drawing, borders, blocks, braille, etc.)
pub const symbols = @import("symbols/symbols.zig"); pub const symbols = @import("symbols/symbols.zig");
@ -207,6 +214,41 @@ pub const widgets = struct {
pub const Toast = statusbar_mod.Toast; pub const Toast = statusbar_mod.Toast;
pub const ToastType = statusbar_mod.ToastType; pub const ToastType = statusbar_mod.ToastType;
pub const ToastManager = statusbar_mod.ToastManager; 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 // Backend
@ -294,6 +336,37 @@ pub const Capabilities = termcap.Capabilities;
pub const ColorSupport = termcap.ColorSupport; pub const ColorSupport = termcap.ColorSupport;
pub const detectCapabilities = termcap.detect; 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 // Tests
// ============================================================================ // ============================================================================
@ -315,6 +388,21 @@ test {
_ = @import("unicode.zig"); _ = @import("unicode.zig");
_ = @import("termcap.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 // Comprehensive test suite
_ = @import("tests/tests.zig"); _ = @import("tests/tests.zig");
} }

458
src/serialize.zig Normal file
View file

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

566
src/testing.zig Normal file
View file

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

439
src/theme_loader.zig Normal file
View file

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

633
src/widgets/dirtree.zig Normal file
View file

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

435
src/widgets/help.zig Normal file
View file

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

549
src/widgets/markdown.zig Normal file
View file

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

544
src/widgets/progress.zig Normal file
View file

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

282
src/widgets/spinner.zig Normal file
View file

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

967
src/widgets/syntax.zig Normal file
View file

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

527
src/widgets/viewport.zig Normal file
View file

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