feat: zcatui v2.2 - Complete feature set with 13 new modules

New modules (13):
- src/resize.zig: SIGWINCH terminal resize detection
- src/drag.zig: Mouse drag state and Splitter panels
- src/diagnostic.zig: Elm-style error messages with code snippets
- src/debug.zig: Debug overlay (FPS, timing, widget count)
- src/profile.zig: Performance profiling with scoped timers
- src/sixel.zig: Sixel graphics encoding for terminal images
- src/async_loop.zig: epoll-based async event loop with timers
- src/compose.zig: Widget composition utilities
- src/shortcuts.zig: Keyboard shortcut registry
- src/widgets/logo.zig: ASCII art logo widget

Enhanced modules:
- src/layout.zig: Added Constraint.ratio(num, denom)
- src/terminal.zig: Integrated resize handling
- src/root.zig: Re-exports all new modules

New examples (9):
- resize_demo, splitter_demo, dirtree_demo
- help_demo, markdown_demo, progress_demo
- spinner_demo, syntax_demo, viewport_demo

Package manager:
- build.zig.zon: Zig package manager support

Stats: 60+ source files, 186+ tests, 20 executables

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 22:46:06 +01:00
parent c8316f2134
commit 7abc87a4f5
26 changed files with 8943 additions and 13 deletions

171
build.zig
View file

@ -232,4 +232,175 @@ pub fn build(b: *std.Build) void {
run_panel_demo.step.dependOn(b.getInstallStep());
const panel_demo_step = b.step("panel-demo", "Run panel demo");
panel_demo_step.dependOn(&run_panel_demo.step);
// Ejemplo: spinner_demo
const spinner_demo_exe = b.addExecutable(.{
.name = "spinner-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/spinner_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(spinner_demo_exe);
const run_spinner_demo = b.addRunArtifact(spinner_demo_exe);
run_spinner_demo.step.dependOn(b.getInstallStep());
const spinner_demo_step = b.step("spinner-demo", "Run spinner demo");
spinner_demo_step.dependOn(&run_spinner_demo.step);
// Ejemplo: syntax_demo
const syntax_demo_exe = b.addExecutable(.{
.name = "syntax-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/syntax_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(syntax_demo_exe);
const run_syntax_demo = b.addRunArtifact(syntax_demo_exe);
run_syntax_demo.step.dependOn(b.getInstallStep());
const syntax_demo_step = b.step("syntax-demo", "Run syntax highlighting demo");
syntax_demo_step.dependOn(&run_syntax_demo.step);
// Ejemplo: markdown_demo
const markdown_demo_exe = b.addExecutable(.{
.name = "markdown-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/markdown_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(markdown_demo_exe);
const run_markdown_demo = b.addRunArtifact(markdown_demo_exe);
run_markdown_demo.step.dependOn(b.getInstallStep());
const markdown_demo_step = b.step("markdown-demo", "Run markdown viewer demo");
markdown_demo_step.dependOn(&run_markdown_demo.step);
// Ejemplo: help_demo
const help_demo_exe = b.addExecutable(.{
.name = "help-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/help_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(help_demo_exe);
const run_help_demo = b.addRunArtifact(help_demo_exe);
run_help_demo.step.dependOn(b.getInstallStep());
const help_demo_step = b.step("help-demo", "Run help keybindings demo");
help_demo_step.dependOn(&run_help_demo.step);
// Ejemplo: viewport_demo
const viewport_demo_exe = b.addExecutable(.{
.name = "viewport-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/viewport_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(viewport_demo_exe);
const run_viewport_demo = b.addRunArtifact(viewport_demo_exe);
run_viewport_demo.step.dependOn(b.getInstallStep());
const viewport_demo_step = b.step("viewport-demo", "Run scrollable viewport demo");
viewport_demo_step.dependOn(&run_viewport_demo.step);
// Ejemplo: progress_demo
const progress_demo_exe = b.addExecutable(.{
.name = "progress-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/progress_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(progress_demo_exe);
const run_progress_demo = b.addRunArtifact(progress_demo_exe);
run_progress_demo.step.dependOn(b.getInstallStep());
const progress_demo_step = b.step("progress-demo", "Run progress bars demo");
progress_demo_step.dependOn(&run_progress_demo.step);
// Ejemplo: dirtree_demo
const dirtree_demo_exe = b.addExecutable(.{
.name = "dirtree-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/dirtree_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(dirtree_demo_exe);
const run_dirtree_demo = b.addRunArtifact(dirtree_demo_exe);
run_dirtree_demo.step.dependOn(b.getInstallStep());
const dirtree_demo_step = b.step("dirtree-demo", "Run directory tree demo");
dirtree_demo_step.dependOn(&run_dirtree_demo.step);
// Ejemplo: resize_demo
const resize_demo_exe = b.addExecutable(.{
.name = "resize-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/resize_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(resize_demo_exe);
const run_resize_demo = b.addRunArtifact(resize_demo_exe);
run_resize_demo.step.dependOn(b.getInstallStep());
const resize_demo_step = b.step("resize-demo", "Run resize handling demo");
resize_demo_step.dependOn(&run_resize_demo.step);
// Ejemplo: splitter_demo
const splitter_demo_exe = b.addExecutable(.{
.name = "splitter-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/splitter_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(splitter_demo_exe);
const run_splitter_demo = b.addRunArtifact(splitter_demo_exe);
run_splitter_demo.step.dependOn(b.getInstallStep());
const splitter_demo_step = b.step("splitter-demo", "Run resizable splitter demo");
splitter_demo_step.dependOn(&run_splitter_demo.step);
}

16
build.zig.zon Normal file
View file

@ -0,0 +1,16 @@
.{
.name = .zcatui,
.fingerprint = 0x73ad863c554280f3,
.version = "2.2.0",
.minimum_zig_version = "0.14.0",
.dependencies = .{},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
"README.md",
"LICENSE",
},
}

2636
docs/PLAN_V2.2.md Normal file

File diff suppressed because it is too large Load diff

126
examples/dirtree_demo.zig Normal file
View file

@ -0,0 +1,126 @@
//! Directory Tree Demo - File browser widget
//!
//! Run with: zig build dirtree-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const DirectoryTree = zcatui.widgets.DirectoryTree;
// TreeSymbols is in dirtree_mod, not exported directly
const DirTreeSymbols = zcatui.widgets.dirtree_mod.TreeSymbols;
/// State for the demo
const State = struct {
tree: *DirectoryTree,
use_ascii: bool = false,
running: bool = true,
path: []const u8,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
// Get current directory
const start_path = std.fs.cwd().realpathAlloc(allocator, ".") catch "/tmp";
defer allocator.free(start_path);
// Create directory tree
var tree = DirectoryTree.init(allocator, start_path) catch |err| {
std.debug.print("Failed to open directory: {}\n", .{err});
return;
};
defer tree.deinit();
var state = State{
.tree = &tree,
.path = start_path,
};
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
if (c == 'j') state.tree.moveDown();
if (c == 'k') state.tree.moveUp();
if (c == 'l' or c == 'o') state.tree.toggleExpand() catch {};
if (c == 'h') state.tree.collapse() catch {};
if (c == '.') state.tree.toggleHidden() catch {};
if (c == 'i') state.use_ascii = !state.use_ascii;
if (c == 'g') state.tree.goToTop();
if (c == 'G') state.tree.goToBottom();
},
.up => state.tree.moveUp(),
.down => state.tree.moveDown(),
.left => state.tree.collapse() catch {},
.right => state.tree.toggleExpand() catch {},
.enter => state.tree.toggleExpand() catch {},
.home => state.tree.goToTop(),
.end => state.tree.goToBottom(),
.esc => state.running = false,
else => {},
}
},
else => {},
}
}
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Main border
const block = Block.init()
.title(" Directory Tree ")
.setBorders(Borders.all)
.borderStyle((Style{}).fg(Color.cyan));
block.render(area, buf);
// Path display
var path_buf: [256]u8 = undefined;
const current_path = state.tree.getSelectedPath() orelse state.path;
const path_display = std.fmt.bufPrint(&path_buf, " {s} ", .{current_path}) catch "...";
_ = buf.setString(2, 1, path_display, (Style{}).fg(Color.yellow));
// Tree view
const tree_area = Rect.init(1, 2, area.width -| 2, area.height -| 4);
// Configure symbols based on preference
if (state.use_ascii) {
var configured = state.tree.setSymbols(DirTreeSymbols.ascii);
configured.render(tree_area, buf);
} else {
state.tree.render(tree_area, buf);
}
// File info panel
if (state.tree.getSelected()) |node| {
var info_buf: [128]u8 = undefined;
const info = std.fmt.bufPrint(&info_buf, "Selected: {s}", .{node.name}) catch "...";
_ = buf.setString(2, area.height -| 2, info, (Style{}).fg(Color.white));
}
// Footer
const footer = "j/k nav | l expand | h collapse | . hidden | i ascii | g/G top/bottom | q quit";
_ = buf.setString(
2,
area.height -| 1,
footer,
(Style{}).fg(Color.rgb(100, 100, 100)),
);
}

134
examples/help_demo.zig Normal file
View file

@ -0,0 +1,134 @@
//! Help Widget Demo - Shows keybinding help in different modes
//!
//! Run with: zig build help-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Help = zcatui.widgets.Help;
const KeyBinding = zcatui.widgets.KeyBinding;
const HelpMode = zcatui.widgets.HelpMode;
const bindings = [_]KeyBinding{
.{ .key = "q", .description = "Quit", .group = "General" },
.{ .key = "?", .description = "Toggle help", .group = "General" },
.{ .key = "up/k", .description = "Move up", .group = "Navigation" },
.{ .key = "down/j", .description = "Move down", .group = "Navigation" },
.{ .key = "left/h", .description = "Move left", .group = "Navigation" },
.{ .key = "right/l", .description = "Move right", .group = "Navigation" },
.{ .key = "PgUp", .description = "Page up", .group = "Navigation" },
.{ .key = "PgDn", .description = "Page down", .group = "Navigation" },
.{ .key = "Home", .description = "Go to start", .group = "Navigation" },
.{ .key = "End", .description = "Go to end", .group = "Navigation" },
.{ .key = "Enter", .description = "Select item", .group = "Actions" },
.{ .key = "Space", .description = "Toggle selection", .group = "Actions" },
.{ .key = "Tab", .description = "Next panel", .group = "Actions" },
.{ .key = "Ctrl+C", .description = "Copy", .group = "Edit" },
.{ .key = "Ctrl+V", .description = "Paste", .group = "Edit" },
.{ .key = "Ctrl+Z", .description = "Undo", .group = "Edit" },
};
const modes = [_]HelpMode{ .single_line, .compact, .multi_line, .full };
const mode_names = [_][]const u8{ "Single Line", "Compact", "Multi Line", "Full" };
/// State for the demo
const State = struct {
current_mode: usize = 0,
running: bool = true,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = State{};
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
if (c >= '1' and c <= '4') {
state.current_mode = c - '1';
}
},
.left => {
if (state.current_mode > 0) state.current_mode -= 1;
},
.right => {
if (state.current_mode < modes.len - 1) state.current_mode += 1;
},
.esc => state.running = false,
else => {},
}
},
else => {},
}
}
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Main border
const block = Block.init()
.title(" Help Widget Demo ")
.setBorders(Borders.all)
.borderStyle((Style{}).fg(Color.cyan));
block.render(area, buf);
// Mode tabs
var x: u16 = 2;
for (mode_names, 0..) |name, i| {
const style = if (i == state.current_mode)
(Style{}).fg(Color.black).bg(Color.cyan).bold()
else
(Style{}).fg(Color.white);
_ = buf.setString(x, 1, " ", style);
_ = buf.setString(x + 1, 1, name, style);
_ = buf.setString(x + 1 + @as(u16, @intCast(name.len)), 1, " ", style);
x += @as(u16, @intCast(name.len)) + 3;
}
// Help content area
const content_area = Rect.init(1, 3, area.width -| 2, area.height -| 5);
const help_block = Block.init()
.setBorders(Borders.all)
.title(" Keybindings ")
.borderStyle((Style{}).fg(Color.rgb(80, 80, 80)));
help_block.render(content_area, buf);
const inner = Rect.init(2, 4, area.width -| 4, area.height -| 7);
// Help widget
const key_style = (Style{}).fg(Color.yellow).bold();
const help = Help.init(&bindings)
.setMode(modes[state.current_mode])
.setKeyStyle(key_style);
help.render(inner, buf);
// Footer
_ = buf.setString(
2,
area.height -| 1,
"Press 1-4 or left/right to change mode, q to quit",
(Style{}).fg(Color.rgb(100, 100, 100)),
);
}

140
examples/markdown_demo.zig Normal file
View file

@ -0,0 +1,140 @@
//! Markdown Viewer Demo
//!
//! Run with: zig build markdown-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Markdown = zcatui.widgets.Markdown;
const sample_markdown =
\\# Welcome to zcatui
\\
\\A **TUI library** for Zig, inspired by _ratatui_.
\\
\\## Features
\\
\\- 35+ widgets
\\- Event handling (keyboard, mouse)
\\- Animations with easing
\\- Clipboard support (OSC 52)
\\- Syntax highlighting
\\
\\## Code Example
\\
\\```zig
\\const zcatui = @import("zcatui");
\\
\\pub fn main() !void {
\\ var term = try zcatui.Terminal.init(allocator);
\\ defer term.deinit();
\\ // ...
\\}
\\```
\\
\\## Installation
\\
\\Add to your `build.zig.zon`:
\\
\\```zon
\\.dependencies = .{
\\ .zcatui = .{ .url = "..." },
\\},
\\```
\\
\\> **Note**: This is a blockquote with important information
\\> that spans multiple lines.
\\
\\### Links
\\
\\Check out the [documentation](https://git.reugenio.com/reugenio/zcatui).
\\
\\---
\\
\\*Thank you for using zcatui!*
;
/// State for the demo
const State = struct {
scroll_offset: u16 = 0,
running: bool = true,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = State{};
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
if (c == 'j') state.scroll_offset +|= 1;
if (c == 'k' and state.scroll_offset > 0) state.scroll_offset -= 1;
},
.up => {
if (state.scroll_offset > 0) state.scroll_offset -= 1;
},
.down => {
state.scroll_offset +|= 1;
},
.page_up => {
state.scroll_offset -|= 10;
},
.page_down => {
state.scroll_offset +|= 10;
},
.home => state.scroll_offset = 0,
.esc => state.running = false,
else => {},
}
},
else => {},
}
}
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Border
const block = Block.init()
.title(" Markdown Viewer ")
.setBorders(Borders.all)
.borderStyle((Style{}).fg(Color.cyan));
block.render(area, buf);
// Markdown content
const inner = Rect.init(2, 2, area.width -| 4, area.height -| 5);
const md = Markdown.init(sample_markdown)
.setScroll(state.scroll_offset);
md.render(inner, buf);
// Footer with scroll info
var footer_buf: [64]u8 = undefined;
const footer = std.fmt.bufPrint(&footer_buf, "Line {d} | j/k or up/down to scroll, PgUp/PgDn, q to quit", .{state.scroll_offset}) catch "...";
_ = buf.setString(
2,
area.height -| 2,
footer,
(Style{}).fg(Color.rgb(100, 100, 100)),
);
}

178
examples/progress_demo.zig Normal file
View file

@ -0,0 +1,178 @@
//! Progress Bar Demo - Shows progress with ETA
//!
//! Run with: zig build progress-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Task = struct {
name: []const u8,
current: u64,
total: u64,
speed: f64, // Items per frame
color: Color,
};
/// State for the demo
const State = struct {
tasks: [4]Task = .{
.{ .name = "Downloading...", .current = 0, .total = 100, .speed = 0.8, .color = Color.blue },
.{ .name = "Compiling...", .current = 0, .total = 50, .speed = 0.3, .color = Color.green },
.{ .name = "Installing...", .current = 0, .total = 200, .speed = 1.2, .color = Color.yellow },
.{ .name = "Verifying...", .current = 0, .total = 80, .speed = 0.5, .color = Color.magenta },
},
frame: u64 = 0,
paused: bool = false,
running: bool = true,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = State{};
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(50)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
if (c == 'r') {
// Reset all tasks
for (&state.tasks) |*task| {
task.current = 0;
}
state.frame = 0;
}
if (c == 'p' or c == ' ') state.paused = !state.paused;
},
.esc => state.running = false,
else => {},
}
},
else => {},
}
}
// Update progress (simulate work)
if (!state.paused) {
for (&state.tasks) |*task| {
if (task.current < task.total) {
// Add randomness to speed
const variation = @as(f64, @floatFromInt(state.frame % 10)) / 20.0;
const effective_speed = task.speed * (0.8 + variation);
const increment: u64 = @intFromFloat(effective_speed);
task.current = @min(task.current + @max(increment, 1), task.total);
}
}
}
state.frame +%= 1;
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Main border
const status_text = if (state.paused) " Progress Demo [PAUSED] " else " Progress Demo ";
const block = Block.init()
.title(status_text)
.setBorders(Borders.all)
.borderStyle((Style{}).fg(Color.cyan));
block.render(area, buf);
// Calculate total progress
var total_current: u64 = 0;
var total_total: u64 = 0;
for (state.tasks) |task| {
total_current += task.current;
total_total += task.total;
}
// Overall progress at top
const overall_y: u16 = 2;
_ = buf.setString(2, overall_y, "Overall Progress:", (Style{}).fg(Color.white).bold());
const overall_pct = if (total_total > 0) total_current * 100 / total_total else 0;
const overall_bar_width = area.width -| 6;
const overall_filled = @as(u16, @intCast(overall_bar_width * overall_pct / 100));
// Draw overall progress bar
var i: u16 = 0;
while (i < overall_bar_width) : (i += 1) {
const char: []const u8 = if (i < overall_filled) "=" else "-";
const col = if (i < overall_filled) Color.cyan else Color.rgb(60, 60, 60);
_ = buf.setString(2 + i, overall_y + 1, char, (Style{}).fg(col));
}
// Overall percentage
var pct_buf: [16]u8 = undefined;
const pct_str = std.fmt.bufPrint(&pct_buf, " {d}%", .{overall_pct}) catch "?%";
_ = buf.setString(2 + overall_bar_width -| 5, overall_y + 1, pct_str, (Style{}).fg(Color.white).bold());
// Individual task progress
var y: u16 = overall_y + 4;
for (state.tasks) |task| {
if (y >= area.height -| 3) break;
// Task name
_ = buf.setString(2, y, task.name, (Style{}).fg(task.color));
// Progress bar
const bar_width = area.width -| 30;
const pct = if (task.total > 0) task.current * 100 / task.total else 0;
const filled = @as(u16, @intCast(bar_width * pct / 100));
var j: u16 = 0;
while (j < bar_width) : (j += 1) {
const char: []const u8 = if (j < filled) "=" else "-";
const col = if (j < filled) task.color else Color.rgb(60, 60, 60);
_ = buf.setString(18 + j, y, char, (Style{}).fg(col));
}
// Stats
var stats_buf: [32]u8 = undefined;
const stats = std.fmt.bufPrint(&stats_buf, "{d}/{d} ({d}%)", .{
task.current,
task.total,
pct,
}) catch "...";
_ = buf.setString(area.width -| 18, y, stats, (Style{}).fg(Color.white));
y += 2;
}
// Completion message
if (total_current >= total_total) {
const msg = "All tasks completed!";
const msg_x = (area.width -| @as(u16, @intCast(msg.len))) / 2;
_ = buf.setString(msg_x, y + 1, msg, (Style{}).fg(Color.green).bold());
}
// Footer
const help = if (state.paused)
"SPACE/p resume | r restart | q quit"
else
"SPACE/p pause | r restart | q quit";
_ = buf.setString(
2,
area.height -| 1,
help,
(Style{}).fg(Color.rgb(100, 100, 100)),
);
}

159
examples/resize_demo.zig Normal file
View file

@ -0,0 +1,159 @@
//! Terminal Resize Demo
//!
//! Demonstrates automatic terminal resize handling.
//! Try resizing your terminal window to see the app adapt.
//!
//! Run with: zig build resize-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
/// State for the demo
const State = struct {
resize_count: u32 = 0,
last_width: u16 = 0,
last_height: u16 = 0,
running: bool = true,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
// Enable automatic resize detection
term.enableAutoResize();
// Get initial size
const initial_size = term.getSize();
var state = State{
.last_width = initial_size.width,
.last_height = initial_size.height,
};
while (state.running) {
// Check if size changed
const current_area = term.area();
if (current_area.width != state.last_width or current_area.height != state.last_height) {
state.resize_count += 1;
state.last_width = current_area.width;
state.last_height = current_area.height;
}
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
},
.esc => state.running = false,
else => {},
}
},
else => {},
}
}
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Main border
const block = Block.init()
.title(" Resize Demo ")
.setBorders(Borders.all)
.borderStyle((Style{}).fg(Color.cyan));
block.render(area, buf);
// Content
const content_start_y: u16 = 3;
const content_x: u16 = 4;
// Title
_ = buf.setString(
content_x,
content_start_y,
"Resize your terminal to see this demo adapt!",
(Style{}).fg(Color.yellow).bold(),
);
// Current size
var size_buf: [64]u8 = undefined;
const size_str = std.fmt.bufPrint(&size_buf, "Current size: {d} x {d}", .{
area.width,
area.height,
}) catch "?";
_ = buf.setString(content_x, content_start_y + 2, size_str, (Style{}).fg(Color.white));
// Resize count
var count_buf: [64]u8 = undefined;
const count_str = std.fmt.bufPrint(&count_buf, "Resize events: {d}", .{state.resize_count}) catch "?";
_ = buf.setString(content_x, content_start_y + 3, count_str, (Style{}).fg(Color.white));
// Draw a visual indicator based on size
const indicator_y = content_start_y + 6;
_ = buf.setString(content_x, indicator_y, "Size indicator:", (Style{}).fg(Color.magenta));
// Draw a bar that scales with width
const bar_width = @min(area.width -| 10, 50);
var i: u16 = 0;
while (i < bar_width) : (i += 1) {
const progress = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(bar_width));
const color = if (progress < 0.33)
Color.red
else if (progress < 0.66)
Color.yellow
else
Color.green;
_ = buf.setString(content_x + i, indicator_y + 1, "=", (Style{}).fg(color));
}
// Draw a visual box that scales with size
if (area.height > 15 and area.width > 30) {
const box_y = indicator_y + 4;
const box_width = @min(area.width -| 10, 40);
const box_height = @min(area.height -| box_y -| 3, 10);
// Draw box
var y: u16 = 0;
while (y < box_height) : (y += 1) {
var x: u16 = 0;
while (x < box_width) : (x += 1) {
const is_border = y == 0 or y == box_height - 1 or x == 0 or x == box_width - 1;
const char: []const u8 = if (is_border) "#" else " ";
const style = if (is_border)
(Style{}).fg(Color.blue)
else
Style{};
_ = buf.setString(content_x + x, box_y + y, char, style);
}
}
// Label inside box
var box_info: [32]u8 = undefined;
const box_str = std.fmt.bufPrint(&box_info, "{d}x{d}", .{ box_width, box_height }) catch "?";
const label_x = content_x + (box_width / 2) - @as(u16, @intCast(box_str.len / 2));
_ = buf.setString(label_x, box_y + box_height / 2, box_str, (Style{}).fg(Color.cyan));
}
// Footer
_ = buf.setString(
2,
area.height -| 1,
"Press q to quit | Resize terminal to test",
(Style{}).fg(Color.rgb(100, 100, 100)),
);
}

151
examples/spinner_demo.zig Normal file
View file

@ -0,0 +1,151 @@
//! Spinner Demo - Shows all spinner styles
//!
//! Run with: zig build spinner-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Spinner = zcatui.widgets.Spinner;
const SpinnerStyle = zcatui.widgets.SpinnerStyle;
const styles = [_]SpinnerStyle{
.dots,
.dots_braille,
.line,
.arrows,
.box_corners,
.circle,
.blocks,
};
const style_names = [_][]const u8{
"Dots",
"Braille",
"Line",
"Arrows",
"Box",
"Circle",
"Blocks",
};
const messages = [_][]const u8{
"Loading...",
"Processing...",
"Compiling...",
"Connecting...",
"Syncing...",
"Waiting...",
"Downloading...",
};
/// State for the demo
const State = struct {
frame: u64 = 0,
selected_style: usize = 0,
running: bool = true,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = State{};
// Main loop
while (state.running) {
try term.drawWithContext(&state, render);
// Poll events (non-blocking)
if (try term.pollEvent(50)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
// Number keys 1-7 to select style
if (c >= '1' and c <= '7') {
state.selected_style = c - '1';
}
},
.up => {
if (state.selected_style > 0) state.selected_style -= 1;
},
.down => {
if (state.selected_style < styles.len - 1) state.selected_style += 1;
},
.esc => state.running = false,
else => {},
}
},
else => {},
}
}
state.frame +%= 1;
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Draw border
const block = Block.init()
.title(" Spinner Styles ")
.setBorders(Borders.all)
.borderStyle((Style{}).fg(Color.cyan));
block.render(area, buf);
const inner = Rect.init(2, 2, area.width -| 4, area.height -| 4);
// Draw all spinners
var y: u16 = 0;
for (styles, 0..) |style, i| {
if (y >= inner.height -| 2) break;
const is_selected = i == state.selected_style;
const row_style = if (is_selected)
(Style{}).fg(Color.yellow).bold()
else
Style{};
// Style name
var name_buf: [32]u8 = undefined;
const name = std.fmt.bufPrint(&name_buf, "{s:<10}", .{style_names[i]}) catch "???";
_ = buf.setString(inner.x, inner.y + y, name, row_style);
// Spinner
var spinner = Spinner.init(style)
.setLabel(messages[i]);
// Tick based on frame
var tick_count: u64 = 0;
while (tick_count < state.frame) : (tick_count += 1) {
spinner.tick();
}
spinner.render(
Rect.init(inner.x + 12, inner.y + y, inner.width -| 12, 1),
buf,
);
y += 2;
}
// Instructions at bottom
const help_y = area.height -| 2;
_ = buf.setString(
2,
help_y,
"Press 1-7 to select style, up/down to navigate, q to quit",
(Style{}).fg(Color.rgb(100, 100, 100)),
);
}

200
examples/splitter_demo.zig Normal file
View file

@ -0,0 +1,200 @@
//! Splitter Demo - Resizable panels with mouse drag
//!
//! Demonstrates mouse drag to resize panels.
//! Run with: zig build splitter-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const DragState = zcatui.DragState;
const DragType = zcatui.DragType;
const Splitter = zcatui.Splitter;
/// State for the demo
const State = struct {
// Horizontal splitter (splits left/right)
h_splitter: Splitter = Splitter.horizontal(30).setMinSizes(10, 20),
// Vertical splitter for right side (splits top/bottom)
v_splitter: Splitter = Splitter.vertical(50).setMinSizes(5, 5),
// Drag state
drag_state: DragState = .{},
// Which splitter is being dragged
active_splitter: ActiveSplitter = .none,
// Current area for hit testing
current_area: Rect = Rect.init(0, 0, 80, 24),
running: bool = true,
};
const ActiveSplitter = enum { none, horizontal, vertical };
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
// Enable mouse capture for drag support
try term.enableMouseCapture();
term.enableAutoResize();
var state = State{};
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(50)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
// Keyboard shortcuts to adjust splitters
if (c == 'h' or c == 'H') {
const delta: i32 = if (c == 'H') -5 else 5;
state.h_splitter.adjustPosition(state.current_area, delta);
}
if (c == 'v' or c == 'V') {
const parts = state.h_splitter.split(state.current_area);
const delta: i32 = if (c == 'V') -5 else 5;
state.v_splitter.adjustPosition(parts.second, delta);
}
},
.esc => state.running = false,
else => {},
}
},
.mouse => |mouse| {
handleMouse(&state, mouse);
},
else => {},
}
}
}
}
fn handleMouse(state: *State, mouse: zcatui.event.MouseEvent) void {
switch (mouse.kind) {
.down => {
if (mouse.button == .left) {
// Check if on horizontal splitter
if (state.h_splitter.isOnHandle(state.current_area, mouse.column, mouse.row)) {
state.drag_state.start(.horizontal_resize, mouse.column, mouse.row);
state.active_splitter = .horizontal;
} else {
// Check if on vertical splitter (in second panel)
const parts = state.h_splitter.split(state.current_area);
if (state.v_splitter.isOnHandle(parts.second, mouse.column, mouse.row)) {
state.drag_state.start(.vertical_resize, mouse.column, mouse.row);
state.active_splitter = .vertical;
}
}
}
},
.drag => {
if (state.drag_state.isDragging()) {
const old_x = state.drag_state.current_x;
const old_y = state.drag_state.current_y;
state.drag_state.update(mouse.column, mouse.row);
// Apply the delta to the active splitter
switch (state.active_splitter) {
.horizontal => {
const delta = @as(i32, mouse.column) - @as(i32, old_x);
if (delta != 0) {
state.h_splitter.adjustPosition(state.current_area, delta);
}
},
.vertical => {
const parts = state.h_splitter.split(state.current_area);
const delta = @as(i32, mouse.row) - @as(i32, old_y);
if (delta != 0) {
state.v_splitter.adjustPosition(parts.second, delta);
}
},
.none => {},
}
}
},
.up => {
state.drag_state.end();
state.active_splitter = .none;
},
else => {},
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Store area for mouse hit testing
state.current_area = area;
// Get the split areas
const h_parts = state.h_splitter.split(area);
const v_parts = state.v_splitter.split(h_parts.second);
// Draw left panel
drawPanel(buf, h_parts.first, " Left Panel ", Color.blue, "This is the left panel.\n\nDrag the vertical bar to resize.\n\nOr press h/H to adjust.");
// Draw top-right panel
drawPanel(buf, v_parts.first, " Top Right ", Color.green, "This is the top-right panel.\n\nDrag the horizontal bar to resize.\n\nOr press v/V to adjust.");
// Draw bottom-right panel
drawPanel(buf, v_parts.second, " Bottom Right ", Color.yellow, "This is the bottom-right panel.\n\nTry dragging both splitters!");
// Draw splitter handles
drawSplitter(buf, h_parts.handle, true, state.active_splitter == .horizontal);
drawSplitter(buf, v_parts.handle, false, state.active_splitter == .vertical);
// Draw status bar
var status_buf: [128]u8 = undefined;
const status = std.fmt.bufPrint(&status_buf, "H-split: {d}% | V-split: {d}% | {s}", .{
state.h_splitter.position,
state.v_splitter.position,
if (state.drag_state.isDragging()) "Dragging..." else "Drag splitters or press h/H v/V | q to quit",
}) catch "...";
_ = buf.setString(0, area.height -| 1, status, (Style{}).fg(Color.white).bg(Color.rgb(40, 40, 40)));
}
fn drawPanel(buf: *Buffer, area: Rect, title: []const u8, color: Color, content: []const u8) void {
const block = Block.init()
.title(title)
.setBorders(Borders.all)
.borderStyle((Style{}).fg(color));
block.render(area, buf);
// Draw content
const inner = Rect.init(area.x + 1, area.y + 1, area.width -| 2, area.height -| 2);
var y: u16 = 0;
var lines = std.mem.splitScalar(u8, content, '\n');
while (lines.next()) |line| {
if (y >= inner.height) break;
const max_len = @min(line.len, inner.width);
_ = buf.setString(inner.x, inner.y + y, line[0..max_len], Style{});
y += 1;
}
}
fn drawSplitter(buf: *Buffer, handle: Rect, is_vertical: bool, is_active: bool) void {
const color = if (is_active) Color.cyan else Color.rgb(100, 100, 100);
const char: []const u8 = if (is_vertical) "|" else "-";
if (is_vertical) {
var y: u16 = 0;
while (y < handle.height) : (y += 1) {
_ = buf.setString(handle.x, handle.y + y, char, (Style{}).fg(color));
}
} else {
var x: u16 = 0;
while (x < handle.width) : (x += 1) {
_ = buf.setString(handle.x + x, handle.y, char, (Style{}).fg(color));
}
}
}

176
examples/syntax_demo.zig Normal file
View file

@ -0,0 +1,176 @@
//! Syntax Highlighting Demo
//!
//! Run with: zig build syntax-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const SyntaxHighlighter = zcatui.widgets.SyntaxHighlighter;
const SyntaxLanguage = zcatui.widgets.SyntaxLanguage;
const zig_code =
\\const std = @import("std");
\\
\\pub fn main() !void {
\\ const allocator = std.heap.page_allocator;
\\ var list = std.ArrayList(u32).init(allocator);
\\ defer list.deinit();
\\
\\ try list.append(42);
\\ try list.append(100);
\\
\\ for (list.items) |item| {
\\ std.debug.print("{d}\n", .{item});
\\ }
\\}
;
const rust_code =
\\use std::collections::HashMap;
\\
\\fn main() {
\\ let mut map = HashMap::new();
\\ map.insert("key", "value");
\\
\\ if let Some(val) = map.get("key") {
\\ println!("Found: {}", val);
\\ }
\\
\\ // Iterate over map
\\ for (k, v) in &map {
\\ println!("{}: {}", k, v);
\\ }
\\}
;
const python_code =
\\import json
\\from typing import List, Dict
\\
\\def process_data(items: List[str]) -> Dict:
\\ """Process a list of items."""
\\ result = {}
\\ for i, item in enumerate(items):
\\ result[f"item_{i}"] = item.upper()
\\ return result
\\
\\if __name__ == "__main__":
\\ data = ["hello", "world"]
\\ print(json.dumps(process_data(data)))
;
const js_code =
\\const express = require('express');
\\const app = express();
\\
\\// Middleware
\\app.use(express.json());
\\
\\app.get('/api/users', async (req, res) => {
\\ try {
\\ const users = await fetchUsers();
\\ res.json({ success: true, data: users });
\\ } catch (err) {
\\ res.status(500).json({ error: err.message });
\\ }
\\});
\\
\\app.listen(3000, () => console.log('Server running'));
;
const languages = [_]SyntaxLanguage{ .zig, .rust, .python, .javascript };
const lang_names = [_][]const u8{ "Zig", "Rust", "Python", "JavaScript" };
const codes = [_][]const u8{ zig_code, rust_code, python_code, js_code };
/// State for the demo
const State = struct {
selected: usize = 0,
running: bool = true,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = State{};
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
if (c >= '1' and c <= '4') {
state.selected = c - '1';
}
},
.left => {
if (state.selected > 0) state.selected -= 1;
},
.right => {
if (state.selected < languages.len - 1) state.selected += 1;
},
.esc => state.running = false,
else => {},
}
},
else => {},
}
}
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Header with language tabs
var x: u16 = 2;
for (lang_names, 0..) |name, i| {
const style = if (i == state.selected)
(Style{}).fg(Color.black).bg(Color.cyan).bold()
else
(Style{}).fg(Color.white);
_ = buf.setString(x, 0, " ", style);
_ = buf.setString(x + 1, 0, name, style);
_ = buf.setString(x + 1 + @as(u16, @intCast(name.len)), 0, " ", style);
x += @as(u16, @intCast(name.len)) + 3;
}
// Code area
const code_area = Rect.init(0, 2, area.width, area.height -| 4);
const block = Block.init()
.title(" Code ")
.setBorders(Borders.all)
.borderStyle((Style{}).fg(Color.rgb(80, 80, 80)));
block.render(code_area, buf);
const inner = Rect.init(1, 3, area.width -| 2, area.height -| 6);
// Syntax highlighter
const highlighter = SyntaxHighlighter.init(languages[state.selected])
.setLineNumbers(true);
highlighter.render(codes[state.selected], 0, inner, buf);
// Footer
_ = buf.setString(
2,
area.height -| 1,
"Press 1-4 or left/right to change language, q to quit",
(Style{}).fg(Color.rgb(100, 100, 100)),
);
}

176
examples/viewport_demo.zig Normal file
View file

@ -0,0 +1,176 @@
//! Viewport Demo - Shows scrollable content
//!
//! Run with: zig build viewport-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Rect = zcatui.Rect;
const Buffer = zcatui.Buffer;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
// Sample content - pre-generated long text
const sample_content =
\\ 1 | === Welcome to the Viewport Demo ===
\\ 2 | This demonstrates scrollable content.
\\ 3 | Use j/k or arrows to scroll.
\\ 4 |
\\ 5 | Lorem ipsum dolor sit amet, line 5.
\\ 6 | Lorem ipsum dolor sit amet, line 6.
\\ 7 | Lorem ipsum dolor sit amet, line 7.
\\ 8 | Lorem ipsum dolor sit amet, line 8.
\\ 9 | Lorem ipsum dolor sit amet, line 9.
\\ 10 | === Section 1 ===
\\ 11 | Lorem ipsum dolor sit amet, line 11.
\\ 12 | Lorem ipsum dolor sit amet, line 12.
\\ 13 | Lorem ipsum dolor sit amet, line 13.
\\ 14 | Lorem ipsum dolor sit amet, line 14.
\\ 15 | --- subsection ---
\\ 16 | Lorem ipsum dolor sit amet, line 16.
\\ 17 | Lorem ipsum dolor sit amet, line 17.
\\ 18 | Lorem ipsum dolor sit amet, line 18.
\\ 19 | Lorem ipsum dolor sit amet, line 19.
\\ 20 | === Section 2 ===
\\ 21 | Lorem ipsum dolor sit amet, line 21.
\\ 22 | Lorem ipsum dolor sit amet, line 22.
\\ 23 | Lorem ipsum dolor sit amet, line 23.
\\ 24 | Lorem ipsum dolor sit amet, line 24.
\\ 25 | --- subsection ---
\\ 26 | Lorem ipsum dolor sit amet, line 26.
\\ 27 | Lorem ipsum dolor sit amet, line 27.
\\ 28 | Lorem ipsum dolor sit amet, line 28.
\\ 29 | Lorem ipsum dolor sit amet, line 29.
\\ 30 | === Section 3 ===
\\ 31 | Lorem ipsum dolor sit amet, line 31.
\\ 32 | Lorem ipsum dolor sit amet, line 32.
\\ 33 | Lorem ipsum dolor sit amet, line 33.
\\ 34 | Lorem ipsum dolor sit amet, line 34.
\\ 35 | --- subsection ---
\\ 36 | Lorem ipsum dolor sit amet, line 36.
\\ 37 | Lorem ipsum dolor sit amet, line 37.
\\ 38 | Lorem ipsum dolor sit amet, line 38.
\\ 39 | Lorem ipsum dolor sit amet, line 39.
\\ 40 | === Section 4 ===
\\ 41 | Lorem ipsum dolor sit amet, line 41.
\\ 42 | Lorem ipsum dolor sit amet, line 42.
\\ 43 | Lorem ipsum dolor sit amet, line 43.
\\ 44 | Lorem ipsum dolor sit amet, line 44.
\\ 45 | --- subsection ---
\\ 46 | Lorem ipsum dolor sit amet, line 46.
\\ 47 | Lorem ipsum dolor sit amet, line 47.
\\ 48 | Lorem ipsum dolor sit amet, line 48.
\\ 49 | Lorem ipsum dolor sit amet, line 49.
\\ 50 | === End of content ===
;
/// State for the demo
const State = struct {
offset_y: u16 = 0,
content_height: u16 = 50,
running: bool = true,
view_height: u16 = 20,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
// Enable mouse for scroll wheel
try term.enableMouseCapture();
var state = State{};
while (state.running) {
state.view_height = term.area().height -| 4;
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
switch (event) {
.key => |key| {
switch (key.code) {
.char => |c| {
if (c == 'q') state.running = false;
if (c == 'j') state.offset_y +|= 1;
if (c == 'k' and state.offset_y > 0) state.offset_y -= 1;
if (c == 'g') state.offset_y = 0;
if (c == 'G') state.offset_y = state.content_height -| state.view_height;
},
.up => if (state.offset_y > 0) {
state.offset_y -= 1;
},
.down => state.offset_y +|= 1,
.page_up => state.offset_y -|= state.view_height,
.page_down => state.offset_y +|= state.view_height,
.home => state.offset_y = 0,
.end => state.offset_y = state.content_height -| state.view_height,
.esc => state.running = false,
else => {},
}
},
.mouse => |mouse| {
switch (mouse.kind) {
.scroll_up => if (state.offset_y >= 3) {
state.offset_y -= 3;
},
.scroll_down => state.offset_y +|= 3,
else => {},
}
},
else => {},
}
}
}
}
fn render(state: *State, area: Rect, buf: *Buffer) void {
// Main border
const block = Block.init()
.title(" Viewport Demo ")
.setBorders(Borders.all)
.borderStyle((Style{}).fg(Color.cyan));
block.render(area, buf);
// Content area
const content_area = Rect.init(1, 1, area.width -| 2, area.height -| 3);
// Render visible content manually
var lines = std.mem.splitScalar(u8, sample_content, '\n');
var line_num: u16 = 0;
var y: u16 = 0;
while (lines.next()) |line| {
if (line_num >= state.offset_y and y < content_area.height) {
_ = buf.setString(content_area.x, content_area.y + y, line, Style{});
y += 1;
}
line_num += 1;
}
// Scrollbar indicator
const scroll_pct: u16 = if (state.content_height > state.view_height)
@min(state.offset_y * 100 / (state.content_height -| state.view_height), 100)
else
0;
// Footer with scroll info
var footer_buf: [128]u8 = undefined;
const footer = std.fmt.bufPrint(&footer_buf, "Line {d}/{d} ({d}%) | j/k up/down scroll, g/G top/bottom, PgUp/PgDn, q quit", .{
state.offset_y + 1,
state.content_height,
scroll_pct,
}) catch "...";
_ = buf.setString(
2,
area.height -| 1,
footer,
(Style{}).fg(Color.rgb(100, 100, 100)),
);
}

347
src/async_loop.zig Normal file
View file

@ -0,0 +1,347 @@
//! Async event loop for zcatui.
//!
//! Provides efficient async I/O using epoll on Linux.
//! This allows handling multiple input sources (stdin, timers, signals)
//! with a single event loop.
//!
//! ## Features
//!
//! - Epoll-based event multiplexing
//! - Timer support for animations
//! - Signal handling integration
//! - Non-blocking I/O
//!
//! ## Example
//!
//! ```zig
//! var loop = try AsyncLoop.init();
//! defer loop.deinit();
//!
//! // Add stdin for terminal input
//! try loop.addStdin();
//!
//! // Add a periodic timer
//! const timer_id = try loop.addTimer(100); // 100ms
//!
//! while (running) {
//! const events = try loop.wait(1000); // 1 second timeout
//! for (events) |event| {
//! switch (event.source) {
//! .stdin => handleInput(),
//! .timer => |id| if (id == timer_id) animate(),
//! .signal => |sig| handleSignal(sig),
//! }
//! }
//! }
//! ```
const std = @import("std");
/// Maximum number of events to process per wait.
const MAX_EVENTS = 32;
/// Event source types.
pub const EventSource = union(enum) {
/// Standard input (terminal).
stdin,
/// Timer with ID.
timer: u32,
/// Signal received.
signal: u8,
/// Custom file descriptor.
fd: std.posix.fd_t,
};
/// An async event.
pub const AsyncEvent = struct {
source: EventSource,
/// Whether the source is readable.
readable: bool = false,
/// Whether the source is writable.
writable: bool = false,
/// Whether an error occurred.
err: bool = false,
/// Whether hangup occurred.
hup: bool = false,
};
/// Timer entry.
const TimerEntry = struct {
id: u32,
fd: std.posix.fd_t,
interval_ms: u64,
repeating: bool,
};
/// Async event loop using epoll.
pub const AsyncLoop = struct {
epoll_fd: std.posix.fd_t,
events: [MAX_EVENTS]std.os.linux.epoll_event = undefined,
timers: std.ArrayList(TimerEntry),
next_timer_id: u32 = 1,
stdin_added: bool = false,
allocator: std.mem.Allocator,
/// Creates a new async event loop.
pub fn init(allocator: std.mem.Allocator) !AsyncLoop {
const epoll_fd = try std.posix.epoll_create1(.{ .CLOEXEC = true });
return .{
.epoll_fd = epoll_fd,
.timers = std.ArrayList(TimerEntry).init(allocator),
.allocator = allocator,
};
}
/// Cleans up the event loop.
pub fn deinit(self: *AsyncLoop) void {
// Close timer fds
for (self.timers.items) |timer| {
std.posix.close(timer.fd);
}
self.timers.deinit();
std.posix.close(self.epoll_fd);
}
/// Adds stdin to the event loop.
pub fn addStdin(self: *AsyncLoop) !void {
if (self.stdin_added) return;
var ev = std.os.linux.epoll_event{
.events = std.os.linux.EPOLL.IN,
.data = .{ .fd = std.posix.STDIN_FILENO },
};
try std.posix.epoll_ctl(
self.epoll_fd,
.ADD,
std.posix.STDIN_FILENO,
&ev,
);
self.stdin_added = true;
}
/// Adds a timer to the event loop.
pub fn addTimer(self: *AsyncLoop, interval_ms: u64) !u32 {
return self.addTimerEx(interval_ms, true);
}
/// Adds a one-shot or repeating timer.
pub fn addTimerEx(self: *AsyncLoop, interval_ms: u64, repeating: bool) !u32 {
// Create timerfd
const timer_fd = std.posix.timerfd_create(.MONOTONIC, .{
.CLOEXEC = true,
.NONBLOCK = true,
}) catch |err| {
// Fallback if timerfd not available
_ = err;
return error.TimerUnavailable;
};
errdefer std.posix.close(timer_fd);
// Set timer interval
const interval_ns = interval_ms * 1_000_000;
const interval_sec = interval_ns / 1_000_000_000;
const interval_nsec = interval_ns % 1_000_000_000;
var spec = std.os.linux.itimerspec{
.it_interval = .{
.sec = if (repeating) @intCast(interval_sec) else 0,
.nsec = if (repeating) @intCast(interval_nsec) else 0,
},
.it_value = .{
.sec = @intCast(interval_sec),
.nsec = @intCast(interval_nsec),
},
};
_ = std.os.linux.timerfd_settime(timer_fd, .{}, &spec, null);
// Add to epoll
const timer_id = self.next_timer_id;
self.next_timer_id += 1;
var ev = std.os.linux.epoll_event{
.events = std.os.linux.EPOLL.IN,
.data = .{ .fd = timer_fd },
};
try std.posix.epoll_ctl(self.epoll_fd, .ADD, timer_fd, &ev);
try self.timers.append(.{
.id = timer_id,
.fd = timer_fd,
.interval_ms = interval_ms,
.repeating = repeating,
});
return timer_id;
}
/// Removes a timer.
pub fn removeTimer(self: *AsyncLoop, timer_id: u32) void {
for (self.timers.items, 0..) |timer, i| {
if (timer.id == timer_id) {
std.posix.epoll_ctl(self.epoll_fd, .DEL, timer.fd, null) catch {};
std.posix.close(timer.fd);
_ = self.timers.swapRemove(i);
return;
}
}
}
/// Adds a custom file descriptor.
pub fn addFd(self: *AsyncLoop, fd: std.posix.fd_t, readable: bool, writable: bool) !void {
var events: u32 = 0;
if (readable) events |= std.os.linux.EPOLL.IN;
if (writable) events |= std.os.linux.EPOLL.OUT;
var ev = std.os.linux.epoll_event{
.events = events,
.data = .{ .fd = fd },
};
try std.posix.epoll_ctl(self.epoll_fd, .ADD, fd, &ev);
}
/// Removes a file descriptor.
pub fn removeFd(self: *AsyncLoop, fd: std.posix.fd_t) void {
std.posix.epoll_ctl(self.epoll_fd, .DEL, fd, null) catch {};
}
/// Waits for events.
pub fn wait(self: *AsyncLoop, timeout_ms: ?u32) ![]AsyncEvent {
const timeout: i32 = if (timeout_ms) |t| @intCast(t) else -1;
const n = std.posix.epoll_wait(
self.epoll_fd,
&self.events,
timeout,
);
// Convert to AsyncEvents
var result: [MAX_EVENTS]AsyncEvent = undefined;
var count: usize = 0;
for (self.events[0..n]) |ev| {
const fd = ev.data.fd;
// Determine source
const source: EventSource = blk: {
if (fd == std.posix.STDIN_FILENO) {
break :blk .stdin;
}
// Check timers
for (self.timers.items) |timer| {
if (timer.fd == fd) {
// Read to clear the timer
var buf: [8]u8 = undefined;
_ = std.posix.read(fd, &buf) catch {};
break :blk .{ .timer = timer.id };
}
}
break :blk .{ .fd = fd };
};
result[count] = .{
.source = source,
.readable = (ev.events & std.os.linux.EPOLL.IN) != 0,
.writable = (ev.events & std.os.linux.EPOLL.OUT) != 0,
.err = (ev.events & std.os.linux.EPOLL.ERR) != 0,
.hup = (ev.events & std.os.linux.EPOLL.HUP) != 0,
};
count += 1;
}
return result[0..count];
}
/// Waits for a single event with a callback approach.
pub fn poll(self: *AsyncLoop, timeout_ms: ?u32) !?AsyncEvent {
const events = try self.wait(timeout_ms);
if (events.len > 0) {
return events[0];
}
return null;
}
};
/// Simple ticker that fires at regular intervals.
pub const Ticker = struct {
loop: *AsyncLoop,
timer_id: u32,
interval_ms: u64,
pub fn init(loop: *AsyncLoop, interval_ms: u64) !Ticker {
const timer_id = try loop.addTimer(interval_ms);
return .{
.loop = loop,
.timer_id = timer_id,
.interval_ms = interval_ms,
};
}
pub fn deinit(self: *Ticker) void {
self.loop.removeTimer(self.timer_id);
}
};
// ============================================================================
// Tests
// ============================================================================
test "AsyncLoop creation" {
const allocator = std.testing.allocator;
var loop = try AsyncLoop.init(allocator);
defer loop.deinit();
try std.testing.expect(loop.epoll_fd >= 0);
}
test "AsyncLoop addStdin" {
const allocator = std.testing.allocator;
var loop = try AsyncLoop.init(allocator);
defer loop.deinit();
try loop.addStdin();
try std.testing.expect(loop.stdin_added);
// Adding again should be no-op
try loop.addStdin();
}
test "AsyncLoop timer" {
const allocator = std.testing.allocator;
var loop = try AsyncLoop.init(allocator);
defer loop.deinit();
const timer_id = loop.addTimer(100) catch |err| {
// Skip if timerfd not available
if (err == error.TimerUnavailable) return;
return err;
};
try std.testing.expect(timer_id > 0);
try std.testing.expectEqual(@as(usize, 1), loop.timers.items.len);
loop.removeTimer(timer_id);
try std.testing.expectEqual(@as(usize, 0), loop.timers.items.len);
}
test "EventSource union" {
const stdin: EventSource = .stdin;
const timer: EventSource = .{ .timer = 42 };
switch (stdin) {
.stdin => {},
else => unreachable,
}
switch (timer) {
.timer => |id| try std.testing.expectEqual(@as(u32, 42), id),
else => unreachable,
}
}

611
src/compose.zig Normal file
View file

@ -0,0 +1,611 @@
//! Ergonomic Widget Composition
//!
//! Provides a declarative, fluent API for composing complex layouts
//! from simpler widgets. Similar to SwiftUI/Flutter patterns.
//!
//! ## Example
//!
//! ```zig
//! const compose = @import("compose.zig");
//!
//! // Simple vertical stack
//! compose.vstack(buf, area, .{
//! compose.sized(header_widget, 3),
//! compose.flex(content_widget, 1),
//! compose.sized(footer_widget, 1),
//! });
//!
//! // Horizontal with spacing
//! compose.hstack(buf, area, .{
//! .spacing = 1,
//! .children = .{
//! compose.sized(sidebar, 20),
//! compose.flex(main_content, 1),
//! },
//! });
//!
//! // Nested composition
//! compose.vstack(buf, area, .{
//! compose.sized(header, 3),
//! compose.hstack_flex(.{
//! compose.sized(nav, 15),
//! compose.flex(content, 1),
//! }),
//! });
//! ```
const std = @import("std");
const Buffer = @import("buffer.zig").Buffer;
const Rect = @import("buffer.zig").Rect;
const Style = @import("style.zig").Style;
const Color = @import("style.zig").Color;
const Layout = @import("layout.zig").Layout;
const Constraint = @import("layout.zig").Constraint;
const Flex = @import("layout.zig").Flex;
// ============================================================================
// Renderable Interface
// ============================================================================
/// A renderable widget or composition
pub const Renderable = struct {
ptr: *const anyopaque,
renderFn: *const fn (*const anyopaque, Rect, *Buffer) void,
pub fn render(self: Renderable, area: Rect, buf: *Buffer) void {
self.renderFn(self.ptr, area, buf);
}
/// Create from any type with a render method
pub fn from(widget: anytype) Renderable {
const T = @TypeOf(widget);
const ptr_info = @typeInfo(T);
if (ptr_info == .pointer) {
const ChildType = ptr_info.pointer.child;
return .{
.ptr = @ptrCast(widget),
.renderFn = struct {
fn render(p: *const anyopaque, area: Rect, buf: *Buffer) void {
const w: *const ChildType = @ptrCast(@alignCast(p));
w.render(area, buf);
}
}.render,
};
} else {
// For value types, we'd need to store a copy
// This is a limitation - prefer pointers
@compileError("Renderable.from requires a pointer type");
}
}
};
// ============================================================================
// Child Wrapper
// ============================================================================
/// A child widget with its sizing constraint
pub const Child = struct {
/// The widget to render
widget: Renderable,
/// Size constraint
constraint: Constraint,
/// Render this child in the given area
pub fn render(self: Child, area: Rect, buf: *Buffer) void {
self.widget.render(area, buf);
}
};
/// Create a child with fixed size
pub fn sized(widget: anytype, size: u16) Child {
return .{
.widget = makeRenderable(widget),
.constraint = Constraint.length(size),
};
}
/// Create a child that flexes to fill space
pub fn flex(widget: anytype, factor: u16) Child {
return .{
.widget = makeRenderable(widget),
.constraint = Constraint.ratio(factor, 1),
};
}
/// Create a child that fills all remaining space
pub fn fill(widget: anytype) Child {
return .{
.widget = makeRenderable(widget),
.constraint = Constraint.fill(),
};
}
/// Create a child with minimum size
pub fn minSize(widget: anytype, min: u16) Child {
return .{
.widget = makeRenderable(widget),
.constraint = Constraint.min(min),
};
}
/// Create a child with percentage size
pub fn percent(widget: anytype, pct: u16) Child {
return .{
.widget = makeRenderable(widget),
.constraint = Constraint.percentage(pct),
};
}
/// Create a child with ratio size
pub fn ratio(widget: anytype, num: u32, den: u32) Child {
return .{
.widget = makeRenderable(widget),
.constraint = Constraint.ratio(num, den),
};
}
// ============================================================================
// Stack Layouts
// ============================================================================
/// Vertical stack options
pub const VStackOptions = struct {
spacing: u16 = 0,
margin: u16 = 0,
alignment: Alignment = .stretch,
pub const Alignment = enum { start, center, end, stretch };
};
/// Horizontal stack options
pub const HStackOptions = struct {
spacing: u16 = 0,
margin: u16 = 0,
alignment: Alignment = .stretch,
pub const Alignment = enum { start, center, end, stretch };
};
/// Render a vertical stack of widgets
pub fn vstack(buf: *Buffer, area: Rect, children: anytype) void {
vstackOpts(buf, area, .{}, children);
}
/// Render a vertical stack with options
pub fn vstackOpts(buf: *Buffer, area: Rect, opts: VStackOptions, children: anytype) void {
const T = @TypeOf(children);
const fields = @typeInfo(T).@"struct".fields;
if (fields.len == 0) return;
// Apply margin
const inner = if (opts.margin > 0)
area.inner(.{
.top = opts.margin,
.right = opts.margin,
.bottom = opts.margin,
.left = opts.margin,
})
else
area;
if (inner.isEmpty()) return;
// Build constraints
var constraints: [fields.len]Constraint = undefined;
inline for (fields, 0..) |field, i| {
const child = @field(children, field.name);
constraints[i] = child.constraint;
}
// Split area
const layout = Layout.vertical(&constraints).withMargin(0);
const result = layout.split(inner);
// Render each child with spacing adjustment
var y_offset: u16 = 0;
inline for (fields, 0..) |field, i| {
if (i >= result.count) break;
var child_area = result.rects[i];
// Apply spacing (except for first)
if (i > 0) {
y_offset += opts.spacing;
child_area.y += y_offset;
if (child_area.height > opts.spacing) {
child_area.height -= opts.spacing;
}
}
// Apply alignment
if (opts.alignment != .stretch) {
// For non-stretch, we'd need to know the widget's preferred width
// For now, stretch is the default
}
const child = @field(children, field.name);
child.widget.render(child_area, buf);
}
}
/// Render a horizontal stack of widgets
pub fn hstack(buf: *Buffer, area: Rect, children: anytype) void {
hstackOpts(buf, area, .{}, children);
}
/// Render a horizontal stack with options
pub fn hstackOpts(buf: *Buffer, area: Rect, opts: HStackOptions, children: anytype) void {
const T = @TypeOf(children);
const fields = @typeInfo(T).@"struct".fields;
if (fields.len == 0) return;
// Apply margin
const inner = if (opts.margin > 0)
area.inner(.{
.top = opts.margin,
.right = opts.margin,
.bottom = opts.margin,
.left = opts.margin,
})
else
area;
if (inner.isEmpty()) return;
// Build constraints
var constraints: [fields.len]Constraint = undefined;
inline for (fields, 0..) |field, i| {
const child = @field(children, field.name);
constraints[i] = child.constraint;
}
// Split area
const layout = Layout.horizontal(&constraints).withMargin(0);
const result = layout.split(inner);
// Render each child with spacing adjustment
var x_offset: u16 = 0;
inline for (fields, 0..) |field, i| {
if (i >= result.count) break;
var child_area = result.rects[i];
// Apply spacing (except for first)
if (i > 0) {
x_offset += opts.spacing;
child_area.x += x_offset;
if (child_area.width > opts.spacing) {
child_area.width -= opts.spacing;
}
}
const child = @field(children, field.name);
child.widget.render(child_area, buf);
}
}
// ============================================================================
// Z-Stack (Overlay)
// ============================================================================
/// Render widgets stacked on top of each other (z-order)
pub fn zstack(buf: *Buffer, area: Rect, children: anytype) void {
const T = @TypeOf(children);
const fields = @typeInfo(T).@"struct".fields;
// Render all children in same area (later = on top)
inline for (fields) |field| {
const child = @field(children, field.name);
child.widget.render(area, buf);
}
}
// ============================================================================
// Conditional Rendering
// ============================================================================
/// Conditionally include a widget
pub fn when(condition: bool, child: Child) ?Child {
return if (condition) child else null;
}
/// Conditionally include a widget (inline version for tuples)
pub fn maybe(condition: bool, widget: anytype, constraint: Constraint) ?Child {
if (!condition) return null;
return .{
.widget = makeRenderable(widget),
.constraint = constraint,
};
}
// ============================================================================
// Spacers
// ============================================================================
/// Empty space with fixed size
pub fn spacer(size: u16) Child {
return .{
.widget = .{
.ptr = undefined,
.renderFn = struct {
fn render(_: *const anyopaque, _: Rect, _: *Buffer) void {
// Do nothing - just takes up space
}
}.render,
},
.constraint = Constraint.length(size),
};
}
/// Flexible spacer that expands
pub fn flexSpacer(factor: u16) Child {
return .{
.widget = .{
.ptr = undefined,
.renderFn = struct {
fn render(_: *const anyopaque, _: Rect, _: *Buffer) void {}
}.render,
},
.constraint = Constraint.ratio(factor, 1),
};
}
// ============================================================================
// Decorators
// ============================================================================
/// Decorator that adds padding around a widget
pub const Padded = struct {
inner: Renderable,
padding: u16,
pub fn render(self: *const Padded, area: Rect, buf: *Buffer) void {
const inner_area = area.inner(.{
.top = self.padding,
.right = self.padding,
.bottom = self.padding,
.left = self.padding,
});
self.inner.render(inner_area, buf);
}
};
/// Add padding around a widget
pub fn padded(widget: anytype, padding: u16) *const Padded {
const decorator = Padded{
.inner = makeRenderable(widget),
.padding = padding,
};
// Note: This returns a pointer to stack memory which is problematic
// In practice, you'd want to allocate or use a different pattern
_ = decorator;
@compileError("padded() requires allocation - use pad() with explicit area instead");
}
/// Apply padding to an area (simpler version)
pub fn pad(area: Rect, padding: u16) Rect {
return area.inner(.{
.top = padding,
.right = padding,
.bottom = padding,
.left = padding,
});
}
// ============================================================================
// Centered Content
// ============================================================================
/// Center content within an area
pub fn center(buf: *Buffer, area: Rect, width: u16, height: u16, widget: anytype) void {
const centered_area = Rect.init(
area.x + (area.width -| width) / 2,
area.y + (area.height -| height) / 2,
@min(width, area.width),
@min(height, area.height),
);
const renderable = makeRenderable(widget);
renderable.render(centered_area, buf);
}
/// Center horizontally
pub fn centerH(buf: *Buffer, area: Rect, width: u16, widget: anytype) void {
const centered_area = Rect.init(
area.x + (area.width -| width) / 2,
area.y,
@min(width, area.width),
area.height,
);
const renderable = makeRenderable(widget);
renderable.render(centered_area, buf);
}
/// Center vertically
pub fn centerV(buf: *Buffer, area: Rect, height: u16, widget: anytype) void {
const centered_area = Rect.init(
area.x,
area.y + (area.height -| height) / 2,
area.width,
@min(height, area.height),
);
const renderable = makeRenderable(widget);
renderable.render(centered_area, buf);
}
// ============================================================================
// Alignment Helpers
// ============================================================================
/// Align content to top-left
pub fn topLeft(area: Rect, width: u16, height: u16) Rect {
return Rect.init(area.x, area.y, @min(width, area.width), @min(height, area.height));
}
/// Align content to top-right
pub fn topRight(area: Rect, width: u16, height: u16) Rect {
return Rect.init(
area.x + area.width -| width,
area.y,
@min(width, area.width),
@min(height, area.height),
);
}
/// Align content to bottom-left
pub fn bottomLeft(area: Rect, width: u16, height: u16) Rect {
return Rect.init(
area.x,
area.y + area.height -| height,
@min(width, area.width),
@min(height, area.height),
);
}
/// Align content to bottom-right
pub fn bottomRight(area: Rect, width: u16, height: u16) Rect {
return Rect.init(
area.x + area.width -| width,
area.y + area.height -| height,
@min(width, area.width),
@min(height, area.height),
);
}
// ============================================================================
// Split Helpers
// ============================================================================
/// Split area into two parts vertically
pub fn splitV(area: Rect, top_height: u16) struct { top: Rect, bottom: Rect } {
const h = @min(top_height, area.height);
return .{
.top = Rect.init(area.x, area.y, area.width, h),
.bottom = Rect.init(area.x, area.y + h, area.width, area.height -| h),
};
}
/// Split area into two parts horizontally
pub fn splitH(area: Rect, left_width: u16) struct { left: Rect, right: Rect } {
const w = @min(left_width, area.width);
return .{
.left = Rect.init(area.x, area.y, w, area.height),
.right = Rect.init(area.x + w, area.y, area.width -| w, area.height),
};
}
/// Split into three parts vertically (header, content, footer)
pub fn splitV3(area: Rect, header_h: u16, footer_h: u16) struct { header: Rect, content: Rect, footer: Rect } {
const h1 = @min(header_h, area.height);
const h3 = @min(footer_h, area.height -| h1);
const h2 = area.height -| h1 -| h3;
return .{
.header = Rect.init(area.x, area.y, area.width, h1),
.content = Rect.init(area.x, area.y + h1, area.width, h2),
.footer = Rect.init(area.x, area.y + h1 + h2, area.width, h3),
};
}
// ============================================================================
// Helper Functions
// ============================================================================
fn makeRenderable(widget: anytype) Renderable {
const T = @TypeOf(widget);
// Check if it's already a Child
if (T == Child) {
return widget.widget;
}
// Check if it's a pointer to something with render
const ptr_info = @typeInfo(T);
if (ptr_info == .pointer) {
const WidgetType = ptr_info.pointer.child;
if (@hasDecl(WidgetType, "render")) {
return .{
.ptr = @ptrCast(widget),
.renderFn = struct {
fn render(p: *const anyopaque, area: Rect, buf: *Buffer) void {
const w: *const WidgetType = @ptrCast(@alignCast(p));
w.render(area, buf);
}
}.render,
};
}
}
@compileError("Widget must be a pointer to a type with render(Rect, *Buffer) method");
}
// ============================================================================
// Tests
// ============================================================================
test "splitV" {
const area = Rect.init(0, 0, 80, 24);
const split = splitV(area, 3);
try std.testing.expectEqual(@as(u16, 3), split.top.height);
try std.testing.expectEqual(@as(u16, 21), split.bottom.height);
try std.testing.expectEqual(@as(u16, 3), split.bottom.y);
}
test "splitH" {
const area = Rect.init(0, 0, 100, 10);
const split = splitH(area, 20);
try std.testing.expectEqual(@as(u16, 20), split.left.width);
try std.testing.expectEqual(@as(u16, 80), split.right.width);
try std.testing.expectEqual(@as(u16, 20), split.right.x);
}
test "splitV3" {
const area = Rect.init(0, 0, 80, 24);
const split = splitV3(area, 3, 1);
try std.testing.expectEqual(@as(u16, 3), split.header.height);
try std.testing.expectEqual(@as(u16, 20), split.content.height);
try std.testing.expectEqual(@as(u16, 1), split.footer.height);
}
test "topLeft alignment" {
const area = Rect.init(10, 10, 100, 50);
const aligned = topLeft(area, 20, 5);
try std.testing.expectEqual(@as(u16, 10), aligned.x);
try std.testing.expectEqual(@as(u16, 10), aligned.y);
try std.testing.expectEqual(@as(u16, 20), aligned.width);
try std.testing.expectEqual(@as(u16, 5), aligned.height);
}
test "bottomRight alignment" {
const area = Rect.init(0, 0, 100, 50);
const aligned = bottomRight(area, 20, 10);
try std.testing.expectEqual(@as(u16, 80), aligned.x);
try std.testing.expectEqual(@as(u16, 40), aligned.y);
}
test "spacer creates empty child" {
const s = spacer(5);
try std.testing.expectEqual(Constraint.length(5), s.constraint);
}
test "flexSpacer creates ratio constraint" {
const s = flexSpacer(2);
try std.testing.expectEqual(Constraint.ratio(2, 1), s.constraint);
}
test "pad reduces area" {
const area = Rect.init(0, 0, 100, 50);
const padded_area = pad(area, 5);
try std.testing.expectEqual(@as(u16, 5), padded_area.x);
try std.testing.expectEqual(@as(u16, 5), padded_area.y);
try std.testing.expectEqual(@as(u16, 90), padded_area.width);
try std.testing.expectEqual(@as(u16, 40), padded_area.height);
}

338
src/debug.zig Normal file
View file

@ -0,0 +1,338 @@
//! Debug tools for zcatui.
//!
//! Provides debug overlays, performance counters, and inspection tools
//! for developing TUI applications.
//!
//! ## Features
//!
//! - Widget boundary visualization
//! - Performance metrics (FPS, render time)
//! - Event logging
//! - Memory usage tracking
//!
//! ## Usage
//!
//! ```zig
//! var debug = DebugOverlay.init();
//! debug.setEnabled(true);
//!
//! // In your render loop:
//! debug.beginFrame();
//! // ... render widgets ...
//! debug.endFrame();
//! debug.render(area, buf);
//! ```
const std = @import("std");
const Style = @import("style.zig").Style;
const Color = @import("style.zig").Color;
const Buffer = @import("buffer.zig").Buffer;
const Rect = @import("buffer.zig").Rect;
/// Debug overlay flags.
pub const DebugFlags = packed struct {
/// Show widget boundaries.
show_boundaries: bool = false,
/// Show FPS counter.
show_fps: bool = true,
/// Show render time.
show_render_time: bool = true,
/// Show memory usage.
show_memory: bool = false,
/// Show event log.
show_events: bool = false,
/// Show mouse position.
show_mouse: bool = false,
_padding: u2 = 0,
pub const all: DebugFlags = .{
.show_boundaries = true,
.show_fps = true,
.show_render_time = true,
.show_memory = true,
.show_events = true,
.show_mouse = true,
};
pub const minimal: DebugFlags = .{
.show_fps = true,
};
};
/// A single event log entry.
pub const EventLogEntry = struct {
timestamp_ms: u64,
message: [64]u8,
len: usize,
};
/// Debug overlay for TUI applications.
pub const DebugOverlay = struct {
/// Whether debug mode is enabled.
enabled: bool = false,
/// Debug display flags.
flags: DebugFlags = .{},
/// Frame timing.
frame_start_ns: i128 = 0,
frame_end_ns: i128 = 0,
last_frame_time_ns: i128 = 0,
/// FPS tracking.
frame_count: u64 = 0,
fps_update_time: i128 = 0,
current_fps: f32 = 0,
frames_since_fps_update: u32 = 0,
/// Event log (circular buffer).
event_log: [16]EventLogEntry = undefined,
event_log_head: usize = 0,
event_log_count: usize = 0,
/// Mouse position.
mouse_x: u16 = 0,
mouse_y: u16 = 0,
/// Widget count for current frame.
widget_count: u32 = 0,
/// Render call count.
render_calls: u32 = 0,
/// Creates a new debug overlay.
pub fn init() DebugOverlay {
return .{
.fps_update_time = std.time.nanoTimestamp(),
};
}
/// Enables or disables debug mode.
pub fn setEnabled(self: *DebugOverlay, enabled: bool) void {
self.enabled = enabled;
}
/// Toggles debug mode.
pub fn toggle(self: *DebugOverlay) void {
self.enabled = !self.enabled;
}
/// Sets debug flags.
pub fn setFlags(self: *DebugOverlay, flags: DebugFlags) void {
self.flags = flags;
}
/// Marks the start of a frame.
pub fn beginFrame(self: *DebugOverlay) void {
self.frame_start_ns = std.time.nanoTimestamp();
self.widget_count = 0;
self.render_calls = 0;
}
/// Marks the end of a frame.
pub fn endFrame(self: *DebugOverlay) void {
self.frame_end_ns = std.time.nanoTimestamp();
self.last_frame_time_ns = self.frame_end_ns - self.frame_start_ns;
self.frame_count += 1;
self.frames_since_fps_update += 1;
// Update FPS every second
const elapsed = self.frame_end_ns - self.fps_update_time;
if (elapsed >= std.time.ns_per_s) {
self.current_fps = @as(f32, @floatFromInt(self.frames_since_fps_update)) *
@as(f32, @floatFromInt(std.time.ns_per_s)) / @as(f32, @floatFromInt(elapsed));
self.fps_update_time = self.frame_end_ns;
self.frames_since_fps_update = 0;
}
}
/// Records a widget render.
pub fn recordWidget(self: *DebugOverlay) void {
self.widget_count += 1;
}
/// Records a render call.
pub fn recordRender(self: *DebugOverlay) void {
self.render_calls += 1;
}
/// Updates mouse position.
pub fn updateMouse(self: *DebugOverlay, x: u16, y: u16) void {
self.mouse_x = x;
self.mouse_y = y;
}
/// Logs an event.
pub fn logEvent(self: *DebugOverlay, comptime fmt: []const u8, args: anytype) void {
var entry = &self.event_log[self.event_log_head];
entry.timestamp_ms = @intCast(@divTrunc(std.time.nanoTimestamp(), std.time.ns_per_ms));
const written = std.fmt.bufPrint(&entry.message, fmt, args) catch {
entry.len = 0;
return;
};
entry.len = written.len;
self.event_log_head = (self.event_log_head + 1) % self.event_log.len;
if (self.event_log_count < self.event_log.len) {
self.event_log_count += 1;
}
}
/// Gets the last frame render time in milliseconds.
pub fn getFrameTimeMs(self: *const DebugOverlay) f32 {
return @as(f32, @floatFromInt(self.last_frame_time_ns)) / @as(f32, @floatFromInt(std.time.ns_per_ms));
}
/// Renders the debug overlay.
pub fn render(self: *const DebugOverlay, area: Rect, buf: *Buffer) void {
if (!self.enabled or area.isEmpty()) return;
const bg_style = (Style{}).bg(Color.rgb(30, 30, 30));
const label_style = (Style{}).fg(Color.rgb(150, 150, 150)).bg(Color.rgb(30, 30, 30));
const value_style = (Style{}).fg(Color.green).bg(Color.rgb(30, 30, 30));
const title_style = (Style{}).fg(Color.yellow).bg(Color.rgb(30, 30, 30)).bold();
// Calculate overlay size
var lines_needed: u16 = 1; // Title
if (self.flags.show_fps) lines_needed += 1;
if (self.flags.show_render_time) lines_needed += 1;
if (self.flags.show_memory) lines_needed += 1;
if (self.flags.show_mouse) lines_needed += 1;
lines_needed += 1; // Widget count
const overlay_width: u16 = 25;
const overlay_height = lines_needed + 2;
const overlay_x = area.x + area.width -| overlay_width -| 1;
const overlay_y = area.y + 1;
// Draw background
var y: u16 = 0;
while (y < overlay_height) : (y += 1) {
var x: u16 = 0;
while (x < overlay_width) : (x += 1) {
if (overlay_x + x < area.x + area.width and overlay_y + y < area.y + area.height) {
_ = buf.setString(overlay_x + x, overlay_y + y, " ", bg_style);
}
}
}
// Draw content
var line: u16 = 0;
_ = buf.setString(overlay_x + 1, overlay_y + line, " DEBUG ", title_style);
line += 1;
if (self.flags.show_fps) {
_ = buf.setString(overlay_x + 1, overlay_y + line, "FPS:", label_style);
var fps_buf: [16]u8 = undefined;
const fps_str = std.fmt.bufPrint(&fps_buf, "{d:.1}", .{self.current_fps}) catch "?";
_ = buf.setString(overlay_x + 6, overlay_y + line, fps_str, value_style);
line += 1;
}
if (self.flags.show_render_time) {
_ = buf.setString(overlay_x + 1, overlay_y + line, "Time:", label_style);
var time_buf: [16]u8 = undefined;
const time_str = std.fmt.bufPrint(&time_buf, "{d:.2}ms", .{self.getFrameTimeMs()}) catch "?";
_ = buf.setString(overlay_x + 7, overlay_y + line, time_str, value_style);
line += 1;
}
if (self.flags.show_mouse) {
_ = buf.setString(overlay_x + 1, overlay_y + line, "Mouse:", label_style);
var mouse_buf: [16]u8 = undefined;
const mouse_str = std.fmt.bufPrint(&mouse_buf, "{d},{d}", .{ self.mouse_x, self.mouse_y }) catch "?";
_ = buf.setString(overlay_x + 8, overlay_y + line, mouse_str, value_style);
line += 1;
}
// Widget count
_ = buf.setString(overlay_x + 1, overlay_y + line, "Widgets:", label_style);
var widget_buf: [8]u8 = undefined;
const widget_str = std.fmt.bufPrint(&widget_buf, "{d}", .{self.widget_count}) catch "?";
_ = buf.setString(overlay_x + 10, overlay_y + line, widget_str, value_style);
}
/// Draws a boundary around a rect (for widget debugging).
pub fn drawBoundary(self: *const DebugOverlay, rect: Rect, buf: *Buffer, color: Color) void {
if (!self.enabled or !self.flags.show_boundaries) return;
const style = (Style{}).fg(color);
// Top and bottom
var x: u16 = rect.x;
while (x < rect.x + rect.width) : (x += 1) {
_ = buf.setString(x, rect.y, "", style);
if (rect.height > 0) {
_ = buf.setString(x, rect.y + rect.height -| 1, "", style);
}
}
// Left and right
var y: u16 = rect.y;
while (y < rect.y + rect.height) : (y += 1) {
_ = buf.setString(rect.x, y, "", style);
if (rect.width > 0) {
_ = buf.setString(rect.x + rect.width -| 1, y, "", style);
}
}
// Corners
_ = buf.setString(rect.x, rect.y, "", style);
if (rect.width > 1) {
_ = buf.setString(rect.x + rect.width -| 1, rect.y, "", style);
}
if (rect.height > 1) {
_ = buf.setString(rect.x, rect.y + rect.height -| 1, "", style);
if (rect.width > 1) {
_ = buf.setString(rect.x + rect.width -| 1, rect.y + rect.height -| 1, "", style);
}
}
}
};
/// Global debug instance for convenience.
pub var global_debug: DebugOverlay = DebugOverlay.init();
/// Convenience function to toggle global debug.
pub fn toggleDebug() void {
global_debug.toggle();
}
/// Convenience function to check if debug is enabled.
pub fn isDebugEnabled() bool {
return global_debug.enabled;
}
// ============================================================================
// Tests
// ============================================================================
test "DebugOverlay creation" {
var debug = DebugOverlay.init();
try std.testing.expect(!debug.enabled);
debug.setEnabled(true);
try std.testing.expect(debug.enabled);
debug.toggle();
try std.testing.expect(!debug.enabled);
}
test "DebugOverlay frame timing" {
var debug = DebugOverlay.init();
debug.setEnabled(true);
debug.beginFrame();
// Simulate some work
std.time.sleep(1_000_000); // 1ms
debug.endFrame();
try std.testing.expect(debug.last_frame_time_ns > 0);
}
test "DebugOverlay event logging" {
var debug = DebugOverlay.init();
debug.logEvent("Test event {d}", .{42});
try std.testing.expectEqual(@as(usize, 1), debug.event_log_count);
}
test "DebugFlags" {
const flags = DebugFlags.all;
try std.testing.expect(flags.show_fps);
try std.testing.expect(flags.show_boundaries);
try std.testing.expect(flags.show_memory);
}

401
src/diagnostic.zig Normal file
View file

@ -0,0 +1,401 @@
//! Elm-style diagnostic messages for zcatui.
//!
//! Provides beautiful, helpful error messages inspired by Elm's compiler.
//! These diagnostics show context, highlight problems, and suggest fixes.
//!
//! ## Example Output
//!
//! ```
//! INVALID CONSTRAINT
//!
//! I found a constraint that doesn't make sense:
//!
//! Layout.horizontal()
//! .constraints(&.{
//! Constraint.percentage(150), // <-- HERE
//! })
//!
//! Percentages must be between 0 and 100, but you gave me 150.
//!
//! Hint: Did you mean to use Constraint.min(150) for a minimum size?
//! ```
const std = @import("std");
const Style = @import("style.zig").Style;
const Color = @import("style.zig").Color;
const Buffer = @import("buffer.zig").Buffer;
const Rect = @import("buffer.zig").Rect;
/// Severity level for diagnostics.
pub const Severity = enum {
/// Informational message.
hint,
/// Warning that doesn't prevent operation.
warning,
/// Error that prevents operation.
@"error",
pub fn color(self: Severity) Color {
return switch (self) {
.hint => Color.cyan,
.warning => Color.yellow,
.@"error" => Color.red,
};
}
pub fn label(self: Severity) []const u8 {
return switch (self) {
.hint => "HINT",
.warning => "WARNING",
.@"error" => "ERROR",
};
}
};
/// A code snippet with optional highlighting.
pub const CodeSnippet = struct {
/// Lines of code.
lines: []const []const u8,
/// Line number of first line (1-indexed).
start_line: usize = 1,
/// Column to highlight (0-indexed, optional).
highlight_col: ?usize = null,
/// Length of highlight.
highlight_len: usize = 1,
/// Line containing the highlight (relative to start_line).
highlight_line: usize = 0,
};
/// A diagnostic message with Elm-style formatting.
pub const Diagnostic = struct {
/// Severity of the diagnostic.
severity: Severity = .@"error",
/// Short title (e.g., "INVALID CONSTRAINT").
title: []const u8,
/// Main explanation message.
message: []const u8,
/// Optional code snippet showing context.
snippet: ?CodeSnippet = null,
/// Optional hint for fixing the issue.
hint: ?[]const u8 = null,
/// Optional "see also" reference.
see_also: ?[]const u8 = null,
/// Creates a new error diagnostic.
pub fn err(title: []const u8, message: []const u8) Diagnostic {
return .{
.severity = .@"error",
.title = title,
.message = message,
};
}
/// Creates a new warning diagnostic.
pub fn warning(title: []const u8, message: []const u8) Diagnostic {
return .{
.severity = .warning,
.title = title,
.message = message,
};
}
/// Creates a new hint diagnostic.
pub fn hintDiag(title: []const u8, message: []const u8) Diagnostic {
return .{
.severity = .hint,
.title = title,
.message = message,
};
}
/// Adds a code snippet.
pub fn withSnippet(self: Diagnostic, snippet: CodeSnippet) Diagnostic {
var d = self;
d.snippet = snippet;
return d;
}
/// Adds a hint.
pub fn withHint(self: Diagnostic, h: []const u8) Diagnostic {
var d = self;
d.hint = h;
return d;
}
/// Adds a "see also" reference.
pub fn withSeeAlso(self: Diagnostic, ref: []const u8) Diagnostic {
var d = self;
d.see_also = ref;
return d;
}
/// Formats the diagnostic as a string.
pub fn format(self: *const Diagnostic, allocator: std.mem.Allocator) ![]u8 {
var result = std.ArrayList(u8).init(allocator);
const writer = result.writer();
// Header line with title
try writer.writeAll("── ");
try writer.writeAll(self.title);
try writer.writeAll(" ");
// Fill with dashes to ~80 chars
const title_len = self.title.len + 4;
const dashes_needed = if (title_len < 76) 76 - title_len else 0;
for (0..dashes_needed) |_| {
try writer.writeByte('-');
}
try writer.writeAll("\n\n");
// Main message
try writer.writeAll(self.message);
try writer.writeAll("\n");
// Code snippet
if (self.snippet) |snippet| {
try writer.writeAll("\n");
for (snippet.lines, 0..) |line, i| {
const line_num = snippet.start_line + i;
try writer.print(" {d: >4} │ {s}\n", .{ line_num, line });
// Highlight line
if (i == snippet.highlight_line) {
if (snippet.highlight_col) |col| {
try writer.writeAll("");
for (0..col) |_| {
try writer.writeByte(' ');
}
for (0..snippet.highlight_len) |_| {
try writer.writeByte('^');
}
try writer.writeAll("\n");
}
}
}
try writer.writeAll("\n");
}
// Hint
if (self.hint) |h| {
try writer.writeAll("\nHint: ");
try writer.writeAll(h);
try writer.writeAll("\n");
}
// See also
if (self.see_also) |ref| {
try writer.writeAll("\nSee: ");
try writer.writeAll(ref);
try writer.writeAll("\n");
}
return result.toOwnedSlice();
}
/// Renders the diagnostic to a buffer.
pub fn render(self: *const Diagnostic, area: Rect, buf: *Buffer) void {
if (area.isEmpty()) return;
const title_style = (Style{}).fg(self.severity.color()).bold();
const normal_style = Style{};
const line_num_style = (Style{}).fg(Color.rgb(100, 100, 100));
const highlight_style = (Style{}).fg(self.severity.color()).bold();
var y: u16 = area.y;
// Header
_ = buf.setString(area.x, y, "── ", title_style);
_ = buf.setString(area.x + 3, y, self.title, title_style);
const title_end = area.x + 3 + @as(u16, @intCast(@min(self.title.len, 60)));
var x = title_end + 1;
while (x < area.x + area.width -| 1) : (x += 1) {
_ = buf.setString(x, y, "", title_style);
}
y += 2;
// Message (wrap lines)
var msg_lines = std.mem.splitScalar(u8, self.message, '\n');
while (msg_lines.next()) |line| {
if (y >= area.y + area.height) break;
const max_len = @min(line.len, area.width -| 2);
_ = buf.setString(area.x, y, line[0..max_len], normal_style);
y += 1;
}
y += 1;
// Code snippet
if (self.snippet) |snippet| {
for (snippet.lines, 0..) |line, i| {
if (y >= area.y + area.height -| 3) break;
const line_num = snippet.start_line + i;
var num_buf: [8]u8 = undefined;
const num_str = std.fmt.bufPrint(&num_buf, "{d: >4}", .{line_num}) catch "????";
_ = buf.setString(area.x + 2, y, num_str, line_num_style);
_ = buf.setString(area.x + 7, y, "", line_num_style);
const max_line_len = @min(line.len, area.width -| 10);
_ = buf.setString(area.x + 9, y, line[0..max_line_len], normal_style);
y += 1;
// Highlight
if (i == snippet.highlight_line) {
if (snippet.highlight_col) |col| {
_ = buf.setString(area.x + 7, y, "", line_num_style);
const highlight_x = area.x + 9 + @as(u16, @intCast(col));
var hx: u16 = 0;
while (hx < snippet.highlight_len) : (hx += 1) {
if (highlight_x + hx < area.x + area.width) {
_ = buf.setString(highlight_x + hx, y, "^", highlight_style);
}
}
y += 1;
}
}
}
y += 1;
}
// Hint
if (self.hint) |h| {
if (y < area.y + area.height -| 1) {
_ = buf.setString(area.x, y, "Hint: ", (Style{}).fg(Color.cyan).bold());
const max_hint_len = @min(h.len, area.width -| 8);
_ = buf.setString(area.x + 6, y, h[0..max_hint_len], normal_style);
y += 1;
}
}
// See also
if (self.see_also) |ref| {
if (y < area.y + area.height) {
_ = buf.setString(area.x, y, "See: ", (Style{}).fg(Color.blue));
const max_ref_len = @min(ref.len, area.width -| 6);
_ = buf.setString(area.x + 5, y, ref[0..max_ref_len], (Style{}).fg(Color.blue).underline());
}
}
}
};
/// Builder for creating diagnostics with common patterns.
pub const DiagnosticBuilder = struct {
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) DiagnosticBuilder {
return .{ .allocator = allocator };
}
/// Creates a "value out of range" diagnostic.
pub fn outOfRange(
self: DiagnosticBuilder,
what: []const u8,
value: anytype,
min: anytype,
max: anytype,
) Diagnostic {
_ = self;
_ = value;
_ = min;
_ = max;
return Diagnostic.err("VALUE OUT OF RANGE", what)
.withHint("Check that your value is within the valid range.");
}
/// Creates a "missing required field" diagnostic.
pub fn missingField(self: DiagnosticBuilder, struct_name: []const u8, field_name: []const u8) Diagnostic {
_ = self;
_ = struct_name;
return Diagnostic.err("MISSING REQUIRED FIELD", field_name)
.withHint("Add the missing field to complete the configuration.");
}
/// Creates a "type mismatch" diagnostic.
pub fn typeMismatch(self: DiagnosticBuilder, expected: []const u8, got: []const u8) Diagnostic {
_ = self;
_ = expected;
return Diagnostic.err("TYPE MISMATCH", got)
.withHint("Make sure you're using the correct type.");
}
};
// ============================================================================
// Pre-built diagnostics for common zcatui errors
// ============================================================================
/// Diagnostic for invalid percentage constraint.
pub fn invalidPercentage(value: u16) Diagnostic {
return Diagnostic.err(
"INVALID PERCENTAGE",
"Percentage constraints must be between 0 and 100.",
).withSnippet(.{
.lines = &.{
"Layout.horizontal()",
" .constraints(&.{",
" Constraint.percentage(???),",
" })",
},
.start_line = 1,
.highlight_line = 2,
.highlight_col = 30,
.highlight_len = 3,
}).withHint(if (value > 100)
"Did you mean to use Constraint.min() for a minimum pixel size?"
else
"Use a value between 0 and 100.");
}
/// Diagnostic for empty layout constraints.
pub fn emptyConstraints() Diagnostic {
return Diagnostic.err(
"EMPTY CONSTRAINTS",
"A Layout needs at least one constraint to split the area.",
).withHint("Add constraints like Constraint.percentage(50) or Constraint.min(10).");
}
/// Diagnostic for widget rendered outside bounds.
pub fn widgetOutOfBounds(widget_name: []const u8) Diagnostic {
_ = widget_name;
return Diagnostic.warning(
"WIDGET OUT OF BOUNDS",
"A widget is being rendered outside its designated area.",
).withHint("Check that your layout constraints sum to 100% or fit within the available space.");
}
/// Diagnostic for invalid color value.
pub fn invalidColor(component: []const u8, value: u16) Diagnostic {
_ = component;
_ = value;
return Diagnostic.err(
"INVALID COLOR",
"RGB color components must be between 0 and 255.",
).withHint("Use Color.rgb(r, g, b) with values from 0 to 255.");
}
// ============================================================================
// Tests
// ============================================================================
test "Diagnostic creation" {
const d = Diagnostic.err("TEST ERROR", "This is a test message")
.withHint("Try this instead")
.withSeeAlso("https://example.com");
try std.testing.expectEqualStrings("TEST ERROR", d.title);
try std.testing.expectEqualStrings("This is a test message", d.message);
try std.testing.expectEqualStrings("Try this instead", d.hint.?);
}
test "Diagnostic format" {
const d = Diagnostic.err("TEST", "Message");
const formatted = try d.format(std.testing.allocator);
defer std.testing.allocator.free(formatted);
try std.testing.expect(std.mem.indexOf(u8, formatted, "TEST") != null);
try std.testing.expect(std.mem.indexOf(u8, formatted, "Message") != null);
}
test "invalidPercentage diagnostic" {
const d = invalidPercentage(150);
try std.testing.expectEqualStrings("INVALID PERCENTAGE", d.title);
try std.testing.expect(d.snippet != null);
try std.testing.expect(d.hint != null);
}

342
src/drag.zig Normal file
View file

@ -0,0 +1,342 @@
//! Mouse drag and drop support for zcatui.
//!
//! Provides drag state tracking and utilities for implementing
//! draggable UI elements like resizable panels and splitters.
//!
//! ## Example
//!
//! ```zig
//! var drag_state = DragState{};
//!
//! switch (event.mouse.kind) {
//! .down => {
//! if (isOnSplitter(event.mouse.column, event.mouse.row)) {
//! drag_state.start(.horizontal_resize, event.mouse.column, event.mouse.row);
//! }
//! },
//! .drag => drag_state.update(event.mouse.column, event.mouse.row),
//! .up => drag_state.end(),
//! else => {},
//! }
//! ```
const std = @import("std");
const Rect = @import("buffer.zig").Rect;
/// The type of drag operation in progress.
pub const DragType = enum {
/// No drag in progress.
none,
/// Horizontal resize (dragging left/right).
horizontal_resize,
/// Vertical resize (dragging up/down).
vertical_resize,
/// Moving/reordering an element.
move,
/// Selection drag (e.g., text selection).
selection,
/// Custom drag type for user-defined operations.
custom,
};
/// State for tracking mouse drag operations.
pub const DragState = struct {
/// Current drag type.
drag_type: DragType = .none,
/// Starting X position of drag.
start_x: u16 = 0,
/// Starting Y position of drag.
start_y: u16 = 0,
/// Current X position.
current_x: u16 = 0,
/// Current Y position.
current_y: u16 = 0,
/// Custom data associated with drag (e.g., panel index).
data: usize = 0,
/// Whether the drag has moved from the start position.
has_moved: bool = false,
/// Starts a new drag operation.
pub fn start(self: *DragState, drag_type: DragType, x: u16, y: u16) void {
self.drag_type = drag_type;
self.start_x = x;
self.start_y = y;
self.current_x = x;
self.current_y = y;
self.has_moved = false;
}
/// Starts a drag with associated data.
pub fn startWithData(self: *DragState, drag_type: DragType, x: u16, y: u16, data: usize) void {
self.start(drag_type, x, y);
self.data = data;
}
/// Updates the drag position.
pub fn update(self: *DragState, x: u16, y: u16) void {
if (self.drag_type == .none) return;
self.current_x = x;
self.current_y = y;
if (x != self.start_x or y != self.start_y) {
self.has_moved = true;
}
}
/// Ends the drag operation.
pub fn end(self: *DragState) void {
self.drag_type = .none;
self.has_moved = false;
self.data = 0;
}
/// Checks if a drag is in progress.
pub fn isDragging(self: *const DragState) bool {
return self.drag_type != .none;
}
/// Returns the horizontal delta from start.
pub fn deltaX(self: *const DragState) i32 {
return @as(i32, self.current_x) - @as(i32, self.start_x);
}
/// Returns the vertical delta from start.
pub fn deltaY(self: *const DragState) i32 {
return @as(i32, self.current_y) - @as(i32, self.start_y);
}
/// Returns the absolute horizontal distance.
pub fn distanceX(self: *const DragState) u16 {
const dx = self.deltaX();
return @intCast(if (dx < 0) -dx else dx);
}
/// Returns the absolute vertical distance.
pub fn distanceY(self: *const DragState) u16 {
const dy = self.deltaY();
return @intCast(if (dy < 0) -dy else dy);
}
};
/// Configuration for resizable splitters.
pub const SplitterConfig = struct {
/// Minimum size for the first panel (left/top).
min_first: u16 = 5,
/// Minimum size for the second panel (right/bottom).
min_second: u16 = 5,
/// Width/height of the splitter handle.
handle_size: u16 = 1,
/// Whether to show a visual indicator on the splitter.
show_indicator: bool = true,
};
/// Splitter handle for resizable panels.
pub const Splitter = struct {
/// Orientation of the splitter.
direction: Direction,
/// Position of the splitter (percentage 0-100 or absolute).
position: u16,
/// Whether position is percentage or absolute.
is_percentage: bool = true,
/// Configuration options.
config: SplitterConfig = .{},
pub const Direction = enum { horizontal, vertical };
/// Creates a horizontal splitter (splits left/right).
pub fn horizontal(position_percent: u16) Splitter {
return .{
.direction = .horizontal,
.position = position_percent,
};
}
/// Creates a vertical splitter (splits top/bottom).
pub fn vertical(position_percent: u16) Splitter {
return .{
.direction = .vertical,
.position = position_percent,
};
}
/// Sets minimum sizes.
pub fn setMinSizes(self: Splitter, first: u16, second: u16) Splitter {
var s = self;
s.config.min_first = first;
s.config.min_second = second;
return s;
}
/// Calculates the split areas given a total area.
pub fn split(self: *const Splitter, area: Rect) struct { first: Rect, second: Rect, handle: Rect } {
if (self.direction == .horizontal) {
// Split left/right
const total_width = area.width;
var first_width: u16 = if (self.is_percentage)
@intCast(@as(u32, total_width) * self.position / 100)
else
self.position;
// Enforce minimums
first_width = @max(first_width, self.config.min_first);
first_width = @min(first_width, total_width -| self.config.min_second -| self.config.handle_size);
const handle_x = area.x + first_width;
const second_x = handle_x + self.config.handle_size;
const second_width = total_width -| first_width -| self.config.handle_size;
return .{
.first = Rect.init(area.x, area.y, first_width, area.height),
.second = Rect.init(second_x, area.y, second_width, area.height),
.handle = Rect.init(handle_x, area.y, self.config.handle_size, area.height),
};
} else {
// Split top/bottom
const total_height = area.height;
var first_height: u16 = if (self.is_percentage)
@intCast(@as(u32, total_height) * self.position / 100)
else
self.position;
// Enforce minimums
first_height = @max(first_height, self.config.min_first);
first_height = @min(first_height, total_height -| self.config.min_second -| self.config.handle_size);
const handle_y = area.y + first_height;
const second_y = handle_y + self.config.handle_size;
const second_height = total_height -| first_height -| self.config.handle_size;
return .{
.first = Rect.init(area.x, area.y, area.width, first_height),
.second = Rect.init(area.x, second_y, area.width, second_height),
.handle = Rect.init(area.x, handle_y, area.width, self.config.handle_size),
};
}
}
/// Checks if a point is on the splitter handle.
pub fn isOnHandle(self: *const Splitter, area: Rect, x: u16, y: u16) bool {
const parts = self.split(area);
return parts.handle.contains(x, y);
}
/// Updates position based on drag delta.
pub fn adjustPosition(self: *Splitter, area: Rect, delta: i32) void {
if (self.direction == .horizontal) {
const total = area.width;
if (self.is_percentage) {
// Convert delta to percentage
const delta_percent: i32 = @intCast(@divTrunc(@as(i64, delta) * 100, @as(i64, total)));
const new_pos = @as(i32, self.position) + delta_percent;
self.position = @intCast(@max(0, @min(100, new_pos)));
} else {
const new_pos = @as(i32, self.position) + delta;
self.position = @intCast(@max(0, @min(@as(i32, total), new_pos)));
}
} else {
const total = area.height;
if (self.is_percentage) {
const delta_percent: i32 = @intCast(@divTrunc(@as(i64, delta) * 100, @as(i64, total)));
const new_pos = @as(i32, self.position) + delta_percent;
self.position = @intCast(@max(0, @min(100, new_pos)));
} else {
const new_pos = @as(i32, self.position) + delta;
self.position = @intCast(@max(0, @min(@as(i32, total), new_pos)));
}
}
}
};
/// Helper to process mouse events for drag operations.
pub fn processDragEvent(
drag_state: *DragState,
kind: @import("event.zig").MouseEventKind,
x: u16,
y: u16,
check_start: *const fn (u16, u16) ?DragType,
) bool {
switch (kind) {
.down => {
if (check_start(x, y)) |drag_type| {
drag_state.start(drag_type, x, y);
return true;
}
},
.drag => {
if (drag_state.isDragging()) {
drag_state.update(x, y);
return true;
}
},
.up => {
if (drag_state.isDragging()) {
drag_state.end();
return true;
}
},
else => {},
}
return false;
}
// ============================================================================
// Tests
// ============================================================================
test "DragState basic operations" {
var state = DragState{};
try std.testing.expect(!state.isDragging());
state.start(.horizontal_resize, 10, 20);
try std.testing.expect(state.isDragging());
try std.testing.expectEqual(@as(u16, 10), state.start_x);
try std.testing.expectEqual(@as(u16, 20), state.start_y);
state.update(15, 25);
try std.testing.expectEqual(@as(i32, 5), state.deltaX());
try std.testing.expectEqual(@as(i32, 5), state.deltaY());
try std.testing.expect(state.has_moved);
state.end();
try std.testing.expect(!state.isDragging());
}
test "Splitter horizontal split" {
var splitter = Splitter.horizontal(50);
const area = Rect.init(0, 0, 100, 50);
const parts = splitter.split(area);
try std.testing.expectEqual(@as(u16, 50), parts.first.width);
try std.testing.expectEqual(@as(u16, 49), parts.second.width); // 100 - 50 - 1 handle
try std.testing.expectEqual(@as(u16, 1), parts.handle.width);
}
test "Splitter vertical split" {
var splitter = Splitter.vertical(30);
const area = Rect.init(0, 0, 80, 100);
const parts = splitter.split(area);
try std.testing.expectEqual(@as(u16, 30), parts.first.height);
try std.testing.expectEqual(@as(u16, 69), parts.second.height); // 100 - 30 - 1 handle
try std.testing.expectEqual(@as(u16, 1), parts.handle.height);
}
test "Splitter isOnHandle" {
var splitter = Splitter.horizontal(50);
const area = Rect.init(0, 0, 100, 50);
try std.testing.expect(splitter.isOnHandle(area, 50, 25)); // On handle
try std.testing.expect(!splitter.isOnHandle(area, 25, 25)); // In first panel
try std.testing.expect(!splitter.isOnHandle(area, 75, 25)); // In second panel
}
test "Splitter adjustPosition" {
var splitter = Splitter.horizontal(50);
const area = Rect.init(0, 0, 100, 50);
splitter.adjustPosition(area, 10); // Drag right
try std.testing.expectEqual(@as(u16, 60), splitter.position);
splitter.adjustPosition(area, -20); // Drag left
try std.testing.expectEqual(@as(u16, 40), splitter.position);
}

View file

@ -68,7 +68,62 @@ pub const Constraint = union(enum) {
/// Creates a ratio constraint.
pub fn ratio(num: u32, den: u32) Constraint {
return .{ .rat = .{ .num = num, .den = den } };
return .{ .rat = .{ .num = num, .den = if (den == 0) 1 else den } };
}
// ========================================
// Common ratio helpers
// ========================================
/// Half of available space (1/2)
pub fn half() Constraint {
return ratio(1, 2);
}
/// One third of available space (1/3)
pub fn third() Constraint {
return ratio(1, 3);
}
/// Two thirds of available space (2/3)
pub fn twoThirds() Constraint {
return ratio(2, 3);
}
/// One quarter of available space (1/4)
pub fn quarter() Constraint {
return ratio(1, 4);
}
/// Three quarters of available space (3/4)
pub fn threeQuarters() Constraint {
return ratio(3, 4);
}
/// One fifth of available space (1/5)
pub fn fifth() Constraint {
return ratio(1, 5);
}
/// Golden ratio - larger portion (~61.8%)
pub fn goldenLarge() Constraint {
return ratio(618, 1000);
}
/// Golden ratio - smaller portion (~38.2%)
pub fn goldenSmall() Constraint {
return ratio(382, 1000);
}
/// Fill remaining space (equivalent to min(0) but more readable)
pub fn fill() Constraint {
return .{ .min_size = 0 };
}
/// Proportional constraint - takes N parts out of total parts
/// Example: prop(2, 5) means 2 parts when total is 5 parts
pub fn prop(parts: u32, total_parts: u32) Constraint {
return ratio(parts, if (total_parts == 0) 1 else total_parts);
}
};
@ -608,3 +663,106 @@ test "alignRight helper" {
try std.testing.expectEqual(@as(u16, 20), inner.width);
try std.testing.expectEqual(@as(u16, 50), inner.height);
}
// ============================================================================
// Ratio Constraint Tests
// ============================================================================
test "Layout ratio thirds" {
const area = Rect.init(0, 0, 90, 10);
const layout = Layout.horizontal(&.{
Constraint.third(),
Constraint.third(),
Constraint.third(),
});
const result = layout.split(area);
try std.testing.expectEqual(@as(usize, 3), result.count);
try std.testing.expectEqual(@as(u16, 30), result.rects[0].width);
try std.testing.expectEqual(@as(u16, 30), result.rects[1].width);
try std.testing.expectEqual(@as(u16, 30), result.rects[2].width);
}
test "Layout ratio halves" {
const area = Rect.init(0, 0, 100, 10);
const layout = Layout.horizontal(&.{
Constraint.half(),
Constraint.half(),
});
const result = layout.split(area);
try std.testing.expectEqual(@as(usize, 2), result.count);
try std.testing.expectEqual(@as(u16, 50), result.rects[0].width);
try std.testing.expectEqual(@as(u16, 50), result.rects[1].width);
}
test "Layout golden ratio" {
const area = Rect.init(0, 0, 100, 10);
const layout = Layout.horizontal(&.{
Constraint.goldenSmall(),
Constraint.goldenLarge(),
});
const result = layout.split(area);
try std.testing.expectEqual(@as(usize, 2), result.count);
// 38.2% of 100 = 38, 61.8% of 100 = 61
try std.testing.expectEqual(@as(u16, 38), result.rects[0].width);
try std.testing.expectEqual(@as(u16, 61), result.rects[1].width);
}
test "Layout ratio with fixed" {
const area = Rect.init(0, 0, 100, 10);
const layout = Layout.horizontal(&.{
Constraint.length(20), // Fixed sidebar
Constraint.fill(), // Fill rest
});
const result = layout.split(area);
try std.testing.expectEqual(@as(usize, 2), result.count);
try std.testing.expectEqual(@as(u16, 20), result.rects[0].width);
try std.testing.expectEqual(@as(u16, 80), result.rects[1].width);
}
test "Layout proportional parts" {
const area = Rect.init(0, 0, 100, 10);
const layout = Layout.horizontal(&.{
Constraint.prop(1, 4), // 25%
Constraint.prop(2, 4), // 50%
Constraint.prop(1, 4), // 25%
});
const result = layout.split(area);
try std.testing.expectEqual(@as(usize, 3), result.count);
try std.testing.expectEqual(@as(u16, 25), result.rects[0].width);
try std.testing.expectEqual(@as(u16, 50), result.rects[1].width);
try std.testing.expectEqual(@as(u16, 25), result.rects[2].width);
}
test "Layout ratio zero denominator protection" {
// Should not crash, denominator becomes 1
const c = Constraint.ratio(1, 0);
try std.testing.expectEqual(@as(u32, 1), c.rat.den);
}
test "Layout vertical ratio" {
const area = Rect.init(0, 0, 80, 24);
const layout = Layout.vertical(&.{
Constraint.length(3), // Header
Constraint.twoThirds(), // Content (2/3 of remaining)
Constraint.third(), // Footer (1/3 of remaining)
});
const result = layout.split(area);
try std.testing.expectEqual(@as(usize, 3), result.count);
try std.testing.expectEqual(@as(u16, 3), result.rects[0].height); // Header fixed
// Remaining: 24 - 3 = 21, but ratio is of total
// 2/3 of 24 = 16, 1/3 of 24 = 8
try std.testing.expectEqual(@as(u16, 16), result.rects[1].height);
try std.testing.expectEqual(@as(u16, 5), result.rects[2].height); // Remaining after consuming
}

311
src/profile.zig Normal file
View file

@ -0,0 +1,311 @@
//! Performance profiling for zcatui.
//!
//! Provides timing and profiling tools for measuring render performance.
//!
//! ## Usage
//!
//! ```zig
//! var profiler = Profiler.init();
//!
//! profiler.begin("render");
//! // ... render code ...
//! profiler.end("render");
//!
//! // Get statistics
//! if (profiler.getStats("render")) |stats| {
//! std.debug.print("Avg: {d}ms\n", .{stats.avg_ms});
//! }
//! ```
const std = @import("std");
/// Maximum number of named timers.
const MAX_TIMERS = 32;
/// Number of samples to keep for averaging.
const SAMPLE_COUNT = 60;
/// Statistics for a profiled section.
pub const ProfileStats = struct {
/// Minimum time in nanoseconds.
min_ns: i128 = std.math.maxInt(i128),
/// Maximum time in nanoseconds.
max_ns: i128 = 0,
/// Total time accumulated.
total_ns: i128 = 0,
/// Number of samples.
count: u64 = 0,
/// Recent samples for moving average.
samples: [SAMPLE_COUNT]i128 = [_]i128{0} ** SAMPLE_COUNT,
sample_index: usize = 0,
sample_count: usize = 0,
/// Returns average time in milliseconds.
pub fn avgMs(self: *const ProfileStats) f64 {
if (self.count == 0) return 0;
const avg_ns = @divTrunc(self.total_ns, @as(i128, @intCast(self.count)));
return @as(f64, @floatFromInt(avg_ns)) / 1_000_000.0;
}
/// Returns minimum time in milliseconds.
pub fn minMs(self: *const ProfileStats) f64 {
if (self.min_ns == std.math.maxInt(i128)) return 0;
return @as(f64, @floatFromInt(self.min_ns)) / 1_000_000.0;
}
/// Returns maximum time in milliseconds.
pub fn maxMs(self: *const ProfileStats) f64 {
return @as(f64, @floatFromInt(self.max_ns)) / 1_000_000.0;
}
/// Returns recent average in milliseconds (last N samples).
pub fn recentAvgMs(self: *const ProfileStats) f64 {
if (self.sample_count == 0) return 0;
var sum: i128 = 0;
for (0..self.sample_count) |i| {
sum += self.samples[i];
}
const avg = @divTrunc(sum, @as(i128, @intCast(self.sample_count)));
return @as(f64, @floatFromInt(avg)) / 1_000_000.0;
}
fn addSample(self: *ProfileStats, ns: i128) void {
self.total_ns += ns;
self.count += 1;
self.min_ns = @min(self.min_ns, ns);
self.max_ns = @max(self.max_ns, ns);
self.samples[self.sample_index] = ns;
self.sample_index = (self.sample_index + 1) % SAMPLE_COUNT;
if (self.sample_count < SAMPLE_COUNT) {
self.sample_count += 1;
}
}
fn reset(self: *ProfileStats) void {
self.* = .{};
}
};
/// A named timer entry.
const TimerEntry = struct {
name: [32]u8 = undefined,
name_len: usize = 0,
start_time: i128 = 0,
stats: ProfileStats = .{},
active: bool = false,
fn setName(self: *TimerEntry, name: []const u8) void {
const len = @min(name.len, 32);
@memcpy(self.name[0..len], name[0..len]);
self.name_len = len;
}
fn getName(self: *const TimerEntry) []const u8 {
return self.name[0..self.name_len];
}
};
/// Performance profiler.
pub const Profiler = struct {
timers: [MAX_TIMERS]TimerEntry = [_]TimerEntry{.{}} ** MAX_TIMERS,
timer_count: usize = 0,
enabled: bool = true,
/// Creates a new profiler.
pub fn init() Profiler {
return .{};
}
/// Enables or disables profiling.
pub fn setEnabled(self: *Profiler, enabled: bool) void {
self.enabled = enabled;
}
/// Finds or creates a timer by name.
fn getOrCreateTimer(self: *Profiler, name: []const u8) ?*TimerEntry {
// Look for existing
for (&self.timers) |*timer| {
if (timer.name_len > 0 and std.mem.eql(u8, timer.getName(), name)) {
return timer;
}
}
// Create new
if (self.timer_count < MAX_TIMERS) {
var timer = &self.timers[self.timer_count];
timer.setName(name);
self.timer_count += 1;
return timer;
}
return null;
}
/// Begins timing a named section.
pub fn begin(self: *Profiler, name: []const u8) void {
if (!self.enabled) return;
if (self.getOrCreateTimer(name)) |timer| {
timer.start_time = std.time.nanoTimestamp();
timer.active = true;
}
}
/// Ends timing a named section.
pub fn end(self: *Profiler, name: []const u8) void {
if (!self.enabled) return;
const end_time = std.time.nanoTimestamp();
if (self.getOrCreateTimer(name)) |timer| {
if (timer.active) {
const elapsed = end_time - timer.start_time;
timer.stats.addSample(elapsed);
timer.active = false;
}
}
}
/// Gets statistics for a named timer.
pub fn getStats(self: *const Profiler, name: []const u8) ?*const ProfileStats {
for (&self.timers) |*timer| {
if (timer.name_len > 0 and std.mem.eql(u8, timer.getName(), name)) {
return &timer.stats;
}
}
return null;
}
/// Resets all statistics.
pub fn reset(self: *Profiler) void {
for (&self.timers) |*timer| {
timer.stats.reset();
}
}
/// Returns an iterator over all timers with data.
pub fn iterator(self: *const Profiler) TimerIterator {
return .{ .profiler = self, .index = 0 };
}
pub const TimerIterator = struct {
profiler: *const Profiler,
index: usize,
pub fn next(self: *TimerIterator) ?struct { name: []const u8, stats: *const ProfileStats } {
while (self.index < self.profiler.timer_count) {
const timer = &self.profiler.timers[self.index];
self.index += 1;
if (timer.name_len > 0 and timer.stats.count > 0) {
return .{ .name = timer.getName(), .stats = &timer.stats };
}
}
return null;
}
};
};
/// Scoped timer that automatically ends on scope exit.
pub const ScopedTimer = struct {
profiler: *Profiler,
name: []const u8,
pub fn init(profiler: *Profiler, name: []const u8) ScopedTimer {
profiler.begin(name);
return .{ .profiler = profiler, .name = name };
}
pub fn deinit(self: ScopedTimer) void {
self.profiler.end(self.name);
}
};
/// Global profiler instance.
pub var global_profiler: Profiler = Profiler.init();
/// Convenience function to begin timing.
pub fn begin(name: []const u8) void {
global_profiler.begin(name);
}
/// Convenience function to end timing.
pub fn end(name: []const u8) void {
global_profiler.end(name);
}
/// Creates a scoped timer on the global profiler.
pub fn scoped(name: []const u8) ScopedTimer {
return ScopedTimer.init(&global_profiler, name);
}
// ============================================================================
// Tests
// ============================================================================
test "Profiler basic timing" {
var profiler = Profiler.init();
profiler.begin("test");
std.time.sleep(1_000_000); // 1ms
profiler.end("test");
const stats = profiler.getStats("test").?;
try std.testing.expect(stats.count == 1);
try std.testing.expect(stats.avgMs() > 0);
}
test "Profiler multiple samples" {
var profiler = Profiler.init();
for (0..10) |_| {
profiler.begin("loop");
std.time.sleep(100_000); // 0.1ms
profiler.end("loop");
}
const stats = profiler.getStats("loop").?;
try std.testing.expectEqual(@as(u64, 10), stats.count);
}
test "ProfileStats calculations" {
var stats = ProfileStats{};
stats.addSample(1_000_000); // 1ms
stats.addSample(2_000_000); // 2ms
stats.addSample(3_000_000); // 3ms
try std.testing.expectEqual(@as(u64, 3), stats.count);
try std.testing.expect(stats.avgMs() > 1.9 and stats.avgMs() < 2.1);
try std.testing.expect(stats.minMs() > 0.9 and stats.minMs() < 1.1);
try std.testing.expect(stats.maxMs() > 2.9 and stats.maxMs() < 3.1);
}
test "Profiler iterator" {
var profiler = Profiler.init();
profiler.begin("a");
profiler.end("a");
profiler.begin("b");
profiler.end("b");
var count: usize = 0;
var iter = profiler.iterator();
while (iter.next()) |_| {
count += 1;
}
try std.testing.expectEqual(@as(usize, 2), count);
}
test "ScopedTimer" {
var profiler = Profiler.init();
{
var timer = ScopedTimer.init(&profiler, "scoped");
defer timer.deinit();
std.time.sleep(100_000);
}
const stats = profiler.getStats("scoped").?;
try std.testing.expectEqual(@as(u64, 1), stats.count);
}

217
src/resize.zig Normal file
View file

@ -0,0 +1,217 @@
//! Terminal resize handling for zcatui.
//!
//! Provides automatic terminal resize detection using SIGWINCH signal handler.
//! This allows TUI applications to respond to terminal size changes gracefully.
//!
//! ## Usage
//!
//! ```zig
//! var resize_handler = try ResizeHandler.init();
//! defer resize_handler.deinit();
//!
//! // In your main loop:
//! if (resize_handler.hasResized()) {
//! const new_size = resize_handler.getSize();
//! try term.resize(new_size.width, new_size.height);
//! }
//! ```
const std = @import("std");
/// Terminal size.
pub const Size = struct {
width: u16,
height: u16,
/// Checks if two sizes are equal.
pub fn eql(self: Size, other: Size) bool {
return self.width == other.width and self.height == other.height;
}
};
/// Global flag set by SIGWINCH handler.
/// Using a simple atomic bool for thread-safe access.
var resize_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
/// Cached size from last check.
var cached_size: Size = .{ .width = 80, .height = 24 };
/// SIGWINCH signal handler.
fn sigwinchHandler(_: c_int) callconv(.c) void {
resize_pending.store(true, .release);
}
/// Resize handler for terminal resize events.
///
/// Uses SIGWINCH to detect terminal size changes efficiently.
/// The handler sets a flag which can be checked in the event loop.
pub const ResizeHandler = struct {
original_handler: ?std.posix.Sigaction = null,
auto_resize_callback: ?*const fn (Size) void = null,
last_known_size: Size,
/// Initializes the resize handler.
///
/// Installs a SIGWINCH handler to detect terminal resizes.
pub fn init() ResizeHandler {
const initial_size = queryTerminalSize();
cached_size = initial_size;
// Install SIGWINCH handler
var action = std.posix.Sigaction{
.handler = .{ .handler = sigwinchHandler },
.mask = std.posix.sigemptyset(),
.flags = 0,
};
var old_action: std.posix.Sigaction = undefined;
std.posix.sigaction(std.posix.SIG.WINCH, &action, &old_action);
return .{
.original_handler = old_action,
.last_known_size = initial_size,
};
}
/// Cleans up the resize handler.
///
/// Restores the original SIGWINCH handler.
pub fn deinit(self: *ResizeHandler) void {
if (self.original_handler) |original| {
var restore = original;
std.posix.sigaction(std.posix.SIG.WINCH, &restore, null);
}
}
/// Checks if a resize has occurred since the last check.
///
/// This is a non-blocking check. Returns true if SIGWINCH was received.
pub fn hasResized(self: *ResizeHandler) bool {
if (resize_pending.swap(false, .acquire)) {
const new_size = queryTerminalSize();
if (!new_size.eql(self.last_known_size)) {
self.last_known_size = new_size;
cached_size = new_size;
return true;
}
}
return false;
}
/// Gets the current terminal size.
///
/// Always queries the terminal directly for the latest size.
pub fn getSize(self: *const ResizeHandler) Size {
_ = self;
return queryTerminalSize();
}
/// Gets the last known size without querying the terminal.
pub fn getLastKnownSize(self: *const ResizeHandler) Size {
return self.last_known_size;
}
/// Sets a callback to be called when resize is detected.
///
/// The callback receives the new size.
pub fn setAutoResizeCallback(self: *ResizeHandler, callback: *const fn (Size) void) void {
self.auto_resize_callback = callback;
}
/// Processes any pending resize events.
///
/// If a resize has occurred and a callback is set, the callback is invoked.
/// Returns the new size if resize occurred, null otherwise.
pub fn processResize(self: *ResizeHandler) ?Size {
if (self.hasResized()) {
if (self.auto_resize_callback) |callback| {
callback(self.last_known_size);
}
return self.last_known_size;
}
return null;
}
};
/// Queries the terminal size directly.
///
/// Uses TIOCGWINSZ ioctl to get the current terminal dimensions.
pub fn queryTerminalSize() Size {
const winsize = extern struct {
row: u16,
col: u16,
xpixel: u16,
ypixel: u16,
};
var ws: winsize = undefined;
const fd = std.posix.STDOUT_FILENO;
const TIOCGWINSZ = 0x5413; // Linux value
if (std.posix.system.ioctl(fd, TIOCGWINSZ, @intFromPtr(&ws)) == 0) {
return .{
.width = ws.col,
.height = ws.row,
};
}
// Fallback to default size
return .{ .width = 80, .height = 24 };
}
/// Gets the cached terminal size.
///
/// Returns the size from the last resize check. This is faster than
/// queryTerminalSize() but may not reflect the very latest size.
pub fn getCachedSize() Size {
return cached_size;
}
/// Checks if a resize is pending.
///
/// Returns true if SIGWINCH was received since the last check.
pub fn isResizePending() bool {
return resize_pending.load(.acquire);
}
/// Clears the resize pending flag.
pub fn clearResizePending() void {
resize_pending.store(false, .release);
}
// ============================================================================
// Tests
// ============================================================================
test "Size equality" {
const s1 = Size{ .width = 80, .height = 24 };
const s2 = Size{ .width = 80, .height = 24 };
const s3 = Size{ .width = 100, .height = 30 };
try std.testing.expect(s1.eql(s2));
try std.testing.expect(!s1.eql(s3));
}
test "queryTerminalSize returns valid size" {
const size = queryTerminalSize();
// Terminal might not be available in test, so just check it doesn't crash
// and returns reasonable default
try std.testing.expect(size.width > 0);
try std.testing.expect(size.height > 0);
}
test "ResizeHandler basic functionality" {
// Skip in non-terminal environment
if (std.posix.system.ioctl(std.posix.STDOUT_FILENO, 0x5413, 0) != 0) {
return error.SkipZigTest;
}
var handler = try ResizeHandler.init();
defer handler.deinit();
// Initial state should not have resize pending
// (unless terminal resized during test)
const size = handler.getSize();
try std.testing.expect(size.width > 0);
try std.testing.expect(size.height > 0);
}

View file

@ -52,6 +52,47 @@ pub const Alignment = text.Alignment;
// Re-exports for convenience
pub const terminal = @import("terminal.zig");
pub const Terminal = terminal.Terminal;
pub const Size = terminal.Size;
// Resize handling
pub const resize = @import("resize.zig");
pub const ResizeHandler = resize.ResizeHandler;
// Drag and drop
pub const drag = @import("drag.zig");
pub const DragState = drag.DragState;
pub const DragType = drag.DragType;
pub const Splitter = drag.Splitter;
pub const SplitterConfig = drag.SplitterConfig;
// Diagnostics (Elm-style errors)
pub const diagnostic = @import("diagnostic.zig");
pub const Diagnostic = diagnostic.Diagnostic;
pub const DiagnosticBuilder = diagnostic.DiagnosticBuilder;
pub const Severity = diagnostic.Severity;
// Debug tools
pub const debug = @import("debug.zig");
pub const DebugOverlay = debug.DebugOverlay;
pub const DebugFlags = debug.DebugFlags;
// Performance profiling
pub const profile = @import("profile.zig");
pub const Profiler = profile.Profiler;
pub const ProfileStats = profile.ProfileStats;
pub const ScopedTimer = profile.ScopedTimer;
// Sixel graphics
pub const sixel = @import("sixel.zig");
pub const SixelEncoder = sixel.SixelEncoder;
pub const Pixel = sixel.Pixel;
// Async event loop
pub const async_loop = @import("async_loop.zig");
pub const AsyncLoop = async_loop.AsyncLoop;
pub const AsyncEvent = async_loop.AsyncEvent;
pub const EventSource = async_loop.EventSource;
pub const Ticker = async_loop.Ticker;
// Layout
pub const layout = @import("layout.zig");
@ -249,6 +290,13 @@ pub const widgets = struct {
pub const SyntaxLanguage = syntax_mod.Language;
pub const SyntaxTheme = syntax_mod.SyntaxTheme;
pub const TokenType = syntax_mod.TokenType;
pub const logo_mod = @import("widgets/logo.zig");
pub const Logo = logo_mod.Logo;
pub const LogoAnimation = logo_mod.Animation;
pub const LogoGradientDirection = logo_mod.GradientDirection;
pub const LogoAlignment = logo_mod.Alignment;
pub const predefined_logos = logo_mod.logos;
};
// Backend
@ -367,6 +415,27 @@ pub const prefersReducedMotion = accessibility.prefersReducedMotion;
pub const prefersHighContrast = accessibility.prefersHighContrast;
pub const high_contrast_theme = accessibility.high_contrast_theme;
// Configurable shortcuts
pub const shortcuts = @import("shortcuts.zig");
pub const Shortcut = shortcuts.Shortcut;
pub const ShortcutMap = shortcuts.ShortcutMap;
pub const ShortcutAction = shortcuts.Action;
pub const ShortcutModifiers = shortcuts.Modifiers;
pub const ShortcutContext = shortcuts.ShortcutContext;
// Ergonomic widget composition
pub const compose = @import("compose.zig");
pub const vstack = compose.vstack;
pub const hstack = compose.hstack;
pub const zstack = compose.zstack;
pub const sized = compose.sized;
pub const flexChild = compose.flex;
pub const fillChild = compose.fill;
pub const spacer = compose.spacer;
pub const splitV = compose.splitV;
pub const splitH = compose.splitH;
pub const splitV3 = compose.splitV3;
// ============================================================================
// Tests
// ============================================================================
@ -393,8 +462,11 @@ test {
_ = @import("theme_loader.zig");
_ = @import("serialize.zig");
_ = @import("accessibility.zig");
_ = @import("shortcuts.zig");
_ = @import("compose.zig");
// New widgets
_ = @import("widgets/logo.zig");
_ = @import("widgets/spinner.zig");
_ = @import("widgets/help.zig");
_ = @import("widgets/viewport.zig");

783
src/shortcuts.zig Normal file
View file

@ -0,0 +1,783 @@
//! Configurable Keyboard Shortcuts System
//!
//! Provides a flexible system for mapping keyboard events to actions,
//! with support for presets (vim, emacs, arrows), custom bindings,
//! conflict detection, and persistence.
//!
//! ## Example
//!
//! ```zig
//! const shortcuts = @import("shortcuts.zig");
//!
//! // Use a preset
//! var map = shortcuts.ShortcutMap.vim();
//!
//! // Customize a binding
//! map.rebind(.move_up, Shortcut.parse("Ctrl+k").?);
//!
//! // In event loop
//! if (map.getAction(key_event)) |action| {
//! switch (action) {
//! .move_up => cursor_up(),
//! .move_down => cursor_down(),
//! // ...
//! }
//! }
//! ```
const std = @import("std");
const KeyEvent = @import("event.zig").KeyEvent;
const KeyCode = @import("event.zig").KeyCode;
// ============================================================================
// Shortcut Definition
// ============================================================================
/// Key modifiers
pub const Modifiers = packed struct {
ctrl: bool = false,
alt: bool = false,
shift: bool = false,
super: bool = false,
pub const none = Modifiers{};
pub const ctrl_only = Modifiers{ .ctrl = true };
pub const alt_only = Modifiers{ .alt = true };
pub const shift_only = Modifiers{ .shift = true };
pub const ctrl_shift = Modifiers{ .ctrl = true, .shift = true };
pub const ctrl_alt = Modifiers{ .ctrl = true, .alt = true };
};
/// A single keyboard shortcut
pub const Shortcut = struct {
/// The key code
key: KeyCode,
/// Modifier keys
modifiers: Modifiers = .{},
/// Check if this shortcut matches a key event
pub fn matches(self: Shortcut, event_: KeyEvent) bool {
// Check key code
const key_matches = switch (self.key) {
.char => |c| switch (event_.code) {
.char => |ec| c == ec,
else => false,
},
.f => |n| switch (event_.code) {
.f => |en| n == en,
else => false,
},
.backspace => event_.code == .backspace,
.enter => event_.code == .enter,
.tab => event_.code == .tab,
.backtab => event_.code == .backtab,
.left => event_.code == .left,
.right => event_.code == .right,
.up => event_.code == .up,
.down => event_.code == .down,
.home => event_.code == .home,
.end => event_.code == .end,
.page_up => event_.code == .page_up,
.page_down => event_.code == .page_down,
.insert => event_.code == .insert,
.delete => event_.code == .delete,
.esc => event_.code == .esc,
.null_key => event_.code == .null_key,
else => false,
};
if (!key_matches) return false;
// Check modifiers
return self.modifiers.ctrl == event_.modifiers.ctrl and
self.modifiers.alt == event_.modifiers.alt and
self.modifiers.shift == event_.modifiers.shift;
}
/// Parse a shortcut from string like "Ctrl+Shift+S" or "Alt+F4"
pub fn parse(str: []const u8) ?Shortcut {
var mods = Modifiers{};
var remaining = str;
// Parse modifiers
while (true) {
if (startsWithIgnoreCase(remaining, "ctrl+")) {
mods.ctrl = true;
remaining = remaining[5..];
} else if (startsWithIgnoreCase(remaining, "alt+")) {
mods.alt = true;
remaining = remaining[4..];
} else if (startsWithIgnoreCase(remaining, "shift+")) {
mods.shift = true;
remaining = remaining[6..];
} else if (startsWithIgnoreCase(remaining, "super+") or startsWithIgnoreCase(remaining, "meta+")) {
mods.super = true;
remaining = remaining[5..];
} else {
break;
}
}
// Parse key
const key = parseKey(remaining) orelse return null;
return Shortcut{
.key = key,
.modifiers = mods,
};
}
/// Format shortcut to string
pub fn format(self: Shortcut, buf: []u8) []const u8 {
var fbs = std.io.fixedBufferStream(buf);
const writer = fbs.writer();
if (self.modifiers.ctrl) writer.writeAll("Ctrl+") catch {};
if (self.modifiers.alt) writer.writeAll("Alt+") catch {};
if (self.modifiers.shift) writer.writeAll("Shift+") catch {};
if (self.modifiers.super) writer.writeAll("Super+") catch {};
switch (self.key) {
.char => |c| {
if (c >= 'a' and c <= 'z') {
// Uppercase for display
writer.writeByte(@as(u8, @intCast(c)) - 32) catch {};
} else if (c < 128) {
writer.writeByte(@intCast(c)) catch {};
} else {
// Unicode character - write as UTF-8
var utf8_buf: [4]u8 = undefined;
const len = std.unicode.utf8Encode(c, &utf8_buf) catch 0;
writer.writeAll(utf8_buf[0..len]) catch {};
}
},
.f => |n| writer.print("F{d}", .{n}) catch {},
.up => writer.writeAll("Up") catch {},
.down => writer.writeAll("Down") catch {},
.left => writer.writeAll("Left") catch {},
.right => writer.writeAll("Right") catch {},
.enter => writer.writeAll("Enter") catch {},
.tab => writer.writeAll("Tab") catch {},
.backspace => writer.writeAll("Backspace") catch {},
.delete => writer.writeAll("Delete") catch {},
.home => writer.writeAll("Home") catch {},
.end => writer.writeAll("End") catch {},
.page_up => writer.writeAll("PageUp") catch {},
.page_down => writer.writeAll("PageDown") catch {},
.esc => writer.writeAll("Escape") catch {},
.insert => writer.writeAll("Insert") catch {},
else => writer.writeAll("?") catch {},
}
return fbs.getWritten();
}
// Predefined shortcuts
pub const escape = Shortcut{ .key = .esc };
pub const enter = Shortcut{ .key = .enter };
pub const tab = Shortcut{ .key = .tab };
pub const space = Shortcut{ .key = .{ .char = ' ' } };
pub const backspace = Shortcut{ .key = .backspace };
pub const up = Shortcut{ .key = .up };
pub const down = Shortcut{ .key = .down };
pub const left = Shortcut{ .key = .left };
pub const right = Shortcut{ .key = .right };
pub const home = Shortcut{ .key = .home };
pub const end = Shortcut{ .key = .end };
pub const page_up = Shortcut{ .key = .page_up };
pub const page_down = Shortcut{ .key = .page_down };
pub const ctrl_c = Shortcut{ .key = .{ .char = 'c' }, .modifiers = .{ .ctrl = true } };
pub const ctrl_v = Shortcut{ .key = .{ .char = 'v' }, .modifiers = .{ .ctrl = true } };
pub const ctrl_x = Shortcut{ .key = .{ .char = 'x' }, .modifiers = .{ .ctrl = true } };
pub const ctrl_z = Shortcut{ .key = .{ .char = 'z' }, .modifiers = .{ .ctrl = true } };
pub const ctrl_y = Shortcut{ .key = .{ .char = 'y' }, .modifiers = .{ .ctrl = true } };
pub const ctrl_s = Shortcut{ .key = .{ .char = 's' }, .modifiers = .{ .ctrl = true } };
pub const ctrl_a = Shortcut{ .key = .{ .char = 'a' }, .modifiers = .{ .ctrl = true } };
};
// ============================================================================
// Actions
// ============================================================================
/// Standard actions that can be bound to shortcuts
pub const Action = enum {
// Navigation
move_up,
move_down,
move_left,
move_right,
move_word_left,
move_word_right,
move_line_start,
move_line_end,
page_up,
page_down,
go_start,
go_end,
// Selection
select,
toggle,
select_all,
select_none,
extend_selection_up,
extend_selection_down,
// Editing
delete_char,
delete_word,
delete_line,
backspace,
copy,
paste,
cut,
undo,
redo,
// UI Navigation
focus_next,
focus_prev,
tab_next,
tab_prev,
// Actions
confirm,
cancel,
close,
quit,
help,
search,
refresh,
save,
// Resize
resize_grow_h,
resize_shrink_h,
resize_grow_v,
resize_shrink_v,
resize_maximize,
resize_restore,
// Custom actions (for app-specific use)
custom_1,
custom_2,
custom_3,
custom_4,
custom_5,
custom_6,
custom_7,
custom_8,
custom_9,
custom_10,
};
// ============================================================================
// Shortcut Map
// ============================================================================
/// Maximum shortcuts per action (for multi-key support)
pub const MAX_SHORTCUTS_PER_ACTION = 3;
/// Binding entry
pub const Binding = struct {
shortcuts: [MAX_SHORTCUTS_PER_ACTION]?Shortcut = .{ null, null, null },
pub fn matches(self: Binding, event_: KeyEvent) bool {
for (self.shortcuts) |opt_shortcut| {
if (opt_shortcut) |shortcut| {
if (shortcut.matches(event_)) return true;
}
}
return false;
}
pub fn set(self: *Binding, index: usize, shortcut: Shortcut) void {
if (index < MAX_SHORTCUTS_PER_ACTION) {
self.shortcuts[index] = shortcut;
}
}
pub fn clear(self: *Binding) void {
self.shortcuts = .{ null, null, null };
}
};
/// Map of actions to shortcuts
pub const ShortcutMap = struct {
bindings: [@typeInfo(Action).@"enum".fields.len]Binding,
/// Initialize empty map
pub fn init() ShortcutMap {
return .{
.bindings = [_]Binding{.{}} ** @typeInfo(Action).@"enum".fields.len,
};
}
/// Get the binding for an action
pub fn get(self: *const ShortcutMap, action: Action) Binding {
return self.bindings[@intFromEnum(action)];
}
/// Set primary shortcut for an action
pub fn bind(self: *ShortcutMap, action: Action, shortcut: Shortcut) void {
self.bindings[@intFromEnum(action)].shortcuts[0] = shortcut;
}
/// Add alternative shortcut for an action
pub fn bindAlt(self: *ShortcutMap, action: Action, shortcut: Shortcut) void {
const binding = &self.bindings[@intFromEnum(action)];
for (&binding.shortcuts) |*slot| {
if (slot.* == null) {
slot.* = shortcut;
return;
}
}
}
/// Clear all shortcuts for an action
pub fn unbind(self: *ShortcutMap, action: Action) void {
self.bindings[@intFromEnum(action)].clear();
}
/// Find action for a key event
pub fn getAction(self: *const ShortcutMap, event_: KeyEvent) ?Action {
for (self.bindings, 0..) |binding, i| {
if (binding.matches(event_)) {
return @enumFromInt(i);
}
}
return null;
}
/// Find all conflicts (multiple actions with same shortcut)
pub fn findConflicts(self: *const ShortcutMap, allocator: std.mem.Allocator) ![]Conflict {
var conflicts = std.ArrayListUnmanaged(Conflict){};
errdefer conflicts.deinit(allocator);
// Compare each binding against all others
for (self.bindings, 0..) |binding1, i| {
for (binding1.shortcuts) |opt_s1| {
if (opt_s1) |s1| {
for (self.bindings[i + 1 ..], i + 1..) |binding2, j| {
for (binding2.shortcuts) |opt_s2| {
if (opt_s2) |s2| {
if (shortcutsEqual(s1, s2)) {
try conflicts.append(allocator, .{
.shortcut = s1,
.action1 = @enumFromInt(i),
.action2 = @enumFromInt(j),
});
}
}
}
}
}
}
}
return conflicts.toOwnedSlice(allocator);
}
// ========================================
// Presets
// ========================================
/// Vim-style keybindings
pub fn vim() ShortcutMap {
var map = ShortcutMap.init();
// Navigation
map.bind(.move_up, .{ .key = .{ .char = 'k' } });
map.bindAlt(.move_up, Shortcut.up);
map.bind(.move_down, .{ .key = .{ .char = 'j' } });
map.bindAlt(.move_down, Shortcut.down);
map.bind(.move_left, .{ .key = .{ .char = 'h' } });
map.bindAlt(.move_left, Shortcut.left);
map.bind(.move_right, .{ .key = .{ .char = 'l' } });
map.bindAlt(.move_right, Shortcut.right);
map.bind(.move_word_left, .{ .key = .{ .char = 'b' } });
map.bind(.move_word_right, .{ .key = .{ .char = 'w' } });
map.bind(.move_line_start, .{ .key = .{ .char = '0' } });
map.bind(.move_line_end, .{ .key = .{ .char = '$' } });
map.bind(.page_up, .{ .key = .{ .char = 'u' }, .modifiers = .{ .ctrl = true } });
map.bindAlt(.page_up, Shortcut.page_up);
map.bind(.page_down, .{ .key = .{ .char = 'd' }, .modifiers = .{ .ctrl = true } });
map.bindAlt(.page_down, Shortcut.page_down);
map.bind(.go_start, .{ .key = .{ .char = 'g' } }); // gg in vim, simplified
map.bindAlt(.go_start, Shortcut.home);
map.bind(.go_end, .{ .key = .{ .char = 'G' }, .modifiers = .{ .shift = true } });
map.bindAlt(.go_end, Shortcut.end);
// Selection/Action
map.bind(.select, Shortcut.enter);
map.bind(.toggle, Shortcut.space);
// Editing
map.bind(.delete_char, .{ .key = .{ .char = 'x' } });
map.bind(.delete_line, .{ .key = .{ .char = 'd' } }); // dd in vim, simplified
map.bind(.copy, .{ .key = .{ .char = 'y' } }); // yy in vim
map.bind(.paste, .{ .key = .{ .char = 'p' } });
map.bind(.undo, .{ .key = .{ .char = 'u' } });
map.bind(.redo, .{ .key = .{ .char = 'r' }, .modifiers = .{ .ctrl = true } });
// UI
map.bind(.cancel, Shortcut.escape);
map.bind(.quit, .{ .key = .{ .char = 'q' } });
map.bind(.search, .{ .key = .{ .char = '/' } });
map.bind(.help, .{ .key = .{ .char = '?' } });
// Focus
map.bind(.focus_next, Shortcut.tab);
map.bind(.focus_prev, .{ .key = .tab, .modifiers = .{ .shift = true } });
return map;
}
/// Arrow keys / traditional style
pub fn arrows() ShortcutMap {
var map = ShortcutMap.init();
// Navigation
map.bind(.move_up, Shortcut.up);
map.bind(.move_down, Shortcut.down);
map.bind(.move_left, Shortcut.left);
map.bind(.move_right, Shortcut.right);
map.bind(.move_word_left, .{ .key = .left, .modifiers = .{ .ctrl = true } });
map.bind(.move_word_right, .{ .key = .right, .modifiers = .{ .ctrl = true } });
map.bind(.move_line_start, Shortcut.home);
map.bind(.move_line_end, Shortcut.end);
map.bind(.page_up, Shortcut.page_up);
map.bind(.page_down, Shortcut.page_down);
map.bind(.go_start, .{ .key = .home, .modifiers = .{ .ctrl = true } });
map.bind(.go_end, .{ .key = .end, .modifiers = .{ .ctrl = true } });
// Selection
map.bind(.select, Shortcut.enter);
map.bind(.toggle, Shortcut.space);
map.bind(.select_all, Shortcut.ctrl_a);
// Editing
map.bind(.delete_char, .{ .key = .delete });
map.bind(.backspace, Shortcut.backspace);
map.bind(.copy, Shortcut.ctrl_c);
map.bind(.paste, Shortcut.ctrl_v);
map.bind(.cut, Shortcut.ctrl_x);
map.bind(.undo, Shortcut.ctrl_z);
map.bind(.redo, Shortcut.ctrl_y);
map.bind(.save, Shortcut.ctrl_s);
// UI
map.bind(.cancel, Shortcut.escape);
map.bind(.close, .{ .key = .{ .char = 'w' }, .modifiers = .{ .ctrl = true } });
map.bind(.quit, .{ .key = .{ .char = 'q' }, .modifiers = .{ .ctrl = true } });
map.bind(.search, .{ .key = .{ .char = 'f' }, .modifiers = .{ .ctrl = true } });
map.bind(.help, .{ .key = .{ .f = 1 }, .modifiers = .{} }); // F1
map.bindAlt(.help, .{ .key = .{ .char = '?' } });
// Focus
map.bind(.focus_next, Shortcut.tab);
map.bind(.focus_prev, .{ .key = .tab, .modifiers = .{ .shift = true } });
// Resize
map.bind(.resize_grow_h, .{ .key = .right, .modifiers = .{ .ctrl = true, .shift = true } });
map.bind(.resize_shrink_h, .{ .key = .left, .modifiers = .{ .ctrl = true, .shift = true } });
map.bind(.resize_grow_v, .{ .key = .down, .modifiers = .{ .ctrl = true, .shift = true } });
map.bind(.resize_shrink_v, .{ .key = .up, .modifiers = .{ .ctrl = true, .shift = true } });
return map;
}
/// Emacs-style keybindings
pub fn emacs() ShortcutMap {
var map = ShortcutMap.init();
// Navigation (C-p, C-n, C-b, C-f)
map.bind(.move_up, .{ .key = .{ .char = 'p' }, .modifiers = .{ .ctrl = true } });
map.bindAlt(.move_up, Shortcut.up);
map.bind(.move_down, .{ .key = .{ .char = 'n' }, .modifiers = .{ .ctrl = true } });
map.bindAlt(.move_down, Shortcut.down);
map.bind(.move_left, .{ .key = .{ .char = 'b' }, .modifiers = .{ .ctrl = true } });
map.bindAlt(.move_left, Shortcut.left);
map.bind(.move_right, .{ .key = .{ .char = 'f' }, .modifiers = .{ .ctrl = true } });
map.bindAlt(.move_right, Shortcut.right);
map.bind(.move_word_left, .{ .key = .{ .char = 'b' }, .modifiers = .{ .alt = true } });
map.bind(.move_word_right, .{ .key = .{ .char = 'f' }, .modifiers = .{ .alt = true } });
map.bind(.move_line_start, .{ .key = .{ .char = 'a' }, .modifiers = .{ .ctrl = true } });
map.bind(.move_line_end, .{ .key = .{ .char = 'e' }, .modifiers = .{ .ctrl = true } });
map.bind(.page_up, .{ .key = .{ .char = 'v' }, .modifiers = .{ .alt = true } });
map.bindAlt(.page_up, Shortcut.page_up);
map.bind(.page_down, .{ .key = .{ .char = 'v' }, .modifiers = .{ .ctrl = true } });
map.bindAlt(.page_down, Shortcut.page_down);
map.bind(.go_start, .{ .key = .{ .char = '<' }, .modifiers = .{ .alt = true } });
map.bind(.go_end, .{ .key = .{ .char = '>' }, .modifiers = .{ .alt = true } });
// Selection
map.bind(.select, Shortcut.enter);
// Editing
map.bind(.delete_char, .{ .key = .{ .char = 'd' }, .modifiers = .{ .ctrl = true } });
map.bind(.backspace, Shortcut.backspace);
map.bind(.delete_word, .{ .key = .{ .char = 'd' }, .modifiers = .{ .alt = true } });
map.bind(.delete_line, .{ .key = .{ .char = 'k' }, .modifiers = .{ .ctrl = true } });
map.bind(.copy, .{ .key = .{ .char = 'w' }, .modifiers = .{ .alt = true } });
map.bind(.paste, .{ .key = .{ .char = 'y' }, .modifiers = .{ .ctrl = true } });
map.bind(.undo, .{ .key = .{ .char = '/' }, .modifiers = .{ .ctrl = true } });
// UI
map.bind(.cancel, .{ .key = .{ .char = 'g' }, .modifiers = .{ .ctrl = true } });
map.bindAlt(.cancel, Shortcut.escape);
map.bind(.quit, .{ .key = .{ .char = 'c' }, .modifiers = .{ .ctrl = true } }); // C-x C-c simplified
map.bind(.search, .{ .key = .{ .char = 's' }, .modifiers = .{ .ctrl = true } });
map.bind(.save, .{ .key = .{ .char = 'x' }, .modifiers = .{ .ctrl = true } }); // C-x C-s simplified
return map;
}
/// Minimal - only essential bindings
pub fn minimal() ShortcutMap {
var map = ShortcutMap.init();
map.bind(.move_up, Shortcut.up);
map.bind(.move_down, Shortcut.down);
map.bind(.move_left, Shortcut.left);
map.bind(.move_right, Shortcut.right);
map.bind(.select, Shortcut.enter);
map.bind(.cancel, Shortcut.escape);
map.bind(.focus_next, Shortcut.tab);
return map;
}
};
/// Conflict between two actions
pub const Conflict = struct {
shortcut: Shortcut,
action1: Action,
action2: Action,
};
// ============================================================================
// Shortcut Context
// ============================================================================
/// Context for hierarchical shortcuts (mode-specific)
pub const ShortcutContext = struct {
name: []const u8,
map: ShortcutMap,
parent: ?*const ShortcutContext = null,
/// Get action, checking this context first, then parent
pub fn getAction(self: *const ShortcutContext, event_: KeyEvent) ?Action {
if (self.map.getAction(event_)) |action| {
return action;
}
if (self.parent) |p| {
return p.getAction(event_);
}
return null;
}
/// Create child context
pub fn child(self: *const ShortcutContext, name: []const u8) ShortcutContext {
return .{
.name = name,
.map = ShortcutMap.init(),
.parent = self,
};
}
};
// ============================================================================
// Helper Functions
// ============================================================================
fn startsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (haystack.len < needle.len) return false;
for (haystack[0..needle.len], needle) |h, n| {
const h_lower = if (h >= 'A' and h <= 'Z') h + 32 else h;
const n_lower = if (n >= 'A' and n <= 'Z') n + 32 else n;
if (h_lower != n_lower) return false;
}
return true;
}
fn parseKey(str: []const u8) ?KeyCode {
if (str.len == 0) return null;
// Single character
if (str.len == 1) {
// Convert to lowercase for consistency
const c = str[0];
const lower = if (c >= 'A' and c <= 'Z') c + 32 else c;
return .{ .char = lower };
}
// Named keys
if (eqlIgnoreCase(str, "up")) return .up;
if (eqlIgnoreCase(str, "down")) return .down;
if (eqlIgnoreCase(str, "left")) return .left;
if (eqlIgnoreCase(str, "right")) return .right;
if (eqlIgnoreCase(str, "enter") or eqlIgnoreCase(str, "return")) return .enter;
if (eqlIgnoreCase(str, "tab")) return .tab;
if (eqlIgnoreCase(str, "backspace") or eqlIgnoreCase(str, "bs")) return .backspace;
if (eqlIgnoreCase(str, "delete") or eqlIgnoreCase(str, "del")) return .delete;
if (eqlIgnoreCase(str, "home")) return .home;
if (eqlIgnoreCase(str, "end")) return .end;
if (eqlIgnoreCase(str, "pageup") or eqlIgnoreCase(str, "pgup")) return .page_up;
if (eqlIgnoreCase(str, "pagedown") or eqlIgnoreCase(str, "pgdn")) return .page_down;
if (eqlIgnoreCase(str, "escape") or eqlIgnoreCase(str, "esc")) return .esc;
if (eqlIgnoreCase(str, "insert") or eqlIgnoreCase(str, "ins")) return .insert;
if (eqlIgnoreCase(str, "space")) return .{ .char = ' ' };
// Function keys F1-F12
if (str.len >= 2 and (str[0] == 'F' or str[0] == 'f')) {
const num = std.fmt.parseInt(u8, str[1..], 10) catch return null;
if (num >= 1 and num <= 12) {
return .{ .f = num };
}
}
return null;
}
fn eqlIgnoreCase(a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
for (a, b) |ac, bc| {
const a_lower = if (ac >= 'A' and ac <= 'Z') ac + 32 else ac;
const b_lower = if (bc >= 'A' and bc <= 'Z') bc + 32 else bc;
if (a_lower != b_lower) return false;
}
return true;
}
fn shortcutsEqual(s1: Shortcut, s2: Shortcut) bool {
// Compare keys
const keys_equal = switch (s1.key) {
.char => |c1| switch (s2.key) {
.char => |c2| c1 == c2,
else => false,
},
else => s1.key == s2.key,
};
if (!keys_equal) return false;
// Compare modifiers
return s1.modifiers.ctrl == s2.modifiers.ctrl and
s1.modifiers.alt == s2.modifiers.alt and
s1.modifiers.shift == s2.modifiers.shift and
s1.modifiers.super == s2.modifiers.super;
}
// ============================================================================
// Tests
// ============================================================================
test "Shortcut.parse basic" {
const s1 = Shortcut.parse("Enter").?;
try std.testing.expectEqual(KeyCode.enter, s1.key);
const s2 = Shortcut.parse("Ctrl+S").?;
try std.testing.expectEqual(KeyCode{ .char = 's' }, s2.key);
try std.testing.expect(s2.modifiers.ctrl);
const s3 = Shortcut.parse("Ctrl+Shift+P").?;
try std.testing.expect(s3.modifiers.ctrl);
try std.testing.expect(s3.modifiers.shift);
const s4 = Shortcut.parse("F5").?;
try std.testing.expectEqual(KeyCode{ .f = 5 }, s4.key);
}
test "Shortcut.format" {
var buf: [32]u8 = undefined;
const s1 = Shortcut{ .key = .enter };
try std.testing.expectEqualStrings("Enter", s1.format(&buf));
const s2 = Shortcut{ .key = .{ .char = 's' }, .modifiers = .{ .ctrl = true } };
try std.testing.expectEqualStrings("Ctrl+S", s2.format(&buf));
}
test "ShortcutMap.vim preset" {
const map = ShortcutMap.vim();
// j should be move_down
const j_event = KeyEvent{ .code = .{ .char = 'j' }, .modifiers = .{} };
try std.testing.expectEqual(Action.move_down, map.getAction(j_event).?);
// k should be move_up
const k_event = KeyEvent{ .code = .{ .char = 'k' }, .modifiers = .{} };
try std.testing.expectEqual(Action.move_up, map.getAction(k_event).?);
// q should be quit
const q_event = KeyEvent{ .code = .{ .char = 'q' }, .modifiers = .{} };
try std.testing.expectEqual(Action.quit, map.getAction(q_event).?);
}
test "ShortcutMap.arrows preset" {
const map = ShortcutMap.arrows();
const up_event = KeyEvent{ .code = .up, .modifiers = .{} };
try std.testing.expectEqual(Action.move_up, map.getAction(up_event).?);
const ctrl_z = KeyEvent{ .code = .{ .char = 'z' }, .modifiers = .{ .ctrl = true } };
try std.testing.expectEqual(Action.undo, map.getAction(ctrl_z).?);
}
test "ShortcutMap custom binding" {
var map = ShortcutMap.minimal();
// Add custom binding
map.bind(.custom_1, Shortcut.parse("Ctrl+1").?);
const event_ = KeyEvent{ .code = .{ .char = '1' }, .modifiers = .{ .ctrl = true } };
try std.testing.expectEqual(Action.custom_1, map.getAction(event_).?);
}
test "ShortcutContext hierarchy" {
const base = ShortcutContext{
.name = "base",
.map = ShortcutMap.minimal(),
};
var child_map = ShortcutMap.init();
child_map.bind(.custom_1, .{ .key = .{ .char = 'x' } });
const child_ctx = ShortcutContext{
.name = "child",
.map = child_map,
.parent = &base,
};
// Child-specific binding
const x_event = KeyEvent{ .code = .{ .char = 'x' }, .modifiers = .{} };
try std.testing.expectEqual(Action.custom_1, child_ctx.getAction(x_event).?);
// Inherited from parent
const enter_event = KeyEvent{ .code = .enter, .modifiers = .{} };
try std.testing.expectEqual(Action.select, child_ctx.getAction(enter_event).?);
}
test "parseKey function keys" {
try std.testing.expectEqual(KeyCode{ .f = 1 }, parseKey("F1").?);
try std.testing.expectEqual(KeyCode{ .f = 12 }, parseKey("F12").?);
try std.testing.expectEqual(KeyCode{ .f = 5 }, parseKey("f5").?);
}
test "eqlIgnoreCase" {
try std.testing.expect(eqlIgnoreCase("Enter", "enter"));
try std.testing.expect(eqlIgnoreCase("CTRL", "ctrl"));
try std.testing.expect(!eqlIgnoreCase("a", "b"));
}

316
src/sixel.zig Normal file
View file

@ -0,0 +1,316 @@
//! Sixel graphics support for zcatui.
//!
//! Sixel is a bitmap graphics format that can be displayed in compatible terminals
//! like xterm (with -ti vt340), mlterm, and others.
//!
//! ## Features
//!
//! - Convert RGB pixels to Sixel format
//! - Palette optimization
//! - Render to terminal at specific positions
//!
//! ## Supported Terminals
//!
//! - xterm (with sixel support enabled)
//! - mlterm
//! - foot
//! - WezTerm
//! - Some versions of Windows Terminal
//!
//! ## Example
//!
//! ```zig
//! var encoder = SixelEncoder.init(allocator);
//! defer encoder.deinit();
//!
//! // Create a simple red square
//! var pixels: [100]Pixel = undefined;
//! for (&pixels) |*p| p.* = .{ .r = 255, .g = 0, .b = 0 };
//!
//! const sixel = try encoder.encode(&pixels, 10, 10);
//! defer allocator.free(sixel);
//!
//! // Write to terminal at position
//! try term.backend.stdout.writeAll(sixel);
//! ```
const std = @import("std");
/// An RGB pixel.
pub const Pixel = struct {
r: u8,
g: u8,
b: u8,
a: u8 = 255,
pub fn rgb(r: u8, g: u8, b: u8) Pixel {
return .{ .r = r, .g = g, .b = b };
}
pub fn rgba(r: u8, g: u8, b: u8, a: u8) Pixel {
return .{ .r = r, .g = g, .b = b, .a = a };
}
/// Converts to palette index using simple quantization.
pub fn toPaletteIndex(self: Pixel, palette_size: u8) u8 {
// Simple RGB quantization
const levels = @as(u8, @intCast(std.math.cbrt(@as(f32, @floatFromInt(palette_size)))));
const r_idx = @as(u8, @intCast(@as(u16, self.r) * (levels - 1) / 255));
const g_idx = @as(u8, @intCast(@as(u16, self.g) * (levels - 1) / 255));
const b_idx = @as(u8, @intCast(@as(u16, self.b) * (levels - 1) / 255));
return r_idx * levels * levels + g_idx * levels + b_idx;
}
};
/// Sixel encoder configuration.
pub const SixelConfig = struct {
/// Number of colors in palette (max 256).
palette_size: u8 = 256,
/// Whether to use transparency.
transparent: bool = false,
/// Transparent color index.
transparent_index: u8 = 0,
};
/// Sixel graphics encoder.
pub const SixelEncoder = struct {
allocator: std.mem.Allocator,
config: SixelConfig,
/// Creates a new Sixel encoder.
pub fn init(allocator: std.mem.Allocator) SixelEncoder {
return .{
.allocator = allocator,
.config = .{},
};
}
/// Creates with custom configuration.
pub fn initWithConfig(allocator: std.mem.Allocator, config: SixelConfig) SixelEncoder {
return .{
.allocator = allocator,
.config = config,
};
}
pub fn deinit(self: *SixelEncoder) void {
_ = self;
}
/// Encodes pixels to Sixel format.
pub fn encode(self: *SixelEncoder, pixels: []const Pixel, width: usize, height: usize) ![]u8 {
var result = std.ArrayList(u8).init(self.allocator);
errdefer result.deinit();
const writer = result.writer();
// Sixel start sequence: ESC P q
try writer.writeAll("\x1bPq");
// Set raster attributes: "Pan;Pad;Ph;Pv
// Pan = pixel aspect ratio numerator
// Pad = pixel aspect ratio denominator
// Ph = horizontal size
// Pv = vertical size
try writer.print("\"1;1;{d};{d}", .{ width, height });
// Generate palette
try self.writePalette(writer);
// Encode pixels row by row (6 rows = 1 sixel row)
const sixel_rows = (height + 5) / 6;
for (0..sixel_rows) |sixel_row| {
try self.encodeSixelRow(writer, pixels, width, height, sixel_row);
if (sixel_row < sixel_rows - 1) {
try writer.writeByte('-'); // Graphics newline
}
}
// Sixel end sequence: ESC \
try writer.writeAll("\x1b\\");
return result.toOwnedSlice();
}
fn writePalette(self: *SixelEncoder, writer: anytype) !void {
// Generate a simple RGB palette
const levels: u8 = @intCast(std.math.cbrt(@as(f32, @floatFromInt(self.config.palette_size))));
var idx: u8 = 0;
for (0..levels) |r| {
for (0..levels) |g| {
for (0..levels) |b| {
if (idx >= self.config.palette_size) break;
// Sixel palette: #idx;2;R;G;B (2 = RGB mode, values 0-100)
const r_pct = @as(u8, @intCast(r * 100 / (levels - 1)));
const g_pct = @as(u8, @intCast(g * 100 / (levels - 1)));
const b_pct = @as(u8, @intCast(b * 100 / (levels - 1)));
try writer.print("#{d};2;{d};{d};{d}", .{ idx, r_pct, g_pct, b_pct });
idx += 1;
}
}
}
}
fn encodeSixelRow(
self: *SixelEncoder,
writer: anytype,
pixels: []const Pixel,
width: usize,
height: usize,
sixel_row: usize,
) !void {
// Each sixel character represents 6 vertical pixels
const start_y = sixel_row * 6;
// Process each color in palette
for (0..self.config.palette_size) |color_idx| {
var has_color = false;
var run_start: ?usize = null;
var run_char: u8 = 0;
// Select color
var color_data = std.ArrayList(u8).init(self.allocator);
defer color_data.deinit();
const color_writer = color_data.writer();
for (0..width) |x| {
var sixel_bits: u8 = 0;
// Build sixel character from 6 vertical pixels
for (0..6) |bit| {
const y = start_y + bit;
if (y < height) {
const pixel_idx = y * width + x;
if (pixel_idx < pixels.len) {
const pixel = pixels[pixel_idx];
const pixel_color = pixel.toPaletteIndex(self.config.palette_size);
if (pixel_color == color_idx) {
sixel_bits |= @as(u8, 1) << @intCast(bit);
}
}
}
}
// Convert to sixel character (add 63)
const sixel_char = sixel_bits + 63;
// Run-length encode
if (run_start == null) {
run_start = x;
run_char = sixel_char;
} else if (sixel_char == run_char) {
// Continue run
} else {
// End run, write it
const run_len = x - run_start.?;
if (run_char != 63) { // Skip empty
has_color = true;
try writeRun(color_writer, run_char, run_len);
} else if (has_color) {
try writeRun(color_writer, run_char, run_len);
}
run_start = x;
run_char = sixel_char;
}
}
// Write final run
if (run_start) |start| {
const run_len = width - start;
if (run_char != 63) {
has_color = true;
try writeRun(color_writer, run_char, run_len);
}
}
// Write color data if any
if (has_color) {
try writer.print("#{d}", .{color_idx});
try writer.writeAll(color_data.items);
try writer.writeByte('$'); // Carriage return (stay on same line)
}
}
}
fn writeRun(writer: anytype, char: u8, len: usize) !void {
if (len == 1) {
try writer.writeByte(char);
} else if (len == 2) {
try writer.writeByte(char);
try writer.writeByte(char);
} else {
try writer.print("!{d}{c}", .{ len, char });
}
}
};
/// Checks if the terminal supports Sixel graphics.
pub fn isSixelSupported() bool {
// Check TERM environment variable
const term = std.posix.getenv("TERM") orelse return false;
// Known Sixel-supporting terminals
const sixel_terms = [_][]const u8{
"xterm",
"xterm-256color",
"mlterm",
"yaft",
"foot",
"contour",
};
for (sixel_terms) |t| {
if (std.mem.eql(u8, term, t)) return true;
}
// Also check for sixel in TERM
if (std.mem.indexOf(u8, term, "sixel") != null) return true;
return false;
}
/// Positions cursor for Sixel output.
pub fn positionForSixel(writer: anytype, x: u16, y: u16) !void {
// Move cursor to position
try writer.print("\x1b[{d};{d}H", .{ y + 1, x + 1 });
}
// ============================================================================
// Tests
// ============================================================================
test "Pixel creation" {
const p = Pixel.rgb(255, 128, 64);
try std.testing.expectEqual(@as(u8, 255), p.r);
try std.testing.expectEqual(@as(u8, 128), p.g);
try std.testing.expectEqual(@as(u8, 64), p.b);
}
test "Pixel palette index" {
const red = Pixel.rgb(255, 0, 0);
const idx = red.toPaletteIndex(64);
try std.testing.expect(idx < 64);
}
test "SixelEncoder basic" {
const allocator = std.testing.allocator;
var encoder = SixelEncoder.init(allocator);
defer encoder.deinit();
// Create a 2x2 red image
const pixels = [_]Pixel{
Pixel.rgb(255, 0, 0),
Pixel.rgb(255, 0, 0),
Pixel.rgb(255, 0, 0),
Pixel.rgb(255, 0, 0),
};
const sixel = try encoder.encode(&pixels, 2, 2);
defer allocator.free(sixel);
// Check it starts with ESC P q
try std.testing.expect(std.mem.startsWith(u8, sixel, "\x1bPq"));
// Check it ends with ESC \
try std.testing.expect(std.mem.endsWith(u8, sixel, "\x1b\\"));
}

View file

@ -37,6 +37,9 @@ const event_mod = @import("event.zig");
const Event = event_mod.Event;
const event_reader = @import("event/reader.zig");
const EventReader = event_reader.EventReader;
const resize_mod = @import("resize.zig");
const ResizeHandler = resize_mod.ResizeHandler;
pub const Size = resize_mod.Size;
/// Terminal provides the main interface for TUI applications.
///
@ -48,6 +51,8 @@ pub const Terminal = struct {
current_buffer: Buffer,
previous_buffer: Buffer,
event_reader: EventReader,
resize_handler: ?ResizeHandler = null,
auto_resize_enabled: bool = false,
mouse_enabled: bool = false,
focus_enabled: bool = false,
bracketed_paste_enabled: bool = false,
@ -87,7 +92,7 @@ pub const Terminal = struct {
/// Cleans up terminal state.
///
/// Shows cursor, exits alternate screen, and restores terminal mode.
/// Also disables any enabled features (mouse, focus, paste).
/// Also disables any enabled features (mouse, focus, paste, resize handler).
pub fn deinit(self: *Terminal) void {
// Disable enabled features
if (self.mouse_enabled) {
@ -100,6 +105,11 @@ pub const Terminal = struct {
self.disableBracketedPaste() catch {};
}
// Clean up resize handler
if (self.resize_handler) |*handler| {
handler.deinit();
}
self.backend.disableRawMode() catch {};
self.backend.showCursor() catch {};
self.backend.leaveAlternateScreen() catch {};
@ -122,7 +132,15 @@ pub const Terminal = struct {
///
/// The render function receives the terminal area and buffer,
/// and should render all widgets to the buffer.
///
/// If auto-resize is enabled, this will automatically detect and
/// handle terminal size changes before rendering.
pub fn draw(self: *Terminal, comptime render_fn: fn (Rect, *Buffer) void) !void {
// Check for resize if enabled
if (self.auto_resize_enabled) {
_ = try self.checkAndHandleResize();
}
// Clear buffer
self.current_buffer.clear();
@ -134,11 +152,19 @@ pub const Terminal = struct {
}
/// Draws using a context-aware render function.
///
/// If auto-resize is enabled, this will automatically detect and
/// handle terminal size changes before rendering.
pub fn drawWithContext(
self: *Terminal,
context: anytype,
comptime render_fn: fn (@TypeOf(context), Rect, *Buffer) void,
) !void {
// Check for resize if enabled
if (self.auto_resize_enabled) {
_ = try self.checkAndHandleResize();
}
self.current_buffer.clear();
render_fn(context, self.area(), &self.current_buffer);
try self.flush();
@ -186,6 +212,74 @@ pub const Terminal = struct {
self.current_buffer.markDirty();
}
// ========================================================================
// Resize Handling
// ========================================================================
/// Enables automatic terminal resize detection.
///
/// When enabled, the terminal will automatically detect size changes
/// (via SIGWINCH) and resize buffers accordingly during draw() calls.
///
/// This is the recommended way to handle terminal resizes in most applications.
///
/// Example:
/// ```zig
/// var term = try Terminal.init(allocator);
/// term.enableAutoResize();
/// // Now resize is handled automatically during draw()
/// ```
pub fn enableAutoResize(self: *Terminal) void {
if (self.resize_handler == null) {
self.resize_handler = ResizeHandler.init();
}
self.auto_resize_enabled = true;
}
/// Disables automatic resize detection.
///
/// After calling this, you must handle resize events manually.
pub fn disableAutoResize(self: *Terminal) void {
self.auto_resize_enabled = false;
}
/// Gets the current terminal size.
///
/// Queries the terminal directly for the latest dimensions.
pub fn getSize(self: *Terminal) Size {
const backend_size = self.backend.getSize();
return Size{
.width = backend_size.width,
.height = backend_size.height,
};
}
/// Checks if a resize has occurred and handles it.
///
/// Returns true if resize was detected and handled.
/// This is called automatically by draw() when auto-resize is enabled.
pub fn checkAndHandleResize(self: *Terminal) !bool {
if (self.resize_handler) |*handler| {
if (handler.hasResized()) {
const new_size = handler.getLastKnownSize();
try self.resize(new_size.width, new_size.height);
return true;
}
}
return false;
}
/// Checks if a resize event is pending.
///
/// This is useful if you want to check for resize without handling it.
pub fn isResizePending(self: *const Terminal) bool {
if (self.resize_handler) |handler| {
_ = handler;
return resize_mod.isResizePending();
}
return false;
}
// ========================================================================
// Event Handling
// ========================================================================

View file

@ -187,8 +187,8 @@ pub const TreeSymbols = struct {
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
nodes: std.ArrayListUnmanaged(DirNode),
flat_view: std.ArrayListUnmanaged(usize), // Indices into nodes for visible items
selected: usize = 0,
scroll_offset: u16 = 0,
theme: DirTreeTheme = DirTreeTheme.default,
@ -204,12 +204,12 @@ pub const DirectoryTree = struct {
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),
.nodes = .{},
.flat_view = .{},
};
// Add root node
try tree.nodes.append(.{
try tree.nodes.append(allocator, .{
.name = try allocator.dupe(u8, std.fs.path.basename(root_path)),
.path = tree.root_path,
.kind = .directory,
@ -235,8 +235,8 @@ pub const DirectoryTree = struct {
self.allocator.free(node.path);
}
}
self.nodes.deinit();
self.flat_view.deinit();
self.nodes.deinit(self.allocator);
self.flat_view.deinit(self.allocator);
self.allocator.free(self.root_path);
}
@ -245,8 +245,7 @@ pub const DirectoryTree = struct {
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;
var dir = fs.openDirAbsolute(node.path, .{ .iterate = true }) catch {
node.loaded = true;
return;
};
@ -272,7 +271,7 @@ pub const DirectoryTree = struct {
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(.{
try self.nodes.append(self.allocator, .{
.name = name,
.path = full_path,
.kind = FileKind.fromEntry(entry),
@ -305,7 +304,7 @@ pub const DirectoryTree = struct {
}
fn addToFlatView(self: *DirectoryTree, node_idx: usize) !void {
try self.flat_view.append(node_idx);
try self.flat_view.append(self.allocator, node_idx);
const node = self.nodes.items[node_idx];
if (node.expanded and node.loaded) {

678
src/widgets/logo.zig Normal file
View file

@ -0,0 +1,678 @@
//! Logo Widget - ASCII Art Display with Animations
//!
//! Renders ASCII art logos with support for:
//! - Multi-line ASCII art text
//! - Color gradients (vertical, horizontal, diagonal)
//! - Animations (typewriter, fade, slide, rainbow, pulse)
//! - Alignment options
//!
//! ## Example
//!
//! ```zig
//! const logo = Logo.init()
//! .setText(
//! \\ _______ _______ ______
//! \\ |___ | _ |_ _|
//! \\ ___| | | | |
//! \\ |_______|___|___| |__|
//! )
//! .setStyle(Style{}.fg(Color.cyan).bold())
//! .setAlignment(.center);
//!
//! logo.render(area, buf);
//!
//! // With gradient
//! const gradient_logo = Logo.init()
//! .setText(ascii_art)
//! .setGradient(&.{ Color.red, Color.yellow, Color.green });
//!
//! // With animation
//! const animated = Logo.init()
//! .setText(ascii_art)
//! .setAnimation(.typewriter)
//! .tick(frame_count); // Call each frame
//! ```
const std = @import("std");
const Buffer = @import("../buffer.zig").Buffer;
const Rect = @import("../buffer.zig").Rect;
const Cell = @import("../buffer.zig").Cell;
const Style = @import("../style.zig").Style;
const Color = @import("../style.zig").Color;
/// Animation types for logo display
pub const Animation = enum {
/// No animation (static display)
none,
/// Characters appear one by one
typewriter,
/// Fade in using block characters
fade_in,
/// Lines appear from top to bottom
slide_down,
/// Lines appear from bottom to top
slide_up,
/// Continuous rainbow color cycling
rainbow,
/// Pulsing brightness effect
pulse,
/// Glitch/matrix effect
glitch,
};
/// Gradient direction
pub const GradientDirection = enum {
/// Top to bottom
vertical,
/// Left to right
horizontal,
/// Top-left to bottom-right
diagonal,
/// Center outward
radial,
};
/// Text alignment
pub const Alignment = enum {
left,
center,
right,
};
/// Logo widget for displaying ASCII art
pub const Logo = struct {
/// ASCII art text (multi-line)
text: []const u8 = "",
/// Base style for rendering
style: Style = Style{},
/// Text alignment
alignment: Alignment = .center,
/// Animation type
animation: Animation = .none,
/// Animation progress (0-1000 for precision)
progress: u32 = 1000,
/// Animation speed (ticks per frame)
speed: u32 = 50,
/// Gradient colors (if set)
gradient: ?[]const Color = null,
/// Gradient direction
gradient_dir: GradientDirection = .vertical,
/// Frame counter for animations
frame: u64 = 0,
// Cached calculations
line_count: u16 = 0,
max_line_width: u16 = 0,
char_count: u32 = 0,
/// Initialize a new logo widget
pub fn init() Logo {
return .{};
}
/// Set the ASCII art text
pub fn setText(self: Logo, text: []const u8) Logo {
var logo = self;
logo.text = text;
// Calculate dimensions
logo.line_count = 0;
logo.max_line_width = 0;
logo.char_count = 0;
var lines = std.mem.splitScalar(u8, text, '\n');
while (lines.next()) |line| {
logo.line_count += 1;
logo.max_line_width = @max(logo.max_line_width, @as(u16, @intCast(line.len)));
logo.char_count += @intCast(line.len);
}
return logo;
}
/// Set the base style
pub fn setStyle(self: Logo, style: Style) Logo {
var logo = self;
logo.style = style;
return logo;
}
/// Set text alignment
pub fn setAlignment(self: Logo, alignment: Alignment) Logo {
var logo = self;
logo.alignment = alignment;
return logo;
}
/// Set animation type
pub fn setAnimation(self: Logo, animation: Animation) Logo {
var logo = self;
logo.animation = animation;
if (animation != .none) {
logo.progress = 0; // Start from beginning
}
return logo;
}
/// Set animation speed (lower = faster)
pub fn setSpeed(self: Logo, speed: u32) Logo {
var logo = self;
logo.speed = if (speed == 0) 1 else speed;
return logo;
}
/// Set gradient colors
pub fn setGradient(self: Logo, colors: []const Color) Logo {
var logo = self;
logo.gradient = colors;
return logo;
}
/// Set gradient direction
pub fn setGradientDirection(self: Logo, dir: GradientDirection) Logo {
var logo = self;
logo.gradient_dir = dir;
return logo;
}
/// Advance animation by one tick
pub fn tick(self: Logo) Logo {
var logo = self;
logo.frame +%= 1;
if (logo.animation != .none and logo.progress < 1000) {
logo.progress = @min(1000, logo.progress + logo.speed);
}
return logo;
}
/// Reset animation to beginning
pub fn reset(self: Logo) Logo {
var logo = self;
logo.progress = 0;
logo.frame = 0;
return logo;
}
/// Check if animation is complete
pub fn isComplete(self: Logo) bool {
return self.animation == .none or self.progress >= 1000;
}
/// Render the logo
pub fn render(self: Logo, area: Rect, buf: *Buffer) void {
if (area.isEmpty() or self.text.len == 0) return;
// Calculate vertical centering
const start_y = if (self.line_count < area.height)
area.y + (area.height - self.line_count) / 2
else
area.y;
var y: u16 = 0;
var char_index: u32 = 0;
var lines = std.mem.splitScalar(u8, self.text, '\n');
while (lines.next()) |line| {
if (y >= area.height) break;
const render_y = start_y + y;
if (render_y >= area.y + area.height) break;
// Check slide animations
const line_visible = switch (self.animation) {
.slide_down => blk: {
const visible_lines = (self.progress * self.line_count) / 1000;
break :blk y < visible_lines;
},
.slide_up => blk: {
const visible_lines = (self.progress * self.line_count) / 1000;
break :blk (self.line_count - 1 - y) < visible_lines;
},
else => true,
};
if (line_visible) {
// Calculate horizontal alignment
const line_width = @as(u16, @intCast(line.len));
const start_x = switch (self.alignment) {
.left => area.x,
.center => area.x + (area.width -| line_width) / 2,
.right => area.x + area.width -| line_width,
};
// Render each character
for (line, 0..) |char, x_offset| {
const render_x = start_x + @as(u16, @intCast(x_offset));
if (render_x >= area.x + area.width) break;
const char_visible = self.isCharVisible(char_index);
if (char_visible and char != ' ') {
const style = self.getCharStyle(
@intCast(x_offset),
y,
char_index,
);
const display_char = self.getDisplayChar(char, char_index);
buf.setCell(render_x, render_y, Cell{
.symbol = Cell.Symbol.fromCodepoint(display_char),
.style = style,
});
}
char_index += 1;
}
} else {
char_index += @intCast(line.len);
}
y += 1;
}
}
/// Check if a character should be visible based on animation
fn isCharVisible(self: Logo, char_index: u32) bool {
return switch (self.animation) {
.none => true,
.typewriter => blk: {
if (self.char_count == 0) break :blk true;
const visible_chars = (self.progress * self.char_count) / 1000;
break :blk char_index < visible_chars;
},
.fade_in, .rainbow, .pulse, .glitch => true,
.slide_down, .slide_up => true, // Handled at line level
};
}
/// Get the display character (may be modified for fade effect)
fn getDisplayChar(self: Logo, char: u8, char_index: u32) u21 {
return switch (self.animation) {
.fade_in => blk: {
if (self.char_count == 0) break :blk char;
// Calculate fade level based on progress
_ = self.progress; // Used for animation state
const char_progress = (char_index * 1000) / self.char_count;
// Characters fade in sequentially
if (char_progress > self.progress) {
break :blk ' ';
}
const local_progress = if (self.progress > char_progress)
@min(4, (self.progress - char_progress) * 4 / 200)
else
0;
break :blk switch (local_progress) {
0 => ' ',
1 => '░',
2 => '▒',
3 => '▓',
else => char,
};
},
.glitch => blk: {
// Random glitch based on frame
const hash = (char_index +% @as(u32, @truncate(self.frame))) *% 2654435761;
if (hash % 100 < 5) { // 5% glitch chance
const glitch_chars = "█▓▒░#@$%&*";
break :blk glitch_chars[hash % glitch_chars.len];
}
break :blk char;
},
else => char,
};
}
/// Get style for a character (handles gradients and effects)
fn getCharStyle(self: Logo, x: u16, y: u16, char_index: u32) Style {
var style = self.style;
// Apply gradient if set
if (self.gradient) |colors| {
if (colors.len > 0) {
const color = self.interpolateGradient(colors, x, y);
style = style.fg(color);
}
}
// Apply animation effects
switch (self.animation) {
.rainbow => {
const hue = (@as(u32, @truncate(self.frame)) *% 10 + char_index * 30) % 360;
style = style.fg(hsvToRgb(hue, 100, 100));
},
.pulse => {
// Pulse brightness using sine wave approximation
const phase = (@as(u32, @truncate(self.frame)) *% 20) % 360;
const brightness = 50 + (sinApprox(phase) * 50 / 100);
if (style.foreground) |fg| {
style = style.fg(adjustBrightness(fg, @intCast(brightness)));
}
},
else => {},
}
return style;
}
/// Interpolate gradient color at position
fn interpolateGradient(self: Logo, colors: []const Color, x: u16, y: u16) Color {
if (colors.len == 1) return colors[0];
const t: f32 = switch (self.gradient_dir) {
.vertical => if (self.line_count > 0)
@as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(self.line_count))
else
0.0,
.horizontal => if (self.max_line_width > 0)
@as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(self.max_line_width))
else
0.0,
.diagonal => blk: {
const max_dist = self.line_count + self.max_line_width;
if (max_dist > 0) {
break :blk @as(f32, @floatFromInt(x + y)) / @as(f32, @floatFromInt(max_dist));
}
break :blk 0.0;
},
.radial => blk: {
const center_x = self.max_line_width / 2;
const center_y = self.line_count / 2;
const dx = if (x > center_x) x - center_x else center_x - x;
const dy = if (y > center_y) y - center_y else center_y - y;
const max_dist = @max(center_x, center_y);
if (max_dist > 0) {
const dist = @max(dx, dy); // Chebyshev distance
break :blk @as(f32, @floatFromInt(dist)) / @as(f32, @floatFromInt(max_dist));
}
break :blk 0.0;
},
};
// Find the two colors to interpolate between
const segment_count = colors.len - 1;
const segment_f = t * @as(f32, @floatFromInt(segment_count));
const segment: usize = @min(@as(usize, @intFromFloat(segment_f)), segment_count - 1);
const local_t = segment_f - @as(f32, @floatFromInt(segment));
return lerpColor(colors[segment], colors[segment + 1], local_t);
}
};
// ============================================================================
// Color Utility Functions
// ============================================================================
/// Linear interpolation between two colors
fn lerpColor(c1: Color, c2: Color, t: f32) Color {
const rgb1 = colorToRgb(c1);
const rgb2 = colorToRgb(c2);
const r: u8 = @intFromFloat(@as(f32, @floatFromInt(rgb1.r)) * (1.0 - t) + @as(f32, @floatFromInt(rgb2.r)) * t);
const g: u8 = @intFromFloat(@as(f32, @floatFromInt(rgb1.g)) * (1.0 - t) + @as(f32, @floatFromInt(rgb2.g)) * t);
const b: u8 = @intFromFloat(@as(f32, @floatFromInt(rgb1.b)) * (1.0 - t) + @as(f32, @floatFromInt(rgb2.b)) * t);
return Color.rgb(r, g, b);
}
/// Convert any color to RGB components
fn colorToRgb(color: Color) struct { r: u8, g: u8, b: u8 } {
return switch (color) {
.true_color => |c| .{ .r = c.r, .g = c.g, .b = c.b },
.ansi => |a| switch (a) {
.black => .{ .r = 0, .g = 0, .b = 0 },
.red => .{ .r = 205, .g = 49, .b = 49 },
.green => .{ .r = 13, .g = 188, .b = 121 },
.yellow => .{ .r = 229, .g = 229, .b = 16 },
.blue => .{ .r = 36, .g = 114, .b = 200 },
.magenta => .{ .r = 188, .g = 63, .b = 188 },
.cyan => .{ .r = 17, .g = 168, .b = 205 },
.white => .{ .r = 229, .g = 229, .b = 229 },
.bright_black => .{ .r = 102, .g = 102, .b = 102 },
.bright_red => .{ .r = 241, .g = 76, .b = 76 },
.bright_green => .{ .r = 35, .g = 209, .b = 139 },
.bright_yellow => .{ .r = 245, .g = 245, .b = 67 },
.bright_blue => .{ .r = 59, .g = 142, .b = 234 },
.bright_magenta => .{ .r = 214, .g = 112, .b = 214 },
.bright_cyan => .{ .r = 41, .g = 184, .b = 219 },
.bright_white => .{ .r = 255, .g = 255, .b = 255 },
.default => .{ .r = 229, .g = 229, .b = 229 },
},
.idx => |i| {
// 256 color palette approximation
if (i < 16) {
// Standard colors
return colorToRgb(Color{ .ansi = @enumFromInt(i) });
} else if (i < 232) {
// 6x6x6 color cube
const c = i - 16;
const r: u8 = @intCast((c / 36) * 51);
const g: u8 = @intCast(((c % 36) / 6) * 51);
const b: u8 = @intCast((c % 6) * 51);
return .{ .r = r, .g = g, .b = b };
} else {
// Grayscale
const gray: u8 = @intCast((i - 232) * 10 + 8);
return .{ .r = gray, .g = gray, .b = gray };
}
},
.reset => .{ .r = 229, .g = 229, .b = 229 },
};
}
/// Convert HSV to RGB color
fn hsvToRgb(h: u32, s: u32, v: u32) Color {
if (s == 0) {
const gray: u8 = @intCast(v * 255 / 100);
return Color.rgb(gray, gray, gray);
}
const hue = h % 360;
const sat = @as(f32, @floatFromInt(s)) / 100.0;
const val = @as(f32, @floatFromInt(v)) / 100.0;
const sector: u32 = hue / 60;
const f = @as(f32, @floatFromInt(hue % 60)) / 60.0;
const p = val * (1.0 - sat);
const q = val * (1.0 - sat * f);
const t = val * (1.0 - sat * (1.0 - f));
const rgb: struct { r: f32, g: f32, b: f32 } = switch (sector) {
0 => .{ .r = val, .g = t, .b = p },
1 => .{ .r = q, .g = val, .b = p },
2 => .{ .r = p, .g = val, .b = t },
3 => .{ .r = p, .g = q, .b = val },
4 => .{ .r = t, .g = p, .b = val },
else => .{ .r = val, .g = p, .b = q },
};
return Color.rgb(
@intFromFloat(rgb.r * 255.0),
@intFromFloat(rgb.g * 255.0),
@intFromFloat(rgb.b * 255.0),
);
}
/// Adjust brightness of a color
fn adjustBrightness(color: Color, percent: u8) Color {
const rgb = colorToRgb(color);
const factor = @as(f32, @floatFromInt(percent)) / 100.0;
return Color.rgb(
@intFromFloat(@min(255.0, @as(f32, @floatFromInt(rgb.r)) * factor)),
@intFromFloat(@min(255.0, @as(f32, @floatFromInt(rgb.g)) * factor)),
@intFromFloat(@min(255.0, @as(f32, @floatFromInt(rgb.b)) * factor)),
);
}
/// Approximate sine function (input: degrees 0-360, output: -100 to 100)
fn sinApprox(degrees: u32) i32 {
const d = degrees % 360;
// Simple parabolic approximation
if (d <= 180) {
const x = @as(i32, @intCast(d)) - 90;
return 100 - @divTrunc(x * x * 100, 8100);
} else {
const x = @as(i32, @intCast(d)) - 270;
return -100 + @divTrunc(x * x * 100, 8100);
}
}
// ============================================================================
// Predefined ASCII Art
// ============================================================================
/// Collection of predefined ASCII art logos
pub const logos = struct {
pub const zcatui =
\\ ███████╗ ██████╗ █████╗ ████████╗██╗ ██╗██╗
\\ ╚══███╔╝██╔════╝██╔══██╗╚══██╔══╝██║ ██║██║
\\ ███╔╝ ██║ ███████║ ██║ ██║ ██║██║
\\ ███╔╝ ██║ ██╔══██║ ██║ ██║ ██║██║
\\ ███████╗╚██████╗██║ ██║ ██║ ╚██████╔╝██║
\\ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝
;
pub const zcatui_simple =
\\ ____ ___ _ _____ _ _ ___
\\ |_ / / __| /_\|_ _|| | | ||_ _|
\\ / / | (__ / _ \ | | | |_| | | |
\\ /___| \___/_/ \_\|_| \___/ |___|
;
pub const zig =
\\ ███████╗██╗ ██████╗
\\ ╚══███╔╝██║██╔════╝
\\ ███╔╝ ██║██║ ███╗
\\ ███╔╝ ██║██║ ██║
\\ ███████╗██║╚██████╔╝
\\ ╚══════╝╚═╝ ╚═════╝
;
pub const rust =
\\ ██████╗ ██╗ ██╗███████╗████████╗
\\ ██╔══██╗██║ ██║██╔════╝╚══██╔══╝
\\ ██████╔╝██║ ██║███████╗ ██║
\\ ██╔══██╗██║ ██║╚════██║ ██║
\\ ██║ ██║╚██████╔╝███████║ ██║
\\ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝
;
pub const box_small =
\\ ╔═══════╗
\\ ║ LOGO ║
\\ ╚═══════╝
;
pub const banner =
\\ ╭──────────────────────────────────╮
\\ │ │
\\ │ YOUR TEXT HERE │
\\ │ │
\\ ╰──────────────────────────────────╯
;
};
// ============================================================================
// Tests
// ============================================================================
test "Logo init and setText" {
const logo = Logo.init()
.setText("Line1\nLine2\nLine3");
try std.testing.expectEqual(@as(u16, 3), logo.line_count);
try std.testing.expectEqual(@as(u16, 5), logo.max_line_width);
}
test "Logo with style" {
const base_style = Style{};
const cyan_bold = base_style.fg(Color.cyan).bold();
const logo = Logo.init()
.setText("TEST")
.setStyle(cyan_bold)
.setAlignment(.center);
try std.testing.expectEqual(Alignment.center, logo.alignment);
try std.testing.expect(logo.style.foreground != null);
}
test "Logo animation tick" {
var logo = Logo.init()
.setText("TEST")
.setAnimation(.typewriter)
.setSpeed(100);
try std.testing.expectEqual(@as(u32, 0), logo.progress);
logo = logo.tick();
try std.testing.expectEqual(@as(u32, 100), logo.progress);
// Progress should cap at 1000
for (0..20) |_| {
logo = logo.tick();
}
try std.testing.expectEqual(@as(u32, 1000), logo.progress);
try std.testing.expect(logo.isComplete());
}
test "Logo reset" {
var logo = Logo.init()
.setText("TEST")
.setAnimation(.typewriter);
logo = logo.tick().tick().tick();
try std.testing.expect(logo.progress > 0);
logo = logo.reset();
try std.testing.expectEqual(@as(u32, 0), logo.progress);
try std.testing.expectEqual(@as(u64, 0), logo.frame);
}
test "hsvToRgb basic" {
// Red
const red = hsvToRgb(0, 100, 100);
try std.testing.expectEqual(Color.rgb(255, 0, 0), red);
// Green
const green = hsvToRgb(120, 100, 100);
try std.testing.expectEqual(Color.rgb(0, 255, 0), green);
// Blue
const blue = hsvToRgb(240, 100, 100);
try std.testing.expectEqual(Color.rgb(0, 0, 255), blue);
// White (no saturation)
const white = hsvToRgb(0, 0, 100);
try std.testing.expectEqual(Color.rgb(255, 255, 255), white);
}
test "sinApprox" {
// sin(0) = 0
try std.testing.expect(sinApprox(0) < 10 and sinApprox(0) > -10);
// sin(90) = 1 (100 in our scale)
try std.testing.expect(sinApprox(90) > 90);
// sin(180) = 0
try std.testing.expect(sinApprox(180) < 10 and sinApprox(180) > -10);
// sin(270) = -1 (-100 in our scale)
try std.testing.expect(sinApprox(270) < -90);
}
test "Logo gradient" {
const logo = Logo.init()
.setText("TEST\nTEST")
.setGradient(&.{ Color.red, Color.blue })
.setGradientDirection(.vertical);
try std.testing.expect(logo.gradient != null);
try std.testing.expectEqual(GradientDirection.vertical, logo.gradient_dir);
}
test "predefined logos exist" {
try std.testing.expect(logos.zcatui.len > 0);
try std.testing.expect(logos.zig.len > 0);
try std.testing.expect(logos.rust.len > 0);
}