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:
parent
508bc37dca
commit
c8316f2134
15 changed files with 7738 additions and 15 deletions
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -42,7 +42,7 @@ Es el repositorio centralizado con todas las normas de trabajo del equipo:
|
|||
## INFORMACIÓN DEL PROYECTO
|
||||
|
||||
**Nombre:** zcatui
|
||||
**Versión:** v2.0 - FEATURE COMPLETE
|
||||
**Versión:** v2.1 - FEATURE COMPLETE + INNOVATIONS
|
||||
**Última actualización:** 2025-12-08
|
||||
**Lenguaje:** Zig 0.15.2
|
||||
**Inspiración:** [ratatui](https://github.com/ratatui/ratatui) + [crossterm](https://github.com/crossterm-rs/crossterm) (Rust)
|
||||
|
|
@ -54,15 +54,15 @@ Es el repositorio centralizado con todas las normas de trabajo del equipo:
|
|||
### Estadísticas
|
||||
| Métrica | Valor |
|
||||
|---------|-------|
|
||||
| Archivos fuente | 60 archivos .zig |
|
||||
| Widgets | 27 widgets |
|
||||
| Módulos core | 16 módulos |
|
||||
| Tests | 186+ tests |
|
||||
| Archivos fuente | 67 archivos .zig |
|
||||
| Widgets | 34 widgets |
|
||||
| Módulos core | 20 módulos |
|
||||
| Tests | 250+ tests |
|
||||
| Examples | 11 demos ejecutables |
|
||||
|
||||
### Funcionalidades Principales
|
||||
- ✅ Renderizado immediate-mode con double buffering y diff
|
||||
- ✅ 27 widgets (más que ratatui)
|
||||
- ✅ 34 widgets (más que ratatui)
|
||||
- ✅ Sistema de eventos teclado/ratón
|
||||
- ✅ Sistema de animaciones con easing
|
||||
- ✅ Clipboard (OSC 52)
|
||||
|
|
@ -70,11 +70,25 @@ Es el repositorio centralizado con todas las normas de trabajo del equipo:
|
|||
- ✅ Imágenes en terminal (Kitty/iTerm2)
|
||||
- ✅ Notificaciones desktop (OSC 9/777)
|
||||
- ✅ Focus management global
|
||||
- ✅ Sistema de themes (10 themes predefinidos)
|
||||
- ✅ Sistema de themes con hot-reload
|
||||
- ✅ Unicode width calculation (wcwidth)
|
||||
- ✅ Terminal capability detection
|
||||
- ✅ Lazy rendering con cache
|
||||
|
||||
### Nuevos en v2.1
|
||||
- ✅ **Spinner** - Indicadores de carga animados (17 estilos)
|
||||
- ✅ **Help** - Auto-genera ayuda de keybindings
|
||||
- ✅ **Viewport** - Scroll genérico con buffer interno
|
||||
- ✅ **Progress** - Barras de progreso con ETA y velocidad
|
||||
- ✅ **Markdown** - Renderizado de Markdown styled
|
||||
- ✅ **DirectoryTree** - Navegador de archivos
|
||||
- ✅ **SyntaxHighlighter** - Resaltado de sintaxis (10 lenguajes)
|
||||
- ✅ **Flex Layout** - CSS-like justify/align
|
||||
- ✅ **Widget Testing Framework** - Harness, assertions, benchmarks
|
||||
- ✅ **Theme Hot-Reload** - Cargar themes desde archivos JSON/KV
|
||||
- ✅ **Widget Serialization** - JSON export, undo/redo, snapshots
|
||||
- ✅ **Accessibility** - Roles ARIA, announcements, high contrast
|
||||
|
||||
---
|
||||
|
||||
## RUTAS IMPORTANTES
|
||||
|
|
@ -165,7 +179,7 @@ zcatui/
|
|||
│ ├── ─── SYMBOLS ───
|
||||
│ ├── symbols/ # line, border, block, bar, braille...
|
||||
│ │
|
||||
│ ├── ─── WIDGETS (27) ───
|
||||
│ ├── ─── WIDGETS (34) ───
|
||||
│ ├── widgets/
|
||||
│ │ ├── block.zig # Block (borders, titles)
|
||||
│ │ ├── paragraph.zig # Text wrapping
|
||||
|
|
@ -192,7 +206,14 @@ zcatui/
|
|||
│ │ ├── checkbox.zig # Checkbox, RadioGroup
|
||||
│ │ ├── select.zig # Select dropdown
|
||||
│ │ ├── slider.zig # Slider
|
||||
│ │ └── statusbar.zig # StatusBar, Toast
|
||||
│ │ ├── statusbar.zig # StatusBar, Toast
|
||||
│ │ ├── spinner.zig # Spinner (17 estilos) [NEW v2.1]
|
||||
│ │ ├── help.zig # Help (auto keybindings) [NEW v2.1]
|
||||
│ │ ├── viewport.zig # Viewport (scroll genérico) [NEW v2.1]
|
||||
│ │ ├── progress.zig # Progress (ETA, speed) [NEW v2.1]
|
||||
│ │ ├── markdown.zig # Markdown renderer [NEW v2.1]
|
||||
│ │ ├── dirtree.zig # DirectoryTree [NEW v2.1]
|
||||
│ │ └── syntax.zig # SyntaxHighlighter [NEW v2.1]
|
||||
│ │
|
||||
│ └── ─── TESTS ───
|
||||
│ └── tests/ # Test suite
|
||||
|
|
@ -300,6 +321,7 @@ git.reugenio.com (Forgejo)
|
|||
|
||||
| Versión | Fecha | Cambios |
|
||||
|---------|-------|---------|
|
||||
| v2.1 | 2025-12-08 | 7 nuevos widgets, Flex Layout, Testing Framework, Theme Hot-Reload, Serialization, Accessibility, 250+ tests |
|
||||
| v2.0 | 2025-12-08 | Focus, themes, unicode, termcap, 186+ tests |
|
||||
| v1.4 | 2025-12-08 | Form widgets, panels, scroll, tree |
|
||||
| v1.3 | 2025-12-08 | Menus, modals, animation, clipboard |
|
||||
|
|
@ -310,16 +332,21 @@ git.reugenio.com (Forgejo)
|
|||
|
||||
## ESTADO ACTUAL
|
||||
|
||||
**El proyecto está FEATURE COMPLETE (v2.0)**
|
||||
**El proyecto está FEATURE COMPLETE + INNOVATIONS (v2.1)**
|
||||
|
||||
- ✅ Todos los widgets implementados
|
||||
- ✅ Todos los tests pasando (186+)
|
||||
- ✅ Documentación completa
|
||||
- ✅ 34 widgets implementados (7 nuevos en v2.1)
|
||||
- ✅ Todos los tests pasando (250+)
|
||||
- ✅ Manual técnico completo (docs/TECHNICAL_REFERENCE.md)
|
||||
- ✅ Examples funcionando
|
||||
- ✅ Flex Layout CSS-like
|
||||
- ✅ Testing Framework para widgets
|
||||
- ✅ Theme hot-reload desde archivos
|
||||
- ✅ Widget serialization (JSON, undo/redo)
|
||||
- ✅ Accessibility básico (ARIA roles, announcements)
|
||||
|
||||
**Posibles mejoras futuras (opcionales):**
|
||||
- Performance: SIMD para buffer
|
||||
- Más examples específicos
|
||||
- Más examples específicos de v2.1 widgets
|
||||
- Tutorial paso a paso
|
||||
- Publicación en package registry
|
||||
|
||||
|
|
|
|||
1260
docs/TECHNICAL_REFERENCE.md
Normal file
1260
docs/TECHNICAL_REFERENCE.md
Normal file
File diff suppressed because it is too large
Load diff
591
src/accessibility.zig
Normal file
591
src/accessibility.zig
Normal 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);
|
||||
}
|
||||
359
src/layout.zig
359
src/layout.zig
|
|
@ -1,7 +1,7 @@
|
|||
//! Layout system for dividing terminal space.
|
||||
//!
|
||||
//! Layouts allow you to split a Rect into multiple sub-areas
|
||||
//! using flexible constraints.
|
||||
//! using flexible constraints. Supports CSS-like flex distribution.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
|
|
@ -14,6 +14,12 @@
|
|||
//!
|
||||
//! // chunks[0] = header area (3 rows)
|
||||
//! // chunks[1] = content area (remaining space)
|
||||
//!
|
||||
//! // Using Flex layout (CSS-like):
|
||||
//! const centered = Flex.horizontal()
|
||||
//! .justify(.center)
|
||||
//! .items(&.{ 20, 30, 20 })
|
||||
//! .split(area);
|
||||
//! ```
|
||||
|
||||
const std = @import("std");
|
||||
|
|
@ -251,3 +257,354 @@ test "Layout with margin" {
|
|||
try std.testing.expectEqual(@as(u16, 76), result.rects[0].width);
|
||||
try std.testing.expectEqual(@as(u16, 20), result.rects[0].height);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Flex Layout (CSS-like)
|
||||
// ============================================================================
|
||||
|
||||
/// Justify content options (like CSS flexbox)
|
||||
pub const JustifyContent = enum {
|
||||
/// Items at the start (default)
|
||||
start,
|
||||
/// Items at the end
|
||||
end,
|
||||
/// Items centered
|
||||
center,
|
||||
/// Equal space between items
|
||||
space_between,
|
||||
/// Equal space around items
|
||||
space_around,
|
||||
/// Equal space between and around items
|
||||
space_evenly,
|
||||
};
|
||||
|
||||
/// Align items options (cross-axis)
|
||||
pub const AlignItems = enum {
|
||||
/// Stretch to fill (default)
|
||||
stretch,
|
||||
/// Align at start
|
||||
start,
|
||||
/// Align at end
|
||||
end,
|
||||
/// Align at center
|
||||
center,
|
||||
};
|
||||
|
||||
/// Flex layout for CSS-like distribution
|
||||
pub const Flex = struct {
|
||||
direction: Direction = .horizontal,
|
||||
justify: JustifyContent = .start,
|
||||
align_items: AlignItems = .stretch,
|
||||
gap: u16 = 0,
|
||||
margin: u16 = 0,
|
||||
sizes: []const u16 = &.{},
|
||||
|
||||
/// Creates a horizontal flex layout
|
||||
pub fn horizontal() Flex {
|
||||
return .{ .direction = .horizontal };
|
||||
}
|
||||
|
||||
/// Creates a vertical flex layout
|
||||
pub fn vertical() Flex {
|
||||
return .{ .direction = .vertical };
|
||||
}
|
||||
|
||||
/// Sets justify content
|
||||
pub fn setJustify(self: Flex, j: JustifyContent) Flex {
|
||||
var f = self;
|
||||
f.justify = j;
|
||||
return f;
|
||||
}
|
||||
|
||||
/// Sets align items
|
||||
pub fn setAlign(self: Flex, a: AlignItems) Flex {
|
||||
var f = self;
|
||||
f.align_items = a;
|
||||
return f;
|
||||
}
|
||||
|
||||
/// Sets gap between items
|
||||
pub fn setGap(self: Flex, g: u16) Flex {
|
||||
var f = self;
|
||||
f.gap = g;
|
||||
return f;
|
||||
}
|
||||
|
||||
/// Sets margin around the layout
|
||||
pub fn setMargin(self: Flex, m: u16) Flex {
|
||||
var f = self;
|
||||
f.margin = m;
|
||||
return f;
|
||||
}
|
||||
|
||||
/// Sets item sizes (in the main axis direction)
|
||||
pub fn items(self: Flex, item_sizes: []const u16) Flex {
|
||||
var f = self;
|
||||
f.sizes = item_sizes;
|
||||
return f;
|
||||
}
|
||||
|
||||
/// Splits the area using flex distribution
|
||||
pub fn split(self: Flex, area: Rect) SplitResult {
|
||||
var result: SplitResult = .{};
|
||||
|
||||
if (self.sizes.len == 0) return result;
|
||||
|
||||
// Apply margin
|
||||
const inner = if (self.margin > 0)
|
||||
area.inner(.{
|
||||
.top = self.margin,
|
||||
.right = self.margin,
|
||||
.bottom = self.margin,
|
||||
.left = self.margin,
|
||||
})
|
||||
else
|
||||
area;
|
||||
|
||||
if (inner.isEmpty()) return result;
|
||||
|
||||
const item_count = @min(self.sizes.len, SplitResult.MAX_SPLITS);
|
||||
|
||||
// Calculate total size of items
|
||||
var total_item_size: u32 = 0;
|
||||
for (self.sizes[0..item_count]) |size| {
|
||||
total_item_size += size;
|
||||
}
|
||||
|
||||
// Add gaps
|
||||
const total_gaps: u32 = if (item_count > 1) self.gap * @as(u32, @intCast(item_count - 1)) else 0;
|
||||
const total_content: u32 = total_item_size + total_gaps;
|
||||
|
||||
const available: u32 = switch (self.direction) {
|
||||
.horizontal => inner.width,
|
||||
.vertical => inner.height,
|
||||
};
|
||||
|
||||
// Calculate cross-axis size
|
||||
const cross_size: u16 = switch (self.direction) {
|
||||
.horizontal => inner.height,
|
||||
.vertical => inner.width,
|
||||
};
|
||||
|
||||
// Calculate spacing based on justify
|
||||
const extra_space: u32 = if (available > total_content) available - total_content else 0;
|
||||
|
||||
var start_offset: u32 = 0;
|
||||
var between_space: u32 = self.gap;
|
||||
var around_space: u32 = 0;
|
||||
|
||||
switch (self.justify) {
|
||||
.start => {},
|
||||
.end => {
|
||||
start_offset = extra_space;
|
||||
},
|
||||
.center => {
|
||||
start_offset = extra_space / 2;
|
||||
},
|
||||
.space_between => {
|
||||
if (item_count > 1) {
|
||||
between_space = (extra_space + total_gaps) / @as(u32, @intCast(item_count - 1));
|
||||
}
|
||||
},
|
||||
.space_around => {
|
||||
if (item_count > 0) {
|
||||
around_space = extra_space / @as(u32, @intCast(item_count * 2));
|
||||
start_offset = around_space;
|
||||
}
|
||||
},
|
||||
.space_evenly => {
|
||||
if (item_count > 0) {
|
||||
const slots = @as(u32, @intCast(item_count + 1));
|
||||
start_offset = (extra_space + total_gaps) / slots;
|
||||
between_space = start_offset;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Generate rects
|
||||
var pos: u32 = start_offset;
|
||||
const base_x: u16 = switch (self.direction) {
|
||||
.horizontal => inner.x,
|
||||
.vertical => inner.x,
|
||||
};
|
||||
const base_y: u16 = switch (self.direction) {
|
||||
.horizontal => inner.y,
|
||||
.vertical => inner.y,
|
||||
};
|
||||
|
||||
for (self.sizes[0..item_count], 0..) |size, i| {
|
||||
const item_cross_size = switch (self.align_items) {
|
||||
.stretch => cross_size,
|
||||
.start, .end, .center => @min(size, cross_size),
|
||||
};
|
||||
|
||||
const cross_offset: u16 = switch (self.align_items) {
|
||||
.stretch, .start => 0,
|
||||
.end => cross_size -| item_cross_size,
|
||||
.center => (cross_size -| item_cross_size) / 2,
|
||||
};
|
||||
|
||||
result.rects[i] = switch (self.direction) {
|
||||
.horizontal => Rect.init(
|
||||
base_x +| @as(u16, @intCast(pos)),
|
||||
base_y + cross_offset,
|
||||
size,
|
||||
item_cross_size,
|
||||
),
|
||||
.vertical => Rect.init(
|
||||
base_x + cross_offset,
|
||||
base_y +| @as(u16, @intCast(pos)),
|
||||
item_cross_size,
|
||||
size,
|
||||
),
|
||||
};
|
||||
result.count += 1;
|
||||
|
||||
pos += size;
|
||||
if (i < item_count - 1) {
|
||||
pos += between_space;
|
||||
if (self.justify == .space_around) {
|
||||
pos += around_space * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Centers a single item within the area
|
||||
pub fn center(self: Flex, area: Rect, width: u16, height: u16) Rect {
|
||||
_ = self;
|
||||
const x = area.x + (area.width -| width) / 2;
|
||||
const y = area.y + (area.height -| height) / 2;
|
||||
return Rect.init(x, y, @min(width, area.width), @min(height, area.height));
|
||||
}
|
||||
};
|
||||
|
||||
/// Helper to center a rect within another rect
|
||||
pub fn centerRect(outer: Rect, width: u16, height: u16) Rect {
|
||||
const x = outer.x + (outer.width -| width) / 2;
|
||||
const y = outer.y + (outer.height -| height) / 2;
|
||||
return Rect.init(x, y, @min(width, outer.width), @min(height, outer.height));
|
||||
}
|
||||
|
||||
/// Helper to align a rect at the bottom of another
|
||||
pub fn alignBottom(outer: Rect, height: u16) Rect {
|
||||
const h = @min(height, outer.height);
|
||||
return Rect.init(outer.x, outer.bottom() -| h, outer.width, h);
|
||||
}
|
||||
|
||||
/// Helper to align a rect at the right of another
|
||||
pub fn alignRight(outer: Rect, width: u16) Rect {
|
||||
const w = @min(width, outer.width);
|
||||
return Rect.init(outer.right() -| w, outer.y, w, outer.height);
|
||||
}
|
||||
|
||||
/// Helper to align a rect at the bottom-right corner
|
||||
pub fn alignBottomRight(outer: Rect, width: u16, height: u16) Rect {
|
||||
const w = @min(width, outer.width);
|
||||
const h = @min(height, outer.height);
|
||||
return Rect.init(outer.right() -| w, outer.bottom() -| h, w, h);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Flex Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Flex horizontal center" {
|
||||
const area = Rect.init(0, 0, 100, 10);
|
||||
const flex = Flex.horizontal()
|
||||
.setJustify(.center)
|
||||
.items(&.{ 20, 20 });
|
||||
|
||||
const result = flex.split(area);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), result.count);
|
||||
// Items should be centered: (100 - 40) / 2 = 30 offset
|
||||
try std.testing.expectEqual(@as(u16, 30), result.rects[0].x);
|
||||
try std.testing.expectEqual(@as(u16, 50), result.rects[1].x);
|
||||
}
|
||||
|
||||
test "Flex horizontal space_between" {
|
||||
const area = Rect.init(0, 0, 100, 10);
|
||||
const flex = Flex.horizontal()
|
||||
.setJustify(.space_between)
|
||||
.items(&.{ 10, 10, 10 });
|
||||
|
||||
const result = flex.split(area);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 3), result.count);
|
||||
// First at start, last at end
|
||||
try std.testing.expectEqual(@as(u16, 0), result.rects[0].x);
|
||||
// Space between: (100 - 30) / 2 = 35
|
||||
try std.testing.expectEqual(@as(u16, 45), result.rects[1].x);
|
||||
try std.testing.expectEqual(@as(u16, 90), result.rects[2].x);
|
||||
}
|
||||
|
||||
test "Flex horizontal end" {
|
||||
const area = Rect.init(0, 0, 100, 10);
|
||||
const flex = Flex.horizontal()
|
||||
.setJustify(.end)
|
||||
.items(&.{ 20, 30 });
|
||||
|
||||
const result = flex.split(area);
|
||||
|
||||
// Total: 50, offset: 50
|
||||
try std.testing.expectEqual(@as(u16, 50), result.rects[0].x);
|
||||
try std.testing.expectEqual(@as(u16, 70), result.rects[1].x);
|
||||
}
|
||||
|
||||
test "Flex with gap" {
|
||||
const area = Rect.init(0, 0, 100, 10);
|
||||
const flex = Flex.horizontal()
|
||||
.setGap(5)
|
||||
.items(&.{ 20, 20 });
|
||||
|
||||
const result = flex.split(area);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 0), result.rects[0].x);
|
||||
try std.testing.expectEqual(@as(u16, 25), result.rects[1].x); // 20 + 5 gap
|
||||
}
|
||||
|
||||
test "Flex vertical center" {
|
||||
const area = Rect.init(0, 0, 80, 24);
|
||||
const flex = Flex.vertical()
|
||||
.setJustify(.center)
|
||||
.items(&.{ 3, 3 });
|
||||
|
||||
const result = flex.split(area);
|
||||
|
||||
// (24 - 6) / 2 = 9 offset
|
||||
try std.testing.expectEqual(@as(u16, 9), result.rects[0].y);
|
||||
try std.testing.expectEqual(@as(u16, 12), result.rects[1].y);
|
||||
}
|
||||
|
||||
test "centerRect helper" {
|
||||
const outer = Rect.init(0, 0, 100, 50);
|
||||
const inner = centerRect(outer, 20, 10);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 40), inner.x);
|
||||
try std.testing.expectEqual(@as(u16, 20), inner.y);
|
||||
try std.testing.expectEqual(@as(u16, 20), inner.width);
|
||||
try std.testing.expectEqual(@as(u16, 10), inner.height);
|
||||
}
|
||||
|
||||
test "alignBottom helper" {
|
||||
const outer = Rect.init(0, 0, 100, 50);
|
||||
const inner = alignBottom(outer, 10);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 0), inner.x);
|
||||
try std.testing.expectEqual(@as(u16, 40), inner.y);
|
||||
try std.testing.expectEqual(@as(u16, 100), inner.width);
|
||||
try std.testing.expectEqual(@as(u16, 10), inner.height);
|
||||
}
|
||||
|
||||
test "alignRight helper" {
|
||||
const outer = Rect.init(0, 0, 100, 50);
|
||||
const inner = alignRight(outer, 20);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 80), inner.x);
|
||||
try std.testing.expectEqual(@as(u16, 0), inner.y);
|
||||
try std.testing.expectEqual(@as(u16, 20), inner.width);
|
||||
try std.testing.expectEqual(@as(u16, 50), inner.height);
|
||||
}
|
||||
|
|
|
|||
88
src/root.zig
88
src/root.zig
|
|
@ -58,6 +58,13 @@ pub const layout = @import("layout.zig");
|
|||
pub const Layout = layout.Layout;
|
||||
pub const Constraint = layout.Constraint;
|
||||
pub const Direction = layout.Direction;
|
||||
pub const Flex = layout.Flex;
|
||||
pub const JustifyContent = layout.JustifyContent;
|
||||
pub const AlignItems = layout.AlignItems;
|
||||
pub const centerRect = layout.centerRect;
|
||||
pub const alignBottom = layout.alignBottom;
|
||||
pub const alignRight = layout.alignRight;
|
||||
pub const alignBottomRight = layout.alignBottomRight;
|
||||
|
||||
// Symbols (line drawing, borders, blocks, braille, etc.)
|
||||
pub const symbols = @import("symbols/symbols.zig");
|
||||
|
|
@ -207,6 +214,41 @@ pub const widgets = struct {
|
|||
pub const Toast = statusbar_mod.Toast;
|
||||
pub const ToastType = statusbar_mod.ToastType;
|
||||
pub const ToastManager = statusbar_mod.ToastManager;
|
||||
|
||||
pub const spinner_mod = @import("widgets/spinner.zig");
|
||||
pub const Spinner = spinner_mod.Spinner;
|
||||
pub const SpinnerStyle = spinner_mod.SpinnerStyle;
|
||||
|
||||
pub const help_mod = @import("widgets/help.zig");
|
||||
pub const Help = help_mod.Help;
|
||||
pub const KeyBinding = help_mod.KeyBinding;
|
||||
pub const HelpMode = help_mod.HelpMode;
|
||||
pub const CommonBindings = help_mod.CommonBindings;
|
||||
|
||||
pub const viewport_mod = @import("widgets/viewport.zig");
|
||||
pub const Viewport = viewport_mod.Viewport;
|
||||
pub const ViewportState = viewport_mod.ViewportState;
|
||||
pub const StaticViewport = viewport_mod.StaticViewport;
|
||||
|
||||
pub const progress_mod = @import("widgets/progress.zig");
|
||||
pub const Progress = progress_mod.Progress;
|
||||
pub const ProgressFormat = progress_mod.ProgressFormat;
|
||||
pub const MultiProgress = progress_mod.MultiProgress;
|
||||
|
||||
pub const markdown_mod = @import("widgets/markdown.zig");
|
||||
pub const Markdown = markdown_mod.Markdown;
|
||||
pub const MarkdownTheme = markdown_mod.MarkdownTheme;
|
||||
|
||||
pub const dirtree_mod = @import("widgets/dirtree.zig");
|
||||
pub const DirectoryTree = dirtree_mod.DirectoryTree;
|
||||
pub const DirNode = dirtree_mod.DirNode;
|
||||
pub const DirTreeTheme = dirtree_mod.DirTreeTheme;
|
||||
|
||||
pub const syntax_mod = @import("widgets/syntax.zig");
|
||||
pub const SyntaxHighlighter = syntax_mod.SyntaxHighlighter;
|
||||
pub const SyntaxLanguage = syntax_mod.Language;
|
||||
pub const SyntaxTheme = syntax_mod.SyntaxTheme;
|
||||
pub const TokenType = syntax_mod.TokenType;
|
||||
};
|
||||
|
||||
// Backend
|
||||
|
|
@ -294,6 +336,37 @@ pub const Capabilities = termcap.Capabilities;
|
|||
pub const ColorSupport = termcap.ColorSupport;
|
||||
pub const detectCapabilities = termcap.detect;
|
||||
|
||||
// Testing framework
|
||||
pub const testing_framework = @import("testing.zig");
|
||||
pub const WidgetHarness = testing_framework.WidgetHarness;
|
||||
pub const TestBackend = testing_framework.TestBackend;
|
||||
pub const SimulatedInput = testing_framework.SimulatedInput;
|
||||
pub const Benchmark = testing_framework.Benchmark;
|
||||
|
||||
// Theme hot-reload
|
||||
pub const theme_loader = @import("theme_loader.zig");
|
||||
pub const ThemeLoader = theme_loader.ThemeLoader;
|
||||
pub const ThemeWatcher = theme_loader.ThemeWatcher;
|
||||
pub const exportTheme = theme_loader.exportTheme;
|
||||
|
||||
// Serialization
|
||||
pub const serialize = @import("serialize.zig");
|
||||
pub const StateSnapshot = serialize.StateSnapshot;
|
||||
pub const UndoStack = serialize.UndoStack;
|
||||
pub const KvSerializer = serialize.KvSerializer;
|
||||
pub const toJson = serialize.toJson;
|
||||
|
||||
// Accessibility
|
||||
pub const accessibility = @import("accessibility.zig");
|
||||
pub const AccessibleInfo = accessibility.AccessibleInfo;
|
||||
pub const A11yRole = accessibility.Role;
|
||||
pub const Announcer = accessibility.Announcer;
|
||||
pub const A11yPreferences = accessibility.Preferences;
|
||||
pub const SkipLinks = accessibility.SkipLinks;
|
||||
pub const prefersReducedMotion = accessibility.prefersReducedMotion;
|
||||
pub const prefersHighContrast = accessibility.prefersHighContrast;
|
||||
pub const high_contrast_theme = accessibility.high_contrast_theme;
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
|
@ -315,6 +388,21 @@ test {
|
|||
_ = @import("unicode.zig");
|
||||
_ = @import("termcap.zig");
|
||||
|
||||
// New modules
|
||||
_ = @import("testing.zig");
|
||||
_ = @import("theme_loader.zig");
|
||||
_ = @import("serialize.zig");
|
||||
_ = @import("accessibility.zig");
|
||||
|
||||
// New widgets
|
||||
_ = @import("widgets/spinner.zig");
|
||||
_ = @import("widgets/help.zig");
|
||||
_ = @import("widgets/viewport.zig");
|
||||
_ = @import("widgets/progress.zig");
|
||||
_ = @import("widgets/markdown.zig");
|
||||
_ = @import("widgets/dirtree.zig");
|
||||
_ = @import("widgets/syntax.zig");
|
||||
|
||||
// Comprehensive test suite
|
||||
_ = @import("tests/tests.zig");
|
||||
}
|
||||
|
|
|
|||
458
src/serialize.zig
Normal file
458
src/serialize.zig
Normal 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
566
src/testing.zig
Normal 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
439
src/theme_loader.zig
Normal 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
633
src/widgets/dirtree.zig
Normal 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
435
src/widgets/help.zig
Normal 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
549
src/widgets/markdown.zig
Normal 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
544
src/widgets/progress.zig
Normal 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
282
src/widgets/spinner.zig
Normal 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
967
src/widgets/syntax.zig
Normal 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
527
src/widgets/viewport.zig
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue