Compare commits
No commits in common. "3ec75f626419c411897ad539103a94b99a476f92" and "5556ee1370d078e9881d2954270b708f8ecbe091" have entirely different histories.
3ec75f6264
...
5556ee1370
12 changed files with 13 additions and 3090 deletions
114
build.zig
114
build.zig
|
|
@ -61,118 +61,4 @@ pub fn build(b: *std.Build) void {
|
|||
run_events_demo.step.dependOn(b.getInstallStep());
|
||||
const events_demo_step = b.step("events-demo", "Run events demo");
|
||||
events_demo_step.dependOn(&run_events_demo.step);
|
||||
|
||||
// Ejemplo: list_demo
|
||||
const list_demo_exe = b.addExecutable(.{
|
||||
.name = "list-demo",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("examples/list_demo.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "zcatui", .module = zcatui_mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
b.installArtifact(list_demo_exe);
|
||||
|
||||
const run_list_demo = b.addRunArtifact(list_demo_exe);
|
||||
run_list_demo.step.dependOn(b.getInstallStep());
|
||||
const list_demo_step = b.step("list-demo", "Run list demo");
|
||||
list_demo_step.dependOn(&run_list_demo.step);
|
||||
|
||||
// Ejemplo: table_demo
|
||||
const table_demo_exe = b.addExecutable(.{
|
||||
.name = "table-demo",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("examples/table_demo.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "zcatui", .module = zcatui_mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
b.installArtifact(table_demo_exe);
|
||||
|
||||
const run_table_demo = b.addRunArtifact(table_demo_exe);
|
||||
run_table_demo.step.dependOn(b.getInstallStep());
|
||||
const table_demo_step = b.step("table-demo", "Run table demo");
|
||||
table_demo_step.dependOn(&run_table_demo.step);
|
||||
|
||||
// Ejemplo: dashboard
|
||||
const dashboard_exe = b.addExecutable(.{
|
||||
.name = "dashboard",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("examples/dashboard.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "zcatui", .module = zcatui_mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
b.installArtifact(dashboard_exe);
|
||||
|
||||
const run_dashboard = b.addRunArtifact(dashboard_exe);
|
||||
run_dashboard.step.dependOn(b.getInstallStep());
|
||||
const dashboard_step = b.step("dashboard", "Run dashboard demo");
|
||||
dashboard_step.dependOn(&run_dashboard.step);
|
||||
|
||||
// Ejemplo: input_demo
|
||||
const input_demo_exe = b.addExecutable(.{
|
||||
.name = "input-demo",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("examples/input_demo.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "zcatui", .module = zcatui_mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
b.installArtifact(input_demo_exe);
|
||||
|
||||
const run_input_demo = b.addRunArtifact(input_demo_exe);
|
||||
run_input_demo.step.dependOn(b.getInstallStep());
|
||||
const input_demo_step = b.step("input-demo", "Run input demo");
|
||||
input_demo_step.dependOn(&run_input_demo.step);
|
||||
|
||||
// Ejemplo: animation_demo
|
||||
const animation_demo_exe = b.addExecutable(.{
|
||||
.name = "animation-demo",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("examples/animation_demo.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "zcatui", .module = zcatui_mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
b.installArtifact(animation_demo_exe);
|
||||
|
||||
const run_animation_demo = b.addRunArtifact(animation_demo_exe);
|
||||
run_animation_demo.step.dependOn(b.getInstallStep());
|
||||
const animation_demo_step = b.step("animation-demo", "Run animation demo");
|
||||
animation_demo_step.dependOn(&run_animation_demo.step);
|
||||
|
||||
// Ejemplo: clipboard_demo
|
||||
const clipboard_demo_exe = b.addExecutable(.{
|
||||
.name = "clipboard-demo",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("examples/clipboard_demo.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "zcatui", .module = zcatui_mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
b.installArtifact(clipboard_demo_exe);
|
||||
|
||||
const run_clipboard_demo = b.addRunArtifact(clipboard_demo_exe);
|
||||
run_clipboard_demo.step.dependOn(b.getInstallStep());
|
||||
const clipboard_demo_step = b.step("clipboard-demo", "Run clipboard demo");
|
||||
clipboard_demo_step.dependOn(&run_clipboard_demo.step);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
//! Animation demo for zcatui.
|
||||
//!
|
||||
//! Demonstrates the animation system with:
|
||||
//! - Smooth gauge transitions
|
||||
//! - Bouncing progress bar
|
||||
//! - Multiple easing functions comparison
|
||||
//! - Timer-based updates
|
||||
//!
|
||||
//! Run with: zig build animation-demo
|
||||
|
||||
const std = @import("std");
|
||||
const zcatui = @import("zcatui");
|
||||
|
||||
const Terminal = zcatui.Terminal;
|
||||
const Buffer = zcatui.Buffer;
|
||||
const Rect = zcatui.Rect;
|
||||
const Style = zcatui.Style;
|
||||
const Color = zcatui.Color;
|
||||
const Event = zcatui.Event;
|
||||
const KeyCode = zcatui.KeyCode;
|
||||
const Layout = zcatui.Layout;
|
||||
const Constraint = zcatui.Constraint;
|
||||
const Block = zcatui.widgets.Block;
|
||||
const Borders = zcatui.widgets.Borders;
|
||||
const Gauge = zcatui.widgets.Gauge;
|
||||
const Animation = zcatui.Animation;
|
||||
const Easing = zcatui.Easing;
|
||||
const Timer = zcatui.Timer;
|
||||
|
||||
const AppState = struct {
|
||||
// Animations for different easing demos
|
||||
linear_anim: Animation,
|
||||
ease_in_anim: Animation,
|
||||
ease_out_anim: Animation,
|
||||
bounce_anim: Animation,
|
||||
elastic_anim: Animation,
|
||||
|
||||
// Timer for frame tracking
|
||||
frame_timer: Timer,
|
||||
tick_count: u64 = 0,
|
||||
|
||||
running: bool = true,
|
||||
last_time_ms: i64 = 0,
|
||||
|
||||
fn init() AppState {
|
||||
// All animations: 0 to 100 over 2 seconds, repeating ping-pong
|
||||
return .{
|
||||
.linear_anim = Animation.init(0, 100, 2000)
|
||||
.setEasing(Easing.linear)
|
||||
.setRepeat(-1)
|
||||
.setPingPong(true),
|
||||
.ease_in_anim = Animation.init(0, 100, 2000)
|
||||
.setEasing(Easing.easeInCubic)
|
||||
.setRepeat(-1)
|
||||
.setPingPong(true),
|
||||
.ease_out_anim = Animation.init(0, 100, 2000)
|
||||
.setEasing(Easing.easeOutCubic)
|
||||
.setRepeat(-1)
|
||||
.setPingPong(true),
|
||||
.bounce_anim = Animation.init(0, 100, 2000)
|
||||
.setEasing(Easing.easeOutBounce)
|
||||
.setRepeat(-1)
|
||||
.setPingPong(true),
|
||||
.elastic_anim = Animation.init(0, 100, 2000)
|
||||
.setEasing(Easing.easeOutElastic)
|
||||
.setRepeat(-1)
|
||||
.setPingPong(true),
|
||||
.frame_timer = Timer.repeating(1000), // 1 second for FPS counting
|
||||
};
|
||||
}
|
||||
|
||||
fn update(self: *AppState, delta_ms: u64) void {
|
||||
// Advance all animations
|
||||
self.linear_anim.advance(delta_ms);
|
||||
self.ease_in_anim.advance(delta_ms);
|
||||
self.ease_out_anim.advance(delta_ms);
|
||||
self.bounce_anim.advance(delta_ms);
|
||||
self.elastic_anim.advance(delta_ms);
|
||||
|
||||
// Update timer
|
||||
if (self.frame_timer.advance(delta_ms)) {
|
||||
self.tick_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn restartAnimations(self: *AppState) void {
|
||||
self.linear_anim.reset();
|
||||
self.ease_in_anim.reset();
|
||||
self.ease_out_anim.reset();
|
||||
self.bounce_anim.reset();
|
||||
self.elastic_anim.reset();
|
||||
}
|
||||
};
|
||||
|
||||
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 = AppState.init();
|
||||
|
||||
// Get initial timestamp
|
||||
state.last_time_ms = std.time.milliTimestamp();
|
||||
|
||||
while (state.running) {
|
||||
// Calculate delta time
|
||||
const current_time_ms = std.time.milliTimestamp();
|
||||
const delta_ms: u64 = @intCast(@max(0, current_time_ms - state.last_time_ms));
|
||||
state.last_time_ms = current_time_ms;
|
||||
|
||||
// Update animations
|
||||
state.update(delta_ms);
|
||||
|
||||
// Render
|
||||
try term.drawWithContext(&state, render);
|
||||
|
||||
// Poll events with short timeout for smooth animation
|
||||
if (try term.pollEvent(16)) |event| { // ~60 FPS target
|
||||
handleEvent(&state, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handleEvent(state: *AppState, event: Event) void {
|
||||
switch (event) {
|
||||
.key => |key| {
|
||||
switch (key.code) {
|
||||
.esc => state.running = false,
|
||||
.char => |c| {
|
||||
switch (c) {
|
||||
'q', 'Q' => state.running = false,
|
||||
'r', 'R' => state.restartAnimations(),
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Main layout
|
||||
const chunks = Layout.vertical(&.{
|
||||
Constraint.length(3), // Title
|
||||
Constraint.length(3), // Linear
|
||||
Constraint.length(3), // Ease-in
|
||||
Constraint.length(3), // Ease-out
|
||||
Constraint.length(3), // Bounce
|
||||
Constraint.length(3), // Elastic
|
||||
Constraint.min(0), // Help
|
||||
}).split(area);
|
||||
|
||||
// Title
|
||||
renderTitle(state, chunks.get(0), buf);
|
||||
|
||||
// Animation gauges
|
||||
renderGauge("Linear", state.linear_anim.getValueU16(), Color.cyan, chunks.get(1), buf);
|
||||
renderGauge("Ease-In (Cubic)", state.ease_in_anim.getValueU16(), Color.green, chunks.get(2), buf);
|
||||
renderGauge("Ease-Out (Cubic)", state.ease_out_anim.getValueU16(), Color.yellow, chunks.get(3), buf);
|
||||
renderGauge("Bounce", state.bounce_anim.getValueU16(), Color.magenta, chunks.get(4), buf);
|
||||
renderGauge("Elastic", state.elastic_anim.getValueU16(), Color.red, chunks.get(5), buf);
|
||||
|
||||
// Help
|
||||
renderHelp(chunks.get(6), buf);
|
||||
}
|
||||
|
||||
fn renderTitle(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
const title_block = Block.init()
|
||||
.title(" Animation Demo ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.white));
|
||||
title_block.render(area, buf);
|
||||
|
||||
const inner = title_block.inner(area);
|
||||
var info_buf: [64]u8 = undefined;
|
||||
const info = std.fmt.bufPrint(&info_buf, "Tick: {d} | All animations ping-pong 0-100 over 2s", .{state.tick_count}) catch "???";
|
||||
_ = buf.setString(inner.left(), inner.top(), info, Style.default);
|
||||
}
|
||||
|
||||
fn renderGauge(title: []const u8, value: u16, color: Color, area: Rect, buf: *Buffer) void {
|
||||
// Clamp value to 0-100
|
||||
const percent: u16 = @min(100, value);
|
||||
|
||||
const gauge = Gauge.init()
|
||||
.setBlock(Block.init().title(title).setBorders(Borders.all))
|
||||
.percent(percent)
|
||||
.gaugeStyle(Style.default.fg(color));
|
||||
gauge.render(area, buf);
|
||||
}
|
||||
|
||||
fn renderHelp(area: Rect, buf: *Buffer) void {
|
||||
const help_block = Block.init()
|
||||
.title(" Controls ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.blue));
|
||||
help_block.render(area, buf);
|
||||
|
||||
const inner = help_block.inner(area);
|
||||
var y = inner.top();
|
||||
|
||||
const lines = [_][]const u8{
|
||||
"r - Restart all animations",
|
||||
"q/ESC - Quit",
|
||||
"",
|
||||
"Compare how different easing functions",
|
||||
"create different motion feels!",
|
||||
};
|
||||
|
||||
for (lines) |line| {
|
||||
if (y < inner.bottom()) {
|
||||
_ = buf.setString(inner.left(), y, line, Style.default);
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
//! Clipboard demo for zcatui.
|
||||
//!
|
||||
//! Demonstrates OSC 52 clipboard support:
|
||||
//! - Copy text to system clipboard
|
||||
//! - Copy to primary selection (X11)
|
||||
//! - Clear clipboard
|
||||
//!
|
||||
//! Run with: zig build clipboard-demo
|
||||
//!
|
||||
//! Note: OSC 52 support varies by terminal. If clipboard operations
|
||||
//! don't work, check your terminal's documentation.
|
||||
|
||||
const std = @import("std");
|
||||
const zcatui = @import("zcatui");
|
||||
|
||||
const Terminal = zcatui.Terminal;
|
||||
const Buffer = zcatui.Buffer;
|
||||
const Rect = zcatui.Rect;
|
||||
const Style = zcatui.Style;
|
||||
const Color = zcatui.Color;
|
||||
const Event = zcatui.Event;
|
||||
const KeyCode = zcatui.KeyCode;
|
||||
const Layout = zcatui.Layout;
|
||||
const Constraint = zcatui.Constraint;
|
||||
const Block = zcatui.widgets.Block;
|
||||
const Borders = zcatui.widgets.Borders;
|
||||
const Paragraph = zcatui.widgets.Paragraph;
|
||||
const Clipboard = zcatui.clipboard;
|
||||
|
||||
const AppState = struct {
|
||||
running: bool = true,
|
||||
last_action: []const u8 = "Press a key to test clipboard",
|
||||
copy_count: u32 = 0,
|
||||
};
|
||||
|
||||
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 = AppState{};
|
||||
|
||||
while (state.running) {
|
||||
try term.drawWithContext(&state, render);
|
||||
|
||||
if (try term.pollEvent(100)) |event| {
|
||||
try handleEvent(&state, &term, allocator, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handleEvent(state: *AppState, term: *Terminal, allocator: std.mem.Allocator, event: Event) !void {
|
||||
const writer = term.backend.stdout.deprecatedWriter();
|
||||
switch (event) {
|
||||
.key => |key| {
|
||||
switch (key.code) {
|
||||
.esc => state.running = false,
|
||||
.char => |c| {
|
||||
switch (c) {
|
||||
'q', 'Q' => state.running = false,
|
||||
'1' => {
|
||||
state.copy_count += 1;
|
||||
var buf: [64]u8 = undefined;
|
||||
const text = std.fmt.bufPrint(&buf, "Hello from zcatui #{d}!", .{state.copy_count}) catch "Hello!";
|
||||
try Clipboard.copy(allocator, writer, text);
|
||||
state.last_action = "Copied text to clipboard (Ctrl+V to paste)";
|
||||
},
|
||||
'2' => {
|
||||
try Clipboard.copySmallTo(writer, "Primary selection text", .primary);
|
||||
state.last_action = "Copied to primary (middle-click to paste)";
|
||||
},
|
||||
'3' => {
|
||||
try Clipboard.copySmallTo(writer, "Both clipboard and primary!", .both);
|
||||
state.last_action = "Copied to both clipboard and primary";
|
||||
},
|
||||
'c', 'C' => {
|
||||
try Clipboard.clear(writer);
|
||||
state.last_action = "Cleared clipboard";
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Main layout
|
||||
const chunks = Layout.vertical(&.{
|
||||
Constraint.length(3), // Title
|
||||
Constraint.length(8), // Actions
|
||||
Constraint.length(5), // Status
|
||||
Constraint.min(0), // Help
|
||||
}).split(area);
|
||||
|
||||
// Title
|
||||
renderTitle(chunks.get(0), buf);
|
||||
|
||||
// Actions
|
||||
renderActions(chunks.get(1), buf);
|
||||
|
||||
// Status
|
||||
renderStatus(state, chunks.get(2), buf);
|
||||
|
||||
// Help
|
||||
renderHelp(chunks.get(3), buf);
|
||||
}
|
||||
|
||||
fn renderTitle(area: Rect, buf: *Buffer) void {
|
||||
const block = Block.init()
|
||||
.title(" Clipboard Demo (OSC 52) ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.cyan));
|
||||
block.render(area, buf);
|
||||
|
||||
const inner = block.inner(area);
|
||||
_ = buf.setString(inner.left(), inner.top(), "Test clipboard operations in your terminal", Style.default);
|
||||
}
|
||||
|
||||
fn renderActions(area: Rect, buf: *Buffer) void {
|
||||
const block = Block.init()
|
||||
.title(" Available Actions ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.yellow));
|
||||
block.render(area, buf);
|
||||
|
||||
const inner = block.inner(area);
|
||||
var y = inner.top();
|
||||
|
||||
const actions = [_][]const u8{
|
||||
"1 - Copy text to clipboard",
|
||||
"2 - Copy to primary selection (X11)",
|
||||
"3 - Copy to both clipboard and primary",
|
||||
"c - Clear clipboard",
|
||||
"q - Quit",
|
||||
};
|
||||
|
||||
for (actions) |action| {
|
||||
if (y < inner.bottom()) {
|
||||
_ = buf.setString(inner.left(), y, action, Style.default);
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn renderStatus(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
const block = Block.init()
|
||||
.title(" Last Action ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.green));
|
||||
block.render(area, buf);
|
||||
|
||||
const inner = block.inner(area);
|
||||
_ = buf.setString(inner.left(), inner.top(), state.last_action, Style.default.fg(Color.white).bold());
|
||||
|
||||
if (state.copy_count > 0) {
|
||||
var count_buf: [32]u8 = undefined;
|
||||
const count_text = std.fmt.bufPrint(&count_buf, "Total copies: {d}", .{state.copy_count}) catch "?";
|
||||
_ = buf.setString(inner.left(), inner.top() + 1, count_text, Style.default.dim());
|
||||
}
|
||||
}
|
||||
|
||||
fn renderHelp(area: Rect, buf: *Buffer) void {
|
||||
const block = Block.init()
|
||||
.title(" Notes ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.blue));
|
||||
block.render(area, buf);
|
||||
|
||||
const inner = block.inner(area);
|
||||
var y = inner.top();
|
||||
|
||||
const notes = [_][]const u8{
|
||||
"OSC 52 clipboard support varies by terminal.",
|
||||
"Supported: xterm, iTerm2, kitty, alacritty, WezTerm, foot",
|
||||
"tmux: enable with 'set -g set-clipboard on'",
|
||||
"Some terminals disable OSC 52 by default for security.",
|
||||
};
|
||||
|
||||
for (notes) |note| {
|
||||
if (y < inner.bottom()) {
|
||||
_ = buf.setString(inner.left(), y, note, Style.default.dim());
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
//! Dashboard demo for zcatui.
|
||||
//!
|
||||
//! Demonstrates multiple widgets working together:
|
||||
//! - Gauges showing system metrics
|
||||
//! - Sparkline for real-time data
|
||||
//! - List for logs/events
|
||||
//! - Tabs for navigation
|
||||
//!
|
||||
//! Run with: zig build dashboard
|
||||
|
||||
const std = @import("std");
|
||||
const zcatui = @import("zcatui");
|
||||
|
||||
const Terminal = zcatui.Terminal;
|
||||
const Buffer = zcatui.Buffer;
|
||||
const Rect = zcatui.Rect;
|
||||
const Style = zcatui.Style;
|
||||
const Color = zcatui.Color;
|
||||
const Event = zcatui.Event;
|
||||
const KeyCode = zcatui.KeyCode;
|
||||
const Layout = zcatui.Layout;
|
||||
const Constraint = zcatui.Constraint;
|
||||
const Block = zcatui.widgets.Block;
|
||||
const Borders = zcatui.widgets.Borders;
|
||||
const Gauge = zcatui.widgets.Gauge;
|
||||
const Sparkline = zcatui.widgets.Sparkline;
|
||||
const List = zcatui.widgets.List;
|
||||
const ListItem = zcatui.widgets.ListItem;
|
||||
const Tabs = zcatui.widgets.Tabs;
|
||||
const Paragraph = zcatui.widgets.Paragraph;
|
||||
const Line = zcatui.Line;
|
||||
|
||||
const AppState = struct {
|
||||
running: bool = true,
|
||||
tick: u64 = 0,
|
||||
tab_index: usize = 0,
|
||||
cpu_history: [64]u64 = [_]u64{0} ** 64,
|
||||
mem_history: [64]u64 = [_]u64{0} ** 64,
|
||||
history_idx: usize = 0,
|
||||
cpu: u64 = 0,
|
||||
mem: u64 = 0,
|
||||
|
||||
fn update(self: *AppState) void {
|
||||
self.tick += 1;
|
||||
|
||||
// Simulate changing metrics
|
||||
const seed = @as(u32, @truncate(self.tick));
|
||||
self.cpu = 30 + (seed * 7) % 50;
|
||||
self.mem = 40 + (seed * 13) % 40;
|
||||
|
||||
// Update history
|
||||
self.cpu_history[self.history_idx] = self.cpu;
|
||||
self.mem_history[self.history_idx] = self.mem;
|
||||
self.history_idx = (self.history_idx + 1) % 64;
|
||||
}
|
||||
|
||||
fn nextTab(self: *AppState) void {
|
||||
self.tab_index = (self.tab_index + 1) % 3;
|
||||
}
|
||||
|
||||
fn prevTab(self: *AppState) void {
|
||||
if (self.tab_index == 0) {
|
||||
self.tab_index = 2;
|
||||
} else {
|
||||
self.tab_index -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 = AppState{};
|
||||
|
||||
while (state.running) {
|
||||
state.update();
|
||||
try term.drawWithContext(&state, render);
|
||||
|
||||
if (try term.pollEvent(50)) |event| {
|
||||
handleEvent(&state, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handleEvent(state: *AppState, event: Event) void {
|
||||
switch (event) {
|
||||
.key => |key| {
|
||||
switch (key.code) {
|
||||
.esc => state.running = false,
|
||||
.char => |c| {
|
||||
switch (c) {
|
||||
'q', 'Q' => state.running = false,
|
||||
'1' => state.tab_index = 0,
|
||||
'2' => state.tab_index = 1,
|
||||
'3' => state.tab_index = 2,
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
.tab => state.nextTab(),
|
||||
.backtab => state.prevTab(),
|
||||
.right => state.nextTab(),
|
||||
.left => state.prevTab(),
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Main layout: tabs at top, content below
|
||||
const main_chunks = Layout.vertical(&.{
|
||||
Constraint.length(3),
|
||||
Constraint.min(0),
|
||||
}).split(area);
|
||||
|
||||
// Render tabs
|
||||
renderTabs(state, main_chunks.get(0), buf);
|
||||
|
||||
// Render content based on selected tab
|
||||
switch (state.tab_index) {
|
||||
0 => renderOverview(state, main_chunks.get(1), buf),
|
||||
1 => renderMetrics(state, main_chunks.get(1), buf),
|
||||
2 => renderHelp(main_chunks.get(1), buf),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn renderTabs(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
const titles = [_]Line{
|
||||
Line.raw("Overview"),
|
||||
Line.raw("Metrics"),
|
||||
Line.raw("Help"),
|
||||
};
|
||||
const tabs = Tabs.init(&titles)
|
||||
.setBlock(Block.init()
|
||||
.title(" Dashboard ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.cyan)))
|
||||
.select(state.tab_index)
|
||||
.highlightStyle(Style.default.fg(Color.yellow).bold());
|
||||
tabs.render(area, buf);
|
||||
}
|
||||
|
||||
fn renderOverview(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Split into gauges and sparklines
|
||||
const chunks = Layout.vertical(&.{
|
||||
Constraint.length(5),
|
||||
Constraint.length(5),
|
||||
Constraint.min(0),
|
||||
}).split(area);
|
||||
|
||||
// CPU Gauge
|
||||
const cpu_gauge = Gauge.init()
|
||||
.setBlock(Block.init().title(" CPU ").setBorders(Borders.all))
|
||||
.percent(@intCast(state.cpu))
|
||||
.gaugeStyle(Style.default.fg(Color.green));
|
||||
cpu_gauge.render(chunks.get(0), buf);
|
||||
|
||||
// Memory Gauge
|
||||
const mem_gauge = Gauge.init()
|
||||
.setBlock(Block.init().title(" Memory ").setBorders(Borders.all))
|
||||
.percent(@intCast(state.mem))
|
||||
.gaugeStyle(Style.default.fg(Color.blue));
|
||||
mem_gauge.render(chunks.get(1), buf);
|
||||
|
||||
// Recent events
|
||||
renderEvents(state, chunks.get(2), buf);
|
||||
}
|
||||
|
||||
fn renderMetrics(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Split for two sparklines
|
||||
const chunks = Layout.vertical(&.{
|
||||
Constraint.percentage(50),
|
||||
Constraint.percentage(50),
|
||||
}).split(area);
|
||||
|
||||
// CPU Sparkline
|
||||
const cpu_spark = Sparkline.init()
|
||||
.setBlock(Block.init().title(" CPU History ").setBorders(Borders.all))
|
||||
.setData(&state.cpu_history)
|
||||
.setMax(100)
|
||||
.setStyle(Style.default.fg(Color.green));
|
||||
cpu_spark.render(chunks.get(0), buf);
|
||||
|
||||
// Memory Sparkline
|
||||
const mem_spark = Sparkline.init()
|
||||
.setBlock(Block.init().title(" Memory History ").setBorders(Borders.all))
|
||||
.setData(&state.mem_history)
|
||||
.setMax(100)
|
||||
.setStyle(Style.default.fg(Color.blue));
|
||||
mem_spark.render(chunks.get(1), buf);
|
||||
}
|
||||
|
||||
fn renderEvents(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Generate some fake events based on tick
|
||||
var event_strs: [5][64]u8 = undefined;
|
||||
var event_items: [5]ListItem = undefined;
|
||||
|
||||
for (0..5) |i| {
|
||||
const tick_val = state.tick -| (4 - i);
|
||||
const written = std.fmt.bufPrint(&event_strs[i], "[{d:0>4}] System event {d}", .{ tick_val, i + 1 }) catch "???";
|
||||
event_items[i] = ListItem.raw(written);
|
||||
}
|
||||
|
||||
const list = List.init(&event_items)
|
||||
.setBlock(Block.init()
|
||||
.title(" Recent Events ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.yellow)));
|
||||
list.render(area, buf);
|
||||
}
|
||||
|
||||
fn renderHelp(area: Rect, buf: *Buffer) void {
|
||||
const help_block = Block.init()
|
||||
.title(" Help ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.magenta));
|
||||
help_block.render(area, buf);
|
||||
|
||||
const inner = help_block.inner(area);
|
||||
var y = inner.top();
|
||||
|
||||
const lines = [_][]const u8{
|
||||
"Dashboard Demo - zcatui",
|
||||
"",
|
||||
"Navigation:",
|
||||
" Tab/Shift+Tab - Switch tabs",
|
||||
" Left/Right - Switch tabs",
|
||||
" 1/2/3 - Jump to tab",
|
||||
"",
|
||||
"General:",
|
||||
" q/ESC - Quit",
|
||||
"",
|
||||
"This demo shows multiple widgets:",
|
||||
" - Tabs for navigation",
|
||||
" - Gauges for percentages",
|
||||
" - Sparklines for time series",
|
||||
" - Lists for events/logs",
|
||||
};
|
||||
|
||||
for (lines) |line| {
|
||||
if (y < inner.bottom()) {
|
||||
_ = buf.setString(inner.left(), y, line, Style.default);
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
//! Interactive input demo for zcatui.
|
||||
//!
|
||||
//! Demonstrates the readline-style Input widget with:
|
||||
//! - Basic text editing (insert, delete, backspace)
|
||||
//! - Cursor movement (home, end, left, right)
|
||||
//! - Word navigation (Ctrl+Left/Right)
|
||||
//! - Kill/yank (Ctrl+K, Ctrl+Y)
|
||||
//! - Clear line (Ctrl+U)
|
||||
//! - History navigation (Up/Down arrows)
|
||||
//!
|
||||
//! Run with: zig build input-demo
|
||||
|
||||
const std = @import("std");
|
||||
const zcatui = @import("zcatui");
|
||||
|
||||
const Terminal = zcatui.Terminal;
|
||||
const Buffer = zcatui.Buffer;
|
||||
const Rect = zcatui.Rect;
|
||||
const Style = zcatui.Style;
|
||||
const Color = zcatui.Color;
|
||||
const Event = zcatui.Event;
|
||||
const KeyCode = zcatui.KeyCode;
|
||||
const Layout = zcatui.Layout;
|
||||
const Constraint = zcatui.Constraint;
|
||||
const Block = zcatui.widgets.Block;
|
||||
const Borders = zcatui.widgets.Borders;
|
||||
const Paragraph = zcatui.widgets.Paragraph;
|
||||
const Input = zcatui.widgets.Input;
|
||||
const InputState = zcatui.widgets.InputState;
|
||||
|
||||
const AppState = struct {
|
||||
input_state: InputState,
|
||||
password_state: InputState,
|
||||
focus: Focus = .normal_input,
|
||||
messages: std.ArrayListUnmanaged([]u8) = .{},
|
||||
running: bool = true,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
const Focus = enum { normal_input, password_input };
|
||||
|
||||
fn init(allocator: std.mem.Allocator) AppState {
|
||||
return .{
|
||||
.input_state = InputState.init(allocator),
|
||||
.password_state = InputState.init(allocator),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(self: *AppState) void {
|
||||
self.input_state.deinit();
|
||||
self.password_state.deinit();
|
||||
for (self.messages.items) |msg| {
|
||||
self.allocator.free(msg);
|
||||
}
|
||||
self.messages.deinit(self.allocator);
|
||||
}
|
||||
|
||||
fn addMessage(self: *AppState, msg: []const u8) !void {
|
||||
const copy = try self.allocator.dupe(u8, msg);
|
||||
try self.messages.append(self.allocator, copy);
|
||||
// Keep only last 10 messages
|
||||
while (self.messages.items.len > 10) {
|
||||
self.allocator.free(self.messages.orderedRemove(0));
|
||||
}
|
||||
}
|
||||
|
||||
fn toggleFocus(self: *AppState) void {
|
||||
self.focus = if (self.focus == .normal_input) .password_input else .normal_input;
|
||||
}
|
||||
};
|
||||
|
||||
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 = AppState.init(allocator);
|
||||
defer state.deinit();
|
||||
|
||||
// Add initial help message
|
||||
try state.addMessage("Type text and press Enter to submit.");
|
||||
try state.addMessage("Use Tab to switch between inputs.");
|
||||
try state.addMessage("Press Ctrl+C or Esc to quit.");
|
||||
|
||||
while (state.running) {
|
||||
try term.drawWithContext(&state, render);
|
||||
|
||||
if (try term.pollEvent(100)) |event| {
|
||||
try handleEvent(&state, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handleEvent(state: *AppState, event: Event) !void {
|
||||
switch (event) {
|
||||
.key => |key| {
|
||||
switch (key.code) {
|
||||
.esc => state.running = false,
|
||||
.char => |c| {
|
||||
if (key.modifiers.ctrl and (c == 'c' or c == 'C')) {
|
||||
state.running = false;
|
||||
return;
|
||||
}
|
||||
// Let input handle other chars
|
||||
const input_state = switch (state.focus) {
|
||||
.normal_input => &state.input_state,
|
||||
.password_input => &state.password_state,
|
||||
};
|
||||
_ = try input_state.handleKey(key);
|
||||
},
|
||||
.tab => state.toggleFocus(),
|
||||
.enter => {
|
||||
const input_state = switch (state.focus) {
|
||||
.normal_input => &state.input_state,
|
||||
.password_input => &state.password_state,
|
||||
};
|
||||
const submitted = try input_state.submit();
|
||||
if (submitted.len > 0) {
|
||||
var msg_buf: [128]u8 = undefined;
|
||||
const prefix = if (state.focus == .normal_input) "Input" else "Password";
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "{s}: {s}", .{ prefix, submitted }) catch "???";
|
||||
try state.addMessage(msg);
|
||||
state.allocator.free(submitted);
|
||||
}
|
||||
},
|
||||
else => {
|
||||
const input_state = switch (state.focus) {
|
||||
.normal_input => &state.input_state,
|
||||
.password_input => &state.password_state,
|
||||
};
|
||||
_ = try input_state.handleKey(key);
|
||||
},
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Layout: help at top, inputs in middle, messages at bottom
|
||||
const chunks = Layout.vertical(&.{
|
||||
Constraint.length(6), // Help
|
||||
Constraint.length(3), // Normal input
|
||||
Constraint.length(3), // Password input
|
||||
Constraint.min(0), // Messages
|
||||
}).split(area);
|
||||
|
||||
// Help section
|
||||
renderHelp(chunks.get(0), buf);
|
||||
|
||||
// Normal input
|
||||
const normal_style = if (state.focus == .normal_input)
|
||||
Style.default.fg(Color.cyan)
|
||||
else
|
||||
Style.default.fg(Color.white);
|
||||
|
||||
const normal_input = Input.init()
|
||||
.setBlock(Block.init()
|
||||
.title(" Input (Tab to switch) ")
|
||||
.setBorders(Borders.all)
|
||||
.style(normal_style))
|
||||
.setPlaceholder("Type here...")
|
||||
.showCursor(state.focus == .normal_input);
|
||||
normal_input.render(chunks.get(1), buf, &state.input_state);
|
||||
|
||||
// Password input
|
||||
const password_style = if (state.focus == .password_input)
|
||||
Style.default.fg(Color.cyan)
|
||||
else
|
||||
Style.default.fg(Color.white);
|
||||
|
||||
const password_input = Input.init()
|
||||
.setBlock(Block.init()
|
||||
.title(" Password ")
|
||||
.setBorders(Borders.all)
|
||||
.style(password_style))
|
||||
.setPlaceholder("Enter password...")
|
||||
.setMask('*')
|
||||
.showCursor(state.focus == .password_input);
|
||||
password_input.render(chunks.get(2), buf, &state.password_state);
|
||||
|
||||
// Messages
|
||||
renderMessages(state, chunks.get(3), buf);
|
||||
}
|
||||
|
||||
fn renderHelp(area: Rect, buf: *Buffer) void {
|
||||
const help_block = Block.init()
|
||||
.title(" Keyboard Shortcuts ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.yellow));
|
||||
help_block.render(area, buf);
|
||||
|
||||
const inner = help_block.inner(area);
|
||||
var y = inner.top();
|
||||
|
||||
const lines = [_][]const u8{
|
||||
"Ctrl+A/E: Home/End | Ctrl+B/F: Left/Right | Ctrl+W: Delete word",
|
||||
"Ctrl+K: Kill to end | Ctrl+U: Kill to start | Ctrl+Y: Yank",
|
||||
"Up/Down: History | Tab: Switch input | Enter: Submit | Esc: Quit",
|
||||
};
|
||||
|
||||
for (lines) |line| {
|
||||
if (y < inner.bottom()) {
|
||||
_ = buf.setString(inner.left(), y, line, Style.default);
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn renderMessages(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
const msg_block = Block.init()
|
||||
.title(" Messages ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.green));
|
||||
msg_block.render(area, buf);
|
||||
|
||||
const inner = msg_block.inner(area);
|
||||
var y = inner.top();
|
||||
|
||||
// Show messages in reverse order (newest at top)
|
||||
var i: usize = state.messages.items.len;
|
||||
while (i > 0 and y < inner.bottom()) {
|
||||
i -= 1;
|
||||
const msg = state.messages.items[i];
|
||||
_ = buf.setString(inner.left(), y, msg, Style.default);
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
//! Interactive list demo for zcatui.
|
||||
//!
|
||||
//! Demonstrates a navigable list with keyboard controls.
|
||||
//! - Up/Down or j/k: Navigate items
|
||||
//! - Enter: Select item
|
||||
//! - q/ESC: Quit
|
||||
//!
|
||||
//! Run with: zig build list-demo
|
||||
|
||||
const std = @import("std");
|
||||
const zcatui = @import("zcatui");
|
||||
|
||||
const Terminal = zcatui.Terminal;
|
||||
const Buffer = zcatui.Buffer;
|
||||
const Rect = zcatui.Rect;
|
||||
const Style = zcatui.Style;
|
||||
const Color = zcatui.Color;
|
||||
const Event = zcatui.Event;
|
||||
const KeyCode = zcatui.KeyCode;
|
||||
const Layout = zcatui.Layout;
|
||||
const Constraint = zcatui.Constraint;
|
||||
const Block = zcatui.widgets.Block;
|
||||
const Borders = zcatui.widgets.Borders;
|
||||
const List = zcatui.widgets.List;
|
||||
const ListItem = zcatui.widgets.ListItem;
|
||||
const ListState = zcatui.widgets.ListState;
|
||||
|
||||
const items = [_][]const u8{
|
||||
"Item 1 - First item in the list",
|
||||
"Item 2 - Second item",
|
||||
"Item 3 - Third item",
|
||||
"Item 4 - Fourth item",
|
||||
"Item 5 - Fifth item",
|
||||
"Item 6 - Sixth item",
|
||||
"Item 7 - Seventh item",
|
||||
"Item 8 - Eighth item",
|
||||
"Item 9 - Ninth item",
|
||||
"Item 10 - Tenth item",
|
||||
};
|
||||
|
||||
const AppState = struct {
|
||||
list_state: ListState,
|
||||
selected_item: ?usize = null,
|
||||
running: bool = true,
|
||||
|
||||
fn init() AppState {
|
||||
return .{
|
||||
.list_state = ListState.default,
|
||||
};
|
||||
}
|
||||
|
||||
fn nextItem(self: *AppState) void {
|
||||
const current = self.list_state.selected orelse 0;
|
||||
if (current < items.len - 1) {
|
||||
self.list_state.selected = current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn prevItem(self: *AppState) void {
|
||||
const current = self.list_state.selected orelse 0;
|
||||
if (current > 0) {
|
||||
self.list_state.selected = current - 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn selectCurrent(self: *AppState) void {
|
||||
self.selected_item = self.list_state.selected;
|
||||
}
|
||||
};
|
||||
|
||||
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 = AppState.init();
|
||||
state.list_state.selected = 0; // Start with first item selected
|
||||
|
||||
while (state.running) {
|
||||
try term.drawWithContext(&state, render);
|
||||
|
||||
if (try term.pollEvent(100)) |event| {
|
||||
handleEvent(&state, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handleEvent(state: *AppState, event: Event) void {
|
||||
switch (event) {
|
||||
.key => |key| {
|
||||
switch (key.code) {
|
||||
.esc => state.running = false,
|
||||
.char => |c| {
|
||||
switch (c) {
|
||||
'q', 'Q' => state.running = false,
|
||||
'j' => state.nextItem(),
|
||||
'k' => state.prevItem(),
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
.down => state.nextItem(),
|
||||
.up => state.prevItem(),
|
||||
.enter => state.selectCurrent(),
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Layout: list on left, info on right
|
||||
const chunks = Layout.horizontal(&.{
|
||||
Constraint.percentage(60),
|
||||
Constraint.percentage(40),
|
||||
}).split(area);
|
||||
|
||||
// Render list
|
||||
renderList(state, chunks.get(0), buf);
|
||||
|
||||
// Render info panel
|
||||
renderInfo(state, chunks.get(1), buf);
|
||||
}
|
||||
|
||||
fn renderList(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Create list items
|
||||
var list_items: [items.len]ListItem = undefined;
|
||||
for (items, 0..) |item, i| {
|
||||
list_items[i] = ListItem.raw(item);
|
||||
}
|
||||
|
||||
const list = List.init(&list_items)
|
||||
.setBlock(Block.init()
|
||||
.title(" Items (j/k or arrows) ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.cyan)))
|
||||
.highlightStyle(Style.default.fg(Color.black).bg(Color.cyan))
|
||||
.highlightSymbol("> ");
|
||||
|
||||
list.renderStateful(area, buf, &state.list_state);
|
||||
}
|
||||
|
||||
fn renderInfo(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
const info_block = Block.init()
|
||||
.title(" Info ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.yellow));
|
||||
info_block.render(area, buf);
|
||||
|
||||
const inner = info_block.inner(area);
|
||||
var y = inner.top();
|
||||
|
||||
// Selected index
|
||||
var idx_buf: [32]u8 = undefined;
|
||||
const idx_str = if (state.list_state.selected) |sel|
|
||||
std.fmt.bufPrint(&idx_buf, "Index: {d}", .{sel}) catch "?"
|
||||
else
|
||||
"Index: none";
|
||||
_ = buf.setString(inner.left(), y, idx_str, Style.default);
|
||||
y += 2;
|
||||
|
||||
// Last selected item
|
||||
_ = buf.setString(inner.left(), y, "Last selected:", Style.default.bold());
|
||||
y += 1;
|
||||
|
||||
if (state.selected_item) |sel| {
|
||||
if (sel < items.len) {
|
||||
_ = buf.setString(inner.left(), y, items[sel], Style.default.fg(Color.green));
|
||||
}
|
||||
} else {
|
||||
_ = buf.setString(inner.left(), y, "(press Enter)", Style.default.fg(Color.white));
|
||||
}
|
||||
y += 3;
|
||||
|
||||
// Help
|
||||
_ = buf.setString(inner.left(), y, "Controls:", Style.default.bold());
|
||||
y += 1;
|
||||
_ = buf.setString(inner.left(), y, "Up/k - Previous", Style.default);
|
||||
y += 1;
|
||||
_ = buf.setString(inner.left(), y, "Down/j - Next", Style.default);
|
||||
y += 1;
|
||||
_ = buf.setString(inner.left(), y, "Enter - Select", Style.default);
|
||||
y += 1;
|
||||
_ = buf.setString(inner.left(), y, "q/ESC - Quit", Style.default);
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
//! Interactive table demo for zcatui.
|
||||
//!
|
||||
//! Demonstrates a table with row selection and scrolling.
|
||||
//! - Up/Down or j/k: Navigate rows
|
||||
//! - Left/Right or h/l: Change column widths
|
||||
//! - q/ESC: Quit
|
||||
//!
|
||||
//! Run with: zig build table-demo
|
||||
|
||||
const std = @import("std");
|
||||
const zcatui = @import("zcatui");
|
||||
|
||||
const Terminal = zcatui.Terminal;
|
||||
const Buffer = zcatui.Buffer;
|
||||
const Rect = zcatui.Rect;
|
||||
const Style = zcatui.Style;
|
||||
const Color = zcatui.Color;
|
||||
const Event = zcatui.Event;
|
||||
const KeyCode = zcatui.KeyCode;
|
||||
const Layout = zcatui.Layout;
|
||||
const Constraint = zcatui.Constraint;
|
||||
const Block = zcatui.widgets.Block;
|
||||
const Borders = zcatui.widgets.Borders;
|
||||
const Table = zcatui.widgets.Table;
|
||||
const TableRow = zcatui.widgets.TableRow;
|
||||
const TableCell = zcatui.widgets.TableCell;
|
||||
const TableState = zcatui.widgets.TableState;
|
||||
|
||||
const data = [_][4][]const u8{
|
||||
.{ "1", "Alice", "alice@example.com", "Active" },
|
||||
.{ "2", "Bob", "bob@example.com", "Active" },
|
||||
.{ "3", "Charlie", "charlie@example.com", "Inactive" },
|
||||
.{ "4", "Diana", "diana@example.com", "Active" },
|
||||
.{ "5", "Eve", "eve@example.com", "Pending" },
|
||||
.{ "6", "Frank", "frank@example.com", "Active" },
|
||||
.{ "7", "Grace", "grace@example.com", "Inactive" },
|
||||
.{ "8", "Henry", "henry@example.com", "Active" },
|
||||
.{ "9", "Ivy", "ivy@example.com", "Pending" },
|
||||
.{ "10", "Jack", "jack@example.com", "Active" },
|
||||
.{ "11", "Kate", "kate@example.com", "Active" },
|
||||
.{ "12", "Leo", "leo@example.com", "Inactive" },
|
||||
};
|
||||
|
||||
const AppState = struct {
|
||||
table_state: TableState,
|
||||
running: bool = true,
|
||||
|
||||
fn init() AppState {
|
||||
var state = AppState{
|
||||
.table_state = TableState.init(),
|
||||
};
|
||||
state.table_state.selected = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
fn nextRow(self: *AppState) void {
|
||||
const current = self.table_state.selected orelse 0;
|
||||
if (current < data.len - 1) {
|
||||
self.table_state.selected = current + 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn prevRow(self: *AppState) void {
|
||||
const current = self.table_state.selected orelse 0;
|
||||
if (current > 0) {
|
||||
self.table_state.selected = current - 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn firstRow(self: *AppState) void {
|
||||
self.table_state.selected = 0;
|
||||
}
|
||||
|
||||
fn lastRow(self: *AppState) void {
|
||||
self.table_state.selected = data.len - 1;
|
||||
}
|
||||
};
|
||||
|
||||
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 = AppState.init();
|
||||
|
||||
while (state.running) {
|
||||
try term.drawWithContext(&state, render);
|
||||
|
||||
if (try term.pollEvent(100)) |event| {
|
||||
handleEvent(&state, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handleEvent(state: *AppState, event: Event) void {
|
||||
switch (event) {
|
||||
.key => |key| {
|
||||
switch (key.code) {
|
||||
.esc => state.running = false,
|
||||
.char => |c| {
|
||||
switch (c) {
|
||||
'q', 'Q' => state.running = false,
|
||||
'j' => state.nextRow(),
|
||||
'k' => state.prevRow(),
|
||||
'g' => state.firstRow(),
|
||||
'G' => state.lastRow(),
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
.down => state.nextRow(),
|
||||
.up => state.prevRow(),
|
||||
.home => state.firstRow(),
|
||||
.end => state.lastRow(),
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Layout: table takes most space, status bar at bottom
|
||||
const chunks = Layout.vertical(&.{
|
||||
Constraint.min(0),
|
||||
Constraint.length(3),
|
||||
}).split(area);
|
||||
|
||||
// Render table
|
||||
renderTable(state, chunks.get(0), buf);
|
||||
|
||||
// Render status bar
|
||||
renderStatus(state, chunks.get(1), buf);
|
||||
}
|
||||
|
||||
fn renderTable(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
// Create header
|
||||
var header_cells: [4]TableCell = undefined;
|
||||
const headers = [_][]const u8{ "ID", "Name", "Email", "Status" };
|
||||
for (headers, 0..) |h, i| {
|
||||
header_cells[i] = TableCell.fromString(h);
|
||||
}
|
||||
const header = TableRow.init(&header_cells)
|
||||
.setStyle(Style.default.fg(Color.yellow).bold());
|
||||
|
||||
// Create rows
|
||||
var rows: [data.len]TableRow = undefined;
|
||||
for (data, 0..) |row_data, i| {
|
||||
var cells: [4]TableCell = undefined;
|
||||
for (row_data, 0..) |cell_data, j| {
|
||||
// Color status column
|
||||
const cell_style = if (j == 3) blk: {
|
||||
if (std.mem.eql(u8, cell_data, "Active")) {
|
||||
break :blk Style.default.fg(Color.green);
|
||||
} else if (std.mem.eql(u8, cell_data, "Inactive")) {
|
||||
break :blk Style.default.fg(Color.red);
|
||||
} else {
|
||||
break :blk Style.default.fg(Color.yellow);
|
||||
}
|
||||
} else Style.default;
|
||||
cells[j] = TableCell.fromString(cell_data).setStyle(cell_style);
|
||||
}
|
||||
rows[i] = TableRow.init(&cells);
|
||||
}
|
||||
|
||||
// Column widths
|
||||
const widths = [_]Constraint{
|
||||
Constraint.length(4),
|
||||
Constraint.length(12),
|
||||
Constraint.min(20),
|
||||
Constraint.length(10),
|
||||
};
|
||||
|
||||
const table = Table.init()
|
||||
.setRows(&rows)
|
||||
.setWidths(&widths)
|
||||
.setHeader(header)
|
||||
.setBlock(Block.init()
|
||||
.title(" Users Table ")
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.cyan)))
|
||||
.rowHighlightStyle(Style.default.fg(Color.black).bg(Color.cyan))
|
||||
.highlightSymbol("> ");
|
||||
|
||||
table.renderStateful(area, buf, &state.table_state);
|
||||
}
|
||||
|
||||
fn renderStatus(state: *AppState, area: Rect, buf: *Buffer) void {
|
||||
const status_block = Block.init()
|
||||
.setBorders(Borders.all)
|
||||
.style(Style.default.fg(Color.blue));
|
||||
status_block.render(area, buf);
|
||||
|
||||
const inner = status_block.inner(area);
|
||||
|
||||
// Row info
|
||||
var row_buf: [64]u8 = undefined;
|
||||
const row_str = if (state.table_state.selected) |sel|
|
||||
std.fmt.bufPrint(&row_buf, "Row {d}/{d}", .{ sel + 1, data.len }) catch "?"
|
||||
else
|
||||
"No selection";
|
||||
_ = buf.setString(inner.left(), inner.top(), row_str, Style.default);
|
||||
|
||||
// Help text
|
||||
const help = "j/k:Navigate | g/G:First/Last | q:Quit";
|
||||
const help_x = if (inner.width > help.len) inner.right() - @as(u16, @intCast(help.len)) else inner.left();
|
||||
_ = buf.setString(help_x, inner.top(), help, Style.default.fg(Color.white));
|
||||
}
|
||||
|
|
@ -1,601 +0,0 @@
|
|||
//! Animation system for zcatui.
|
||||
//!
|
||||
//! Provides tools for creating smooth animations and transitions:
|
||||
//! - Easing functions (linear, ease-in, ease-out, etc.)
|
||||
//! - Tween animations between values
|
||||
//! - Animation sequences and timelines
|
||||
//! - Frame-based animation state management
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```zig
|
||||
//! var anim = Animation.init(0, 100, 1000); // 0 to 100 over 1000ms
|
||||
//! anim.easing = Easing.easeInOut;
|
||||
//!
|
||||
//! while (!anim.isComplete()) {
|
||||
//! const value = anim.getValue();
|
||||
//! // Use value for rendering
|
||||
//! anim.advance(delta_ms);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
// ============================================================================
|
||||
// Easing Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Collection of easing functions for smooth animations.
|
||||
///
|
||||
/// All functions take a progress value t in [0, 1] and return
|
||||
/// the eased value in [0, 1].
|
||||
pub const Easing = struct {
|
||||
/// Linear interpolation (no easing).
|
||||
pub fn linear(t: f64) f64 {
|
||||
return t;
|
||||
}
|
||||
|
||||
/// Quadratic ease-in (starts slow, accelerates).
|
||||
pub fn easeIn(t: f64) f64 {
|
||||
return t * t;
|
||||
}
|
||||
|
||||
/// Quadratic ease-out (starts fast, decelerates).
|
||||
pub fn easeOut(t: f64) f64 {
|
||||
return 1.0 - (1.0 - t) * (1.0 - t);
|
||||
}
|
||||
|
||||
/// Quadratic ease-in-out (smooth start and end).
|
||||
pub fn easeInOut(t: f64) f64 {
|
||||
if (t < 0.5) {
|
||||
return 2.0 * t * t;
|
||||
} else {
|
||||
return 1.0 - std.math.pow(f64, -2.0 * t + 2.0, 2) / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cubic ease-in.
|
||||
pub fn easeInCubic(t: f64) f64 {
|
||||
return t * t * t;
|
||||
}
|
||||
|
||||
/// Cubic ease-out.
|
||||
pub fn easeOutCubic(t: f64) f64 {
|
||||
return 1.0 - std.math.pow(f64, 1.0 - t, 3);
|
||||
}
|
||||
|
||||
/// Cubic ease-in-out.
|
||||
pub fn easeInOutCubic(t: f64) f64 {
|
||||
if (t < 0.5) {
|
||||
return 4.0 * t * t * t;
|
||||
} else {
|
||||
return 1.0 - std.math.pow(f64, -2.0 * t + 2.0, 3) / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential ease-in.
|
||||
pub fn easeInExpo(t: f64) f64 {
|
||||
if (t == 0) return 0;
|
||||
return std.math.pow(f64, 2, 10 * (t - 1));
|
||||
}
|
||||
|
||||
/// Exponential ease-out.
|
||||
pub fn easeOutExpo(t: f64) f64 {
|
||||
if (t == 1) return 1;
|
||||
return 1.0 - std.math.pow(f64, 2, -10 * t);
|
||||
}
|
||||
|
||||
/// Bounce ease-out (bounces at the end).
|
||||
pub fn easeOutBounce(t: f64) f64 {
|
||||
const n1: f64 = 7.5625;
|
||||
const d1: f64 = 2.75;
|
||||
|
||||
if (t < 1.0 / d1) {
|
||||
return n1 * t * t;
|
||||
} else if (t < 2.0 / d1) {
|
||||
const t2 = t - 1.5 / d1;
|
||||
return n1 * t2 * t2 + 0.75;
|
||||
} else if (t < 2.5 / d1) {
|
||||
const t2 = t - 2.25 / d1;
|
||||
return n1 * t2 * t2 + 0.9375;
|
||||
} else {
|
||||
const t2 = t - 2.625 / d1;
|
||||
return n1 * t2 * t2 + 0.984375;
|
||||
}
|
||||
}
|
||||
|
||||
/// Elastic ease-out (spring-like).
|
||||
pub fn easeOutElastic(t: f64) f64 {
|
||||
const c4: f64 = (2.0 * std.math.pi) / 3.0;
|
||||
|
||||
if (t == 0) return 0;
|
||||
if (t == 1) return 1;
|
||||
|
||||
return std.math.pow(f64, 2, -10 * t) * @sin((t * 10 - 0.75) * c4) + 1.0;
|
||||
}
|
||||
|
||||
/// Back ease-out (overshoots then returns).
|
||||
pub fn easeOutBack(t: f64) f64 {
|
||||
const c1: f64 = 1.70158;
|
||||
const c3: f64 = c1 + 1;
|
||||
const t2 = t - 1;
|
||||
return 1.0 + c3 * std.math.pow(f64, t2, 3) + c1 * std.math.pow(f64, t2, 2);
|
||||
}
|
||||
};
|
||||
|
||||
/// Type alias for easing function pointer.
|
||||
pub const EasingFn = *const fn (f64) f64;
|
||||
|
||||
// ============================================================================
|
||||
// Animation
|
||||
// ============================================================================
|
||||
|
||||
/// A single value animation (tween).
|
||||
///
|
||||
/// Animates a value from `start` to `end` over a specified duration.
|
||||
pub const Animation = struct {
|
||||
/// Starting value.
|
||||
start_value: f64,
|
||||
/// Ending value.
|
||||
end_value: f64,
|
||||
/// Total duration in milliseconds.
|
||||
duration_ms: u64,
|
||||
/// Current elapsed time in milliseconds.
|
||||
elapsed_ms: u64 = 0,
|
||||
/// Easing function to use.
|
||||
easing: EasingFn = Easing.linear,
|
||||
/// Whether the animation is paused.
|
||||
paused: bool = false,
|
||||
/// Number of times to repeat (-1 for infinite).
|
||||
repeat_count: i32 = 0,
|
||||
/// Current repeat iteration.
|
||||
current_repeat: i32 = 0,
|
||||
/// Whether to reverse on repeat (ping-pong).
|
||||
reverse_on_repeat: bool = false,
|
||||
/// Current direction (true = forward).
|
||||
forward: bool = true,
|
||||
/// Delay before starting (ms).
|
||||
delay_ms: u64 = 0,
|
||||
/// Remaining delay.
|
||||
remaining_delay_ms: u64 = 0,
|
||||
|
||||
/// Creates a new animation.
|
||||
pub fn init(start: f64, end: f64, duration_ms: u64) Animation {
|
||||
return .{
|
||||
.start_value = start,
|
||||
.end_value = end,
|
||||
.duration_ms = duration_ms,
|
||||
.remaining_delay_ms = 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates an animation from an integer value.
|
||||
pub fn fromInt(start: i64, end: i64, duration_ms: u64) Animation {
|
||||
return init(@floatFromInt(start), @floatFromInt(end), duration_ms);
|
||||
}
|
||||
|
||||
/// Sets the easing function.
|
||||
pub fn setEasing(self: Animation, easing: EasingFn) Animation {
|
||||
var anim = self;
|
||||
anim.easing = easing;
|
||||
return anim;
|
||||
}
|
||||
|
||||
/// Sets the repeat count (-1 for infinite).
|
||||
pub fn setRepeat(self: Animation, count: i32) Animation {
|
||||
var anim = self;
|
||||
anim.repeat_count = count;
|
||||
return anim;
|
||||
}
|
||||
|
||||
/// Sets ping-pong mode (reverse on repeat).
|
||||
pub fn setPingPong(self: Animation, enabled: bool) Animation {
|
||||
var anim = self;
|
||||
anim.reverse_on_repeat = enabled;
|
||||
return anim;
|
||||
}
|
||||
|
||||
/// Sets the delay before starting.
|
||||
pub fn setDelay(self: Animation, delay_ms: u64) Animation {
|
||||
var anim = self;
|
||||
anim.delay_ms = delay_ms;
|
||||
anim.remaining_delay_ms = delay_ms;
|
||||
return anim;
|
||||
}
|
||||
|
||||
/// Advances the animation by the given delta time.
|
||||
pub fn advance(self: *Animation, delta_ms: u64) void {
|
||||
if (self.paused) return;
|
||||
|
||||
// Handle delay
|
||||
if (self.remaining_delay_ms > 0) {
|
||||
if (delta_ms >= self.remaining_delay_ms) {
|
||||
const remaining = delta_ms - self.remaining_delay_ms;
|
||||
self.remaining_delay_ms = 0;
|
||||
if (remaining > 0) {
|
||||
self.advance(remaining);
|
||||
}
|
||||
} else {
|
||||
self.remaining_delay_ms -= delta_ms;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance time
|
||||
if (self.forward) {
|
||||
self.elapsed_ms +|= delta_ms;
|
||||
} else {
|
||||
if (delta_ms >= self.elapsed_ms) {
|
||||
self.elapsed_ms = 0;
|
||||
} else {
|
||||
self.elapsed_ms -= delta_ms;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for completion
|
||||
if (self.forward and self.elapsed_ms >= self.duration_ms) {
|
||||
self.elapsed_ms = self.duration_ms;
|
||||
self.handleRepeat();
|
||||
} else if (!self.forward and self.elapsed_ms == 0) {
|
||||
self.handleRepeat();
|
||||
}
|
||||
}
|
||||
|
||||
fn handleRepeat(self: *Animation) void {
|
||||
if (self.repeat_count == 0) return; // No repeat, stay complete
|
||||
|
||||
if (self.repeat_count < 0 or self.current_repeat < self.repeat_count) {
|
||||
self.current_repeat += 1;
|
||||
|
||||
if (self.reverse_on_repeat) {
|
||||
self.forward = !self.forward;
|
||||
} else {
|
||||
self.elapsed_ms = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current progress (0.0 to 1.0).
|
||||
pub fn getProgress(self: *const Animation) f64 {
|
||||
if (self.duration_ms == 0) return 1.0;
|
||||
const raw = @as(f64, @floatFromInt(self.elapsed_ms)) / @as(f64, @floatFromInt(self.duration_ms));
|
||||
return @min(1.0, @max(0.0, raw));
|
||||
}
|
||||
|
||||
/// Returns the current eased progress.
|
||||
pub fn getEasedProgress(self: *const Animation) f64 {
|
||||
return self.easing(self.getProgress());
|
||||
}
|
||||
|
||||
/// Returns the current animated value.
|
||||
pub fn getValue(self: *const Animation) f64 {
|
||||
const t = self.getEasedProgress();
|
||||
return self.start_value + (self.end_value - self.start_value) * t;
|
||||
}
|
||||
|
||||
/// Returns the current value as an integer.
|
||||
pub fn getValueInt(self: *const Animation) i64 {
|
||||
return @intFromFloat(@round(self.getValue()));
|
||||
}
|
||||
|
||||
/// Returns the current value as a u16.
|
||||
pub fn getValueU16(self: *const Animation) u16 {
|
||||
const val = self.getValueInt();
|
||||
if (val < 0) return 0;
|
||||
if (val > 65535) return 65535;
|
||||
return @intCast(val);
|
||||
}
|
||||
|
||||
/// Returns true if the animation is complete.
|
||||
pub fn isComplete(self: *const Animation) bool {
|
||||
if (self.repeat_count < 0) return false; // Infinite
|
||||
if (self.repeat_count > 0 and self.current_repeat < self.repeat_count) return false;
|
||||
// For ping-pong, also check if we're going backward (not yet done)
|
||||
if (self.reverse_on_repeat and !self.forward) return false;
|
||||
return self.elapsed_ms >= self.duration_ms;
|
||||
}
|
||||
|
||||
/// Returns true if the animation is running.
|
||||
pub fn isRunning(self: *const Animation) bool {
|
||||
return !self.paused and !self.isComplete() and self.remaining_delay_ms == 0;
|
||||
}
|
||||
|
||||
/// Pauses the animation.
|
||||
pub fn pause(self: *Animation) void {
|
||||
self.paused = true;
|
||||
}
|
||||
|
||||
/// Resumes the animation.
|
||||
pub fn unpause(self: *Animation) void {
|
||||
self.paused = false;
|
||||
}
|
||||
|
||||
/// Resets the animation to the beginning.
|
||||
pub fn reset(self: *Animation) void {
|
||||
self.elapsed_ms = 0;
|
||||
self.current_repeat = 0;
|
||||
self.forward = true;
|
||||
self.remaining_delay_ms = self.delay_ms;
|
||||
}
|
||||
|
||||
/// Seeks to a specific time.
|
||||
pub fn seekTo(self: *Animation, time_ms: u64) void {
|
||||
self.elapsed_ms = @min(time_ms, self.duration_ms);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AnimationGroup
|
||||
// ============================================================================
|
||||
|
||||
/// A group of animations that can be run in parallel or sequence.
|
||||
pub const AnimationGroup = struct {
|
||||
/// Animations in the group.
|
||||
animations: []Animation,
|
||||
/// Whether to run in parallel (true) or sequence (false).
|
||||
is_parallel: bool = true,
|
||||
/// Index of current animation (for sequential mode).
|
||||
current_index: usize = 0,
|
||||
|
||||
/// Creates a parallel animation group.
|
||||
pub fn parallel(animations: []Animation) AnimationGroup {
|
||||
return .{
|
||||
.animations = animations,
|
||||
.is_parallel = true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates a sequential animation group.
|
||||
pub fn sequential(animations: []Animation) AnimationGroup {
|
||||
return .{
|
||||
.animations = animations,
|
||||
.is_parallel = false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Advances all animations.
|
||||
pub fn advance(self: *AnimationGroup, delta_ms: u64) void {
|
||||
if (self.is_parallel) {
|
||||
for (self.animations) |*anim| {
|
||||
anim.advance(delta_ms);
|
||||
}
|
||||
} else {
|
||||
// Sequential
|
||||
if (self.current_index < self.animations.len) {
|
||||
self.animations[self.current_index].advance(delta_ms);
|
||||
if (self.animations[self.current_index].isComplete()) {
|
||||
self.current_index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if all animations are complete.
|
||||
pub fn isComplete(self: *const AnimationGroup) bool {
|
||||
if (self.is_parallel) {
|
||||
for (self.animations) |*anim| {
|
||||
if (!anim.isComplete()) return false;
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
return self.current_index >= self.animations.len;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets all animations.
|
||||
pub fn reset(self: *AnimationGroup) void {
|
||||
for (self.animations) |*anim| {
|
||||
anim.reset();
|
||||
}
|
||||
self.current_index = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Timer
|
||||
// ============================================================================
|
||||
|
||||
/// A simple timer for frame-based updates.
|
||||
pub const Timer = struct {
|
||||
/// Duration in milliseconds.
|
||||
duration_ms: u64,
|
||||
/// Elapsed time in milliseconds.
|
||||
elapsed_ms: u64 = 0,
|
||||
/// Whether the timer repeats.
|
||||
is_repeating: bool = false,
|
||||
/// Whether the timer is running.
|
||||
running: bool = true,
|
||||
/// Callback data (for user purposes).
|
||||
user_data: usize = 0,
|
||||
|
||||
/// Creates a one-shot timer.
|
||||
pub fn oneShot(duration_ms: u64) Timer {
|
||||
return .{
|
||||
.duration_ms = duration_ms,
|
||||
.is_repeating = false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates a repeating timer.
|
||||
pub fn repeating(duration_ms: u64) Timer {
|
||||
return .{
|
||||
.duration_ms = duration_ms,
|
||||
.is_repeating = true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Advances the timer. Returns true if the timer triggered.
|
||||
pub fn advance(self: *Timer, delta_ms: u64) bool {
|
||||
if (!self.running) return false;
|
||||
|
||||
self.elapsed_ms +|= delta_ms;
|
||||
|
||||
if (self.elapsed_ms >= self.duration_ms) {
|
||||
if (self.is_repeating) {
|
||||
self.elapsed_ms = self.elapsed_ms % self.duration_ms;
|
||||
} else {
|
||||
self.running = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns the progress (0.0 to 1.0).
|
||||
pub fn progress(self: *const Timer) f64 {
|
||||
if (self.duration_ms == 0) return 1.0;
|
||||
return @as(f64, @floatFromInt(self.elapsed_ms)) / @as(f64, @floatFromInt(self.duration_ms));
|
||||
}
|
||||
|
||||
/// Resets the timer.
|
||||
pub fn reset(self: *Timer) void {
|
||||
self.elapsed_ms = 0;
|
||||
self.running = true;
|
||||
}
|
||||
|
||||
/// Stops the timer.
|
||||
pub fn stop(self: *Timer) void {
|
||||
self.running = false;
|
||||
}
|
||||
|
||||
/// Starts/resumes the timer.
|
||||
pub fn start(self: *Timer) void {
|
||||
self.running = true;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Interpolation helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Linear interpolation between two values.
|
||||
pub fn lerp(a: f64, b: f64, t: f64) f64 {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
/// Linear interpolation for integers.
|
||||
pub fn lerpInt(a: i64, b: i64, t: f64) i64 {
|
||||
return @intFromFloat(@round(lerp(@floatFromInt(a), @floatFromInt(b), t)));
|
||||
}
|
||||
|
||||
/// Inverse linear interpolation (find t given value).
|
||||
pub fn inverseLerp(a: f64, b: f64, value: f64) f64 {
|
||||
if (b == a) return 0;
|
||||
return (value - a) / (b - a);
|
||||
}
|
||||
|
||||
/// Maps a value from one range to another.
|
||||
pub fn mapRange(value: f64, in_min: f64, in_max: f64, out_min: f64, out_max: f64) f64 {
|
||||
const t = inverseLerp(in_min, in_max, value);
|
||||
return lerp(out_min, out_max, t);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Easing.linear" {
|
||||
try std.testing.expectEqual(@as(f64, 0.0), Easing.linear(0.0));
|
||||
try std.testing.expectEqual(@as(f64, 0.5), Easing.linear(0.5));
|
||||
try std.testing.expectEqual(@as(f64, 1.0), Easing.linear(1.0));
|
||||
}
|
||||
|
||||
test "Easing.easeIn" {
|
||||
try std.testing.expectEqual(@as(f64, 0.0), Easing.easeIn(0.0));
|
||||
try std.testing.expectEqual(@as(f64, 0.25), Easing.easeIn(0.5));
|
||||
try std.testing.expectEqual(@as(f64, 1.0), Easing.easeIn(1.0));
|
||||
}
|
||||
|
||||
test "Easing.easeOut" {
|
||||
try std.testing.expectEqual(@as(f64, 0.0), Easing.easeOut(0.0));
|
||||
try std.testing.expectEqual(@as(f64, 0.75), Easing.easeOut(0.5));
|
||||
try std.testing.expectEqual(@as(f64, 1.0), Easing.easeOut(1.0));
|
||||
}
|
||||
|
||||
test "Animation basic" {
|
||||
var anim = Animation.init(0, 100, 1000);
|
||||
|
||||
try std.testing.expectEqual(@as(f64, 0), anim.getValue());
|
||||
try std.testing.expect(!anim.isComplete());
|
||||
|
||||
anim.advance(500);
|
||||
try std.testing.expectEqual(@as(f64, 50), anim.getValue());
|
||||
try std.testing.expect(!anim.isComplete());
|
||||
|
||||
anim.advance(500);
|
||||
try std.testing.expectEqual(@as(f64, 100), anim.getValue());
|
||||
try std.testing.expect(anim.isComplete());
|
||||
}
|
||||
|
||||
test "Animation with easing" {
|
||||
var anim = Animation.init(0, 100, 1000).setEasing(Easing.easeIn);
|
||||
|
||||
anim.advance(500);
|
||||
// easeIn at 0.5 = 0.25, so value should be 25
|
||||
try std.testing.expectEqual(@as(f64, 25), anim.getValue());
|
||||
}
|
||||
|
||||
test "Animation repeat" {
|
||||
var anim = Animation.init(0, 100, 100).setRepeat(2);
|
||||
|
||||
// First iteration
|
||||
anim.advance(100);
|
||||
try std.testing.expect(!anim.isComplete());
|
||||
|
||||
// Second iteration
|
||||
anim.advance(100);
|
||||
try std.testing.expect(!anim.isComplete());
|
||||
|
||||
// Third iteration (should complete)
|
||||
anim.advance(100);
|
||||
try std.testing.expect(anim.isComplete());
|
||||
}
|
||||
|
||||
test "Animation ping-pong" {
|
||||
var anim = Animation.init(0, 100, 100).setRepeat(1).setPingPong(true);
|
||||
|
||||
anim.advance(100); // Forward complete
|
||||
try std.testing.expect(!anim.isComplete());
|
||||
try std.testing.expect(!anim.forward);
|
||||
|
||||
anim.advance(50); // Half back
|
||||
try std.testing.expectEqual(@as(f64, 50), anim.getValue());
|
||||
}
|
||||
|
||||
test "Timer one-shot" {
|
||||
var timer = Timer.oneShot(100);
|
||||
|
||||
try std.testing.expect(!timer.advance(50));
|
||||
try std.testing.expect(timer.advance(50));
|
||||
try std.testing.expect(!timer.running);
|
||||
}
|
||||
|
||||
test "Timer repeating" {
|
||||
var timer = Timer.repeating(100);
|
||||
|
||||
try std.testing.expect(timer.advance(100));
|
||||
try std.testing.expect(timer.running);
|
||||
try std.testing.expect(timer.advance(100));
|
||||
try std.testing.expect(timer.running);
|
||||
}
|
||||
|
||||
test "lerp" {
|
||||
try std.testing.expectEqual(@as(f64, 0), lerp(0, 100, 0));
|
||||
try std.testing.expectEqual(@as(f64, 50), lerp(0, 100, 0.5));
|
||||
try std.testing.expectEqual(@as(f64, 100), lerp(0, 100, 1));
|
||||
try std.testing.expectEqual(@as(f64, 25), lerp(0, 100, 0.25));
|
||||
}
|
||||
|
||||
test "inverseLerp" {
|
||||
try std.testing.expectEqual(@as(f64, 0), inverseLerp(0, 100, 0));
|
||||
try std.testing.expectEqual(@as(f64, 0.5), inverseLerp(0, 100, 50));
|
||||
try std.testing.expectEqual(@as(f64, 1), inverseLerp(0, 100, 100));
|
||||
}
|
||||
|
||||
test "mapRange" {
|
||||
// Map 0-100 to 0-1
|
||||
try std.testing.expectEqual(@as(f64, 0.5), mapRange(50, 0, 100, 0, 1));
|
||||
// Map 0-100 to 100-200
|
||||
try std.testing.expectEqual(@as(f64, 150), mapRange(50, 0, 100, 100, 200));
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
//! Clipboard support via OSC 52 escape sequences.
|
||||
//!
|
||||
//! OSC 52 is a terminal escape sequence that allows programs to
|
||||
//! copy text to the system clipboard. It's supported by many
|
||||
//! modern terminals including:
|
||||
//! - xterm
|
||||
//! - iTerm2
|
||||
//! - kitty
|
||||
//! - alacritty
|
||||
//! - WezTerm
|
||||
//! - foot
|
||||
//! - tmux (with set-clipboard on)
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```zig
|
||||
//! const clipboard = @import("clipboard.zig");
|
||||
//!
|
||||
//! // Copy to clipboard
|
||||
//! try clipboard.copy(allocator, writer, "Hello, World!");
|
||||
//!
|
||||
//! // Copy with specific clipboard target
|
||||
//! try clipboard.copyTo(allocator, writer, "Hello", .primary);
|
||||
//! ```
|
||||
//!
|
||||
//! ## Security Note
|
||||
//!
|
||||
//! Some terminals disable OSC 52 by default for security reasons
|
||||
//! (it could potentially be abused by malicious content in cat'd files).
|
||||
//! Users may need to explicitly enable it in their terminal settings.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const base64 = std.base64;
|
||||
|
||||
/// Clipboard selection target.
|
||||
pub const Selection = enum {
|
||||
/// System clipboard (Ctrl+V)
|
||||
clipboard,
|
||||
/// Primary selection (middle-click paste on X11)
|
||||
primary,
|
||||
/// Both clipboard and primary
|
||||
both,
|
||||
|
||||
/// Returns the OSC 52 parameter string for this selection.
|
||||
fn toParam(self: Selection) []const u8 {
|
||||
return switch (self) {
|
||||
.clipboard => "c",
|
||||
.primary => "p",
|
||||
.both => "pc",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// OSC 52 escape sequence format:
|
||||
/// \x1b]52;<target>;<base64-data>\x07
|
||||
/// or with ST terminator:
|
||||
/// \x1b]52;<target>;<base64-data>\x1b\\
|
||||
const OSC_START = "\x1b]52;";
|
||||
const BEL = "\x07";
|
||||
const ST = "\x1b\\";
|
||||
|
||||
/// Copies text to the system clipboard via OSC 52.
|
||||
///
|
||||
/// Uses the standard 'c' (clipboard) selection target.
|
||||
pub fn copy(allocator: Allocator, writer: anytype, text: []const u8) !void {
|
||||
try copyTo(allocator, writer, text, .clipboard);
|
||||
}
|
||||
|
||||
/// Copies text to the specified clipboard selection.
|
||||
///
|
||||
/// `selection` can be:
|
||||
/// - `.clipboard` - system clipboard (Ctrl+V paste)
|
||||
/// - `.primary` - X11 primary selection (middle-click paste)
|
||||
/// - `.both` - copy to both
|
||||
pub fn copyTo(allocator: Allocator, writer: anytype, text: []const u8, selection: Selection) !void {
|
||||
// Calculate base64 encoded size
|
||||
const encoded_len = base64.standard.Encoder.calcSize(text.len);
|
||||
|
||||
// Allocate buffer for base64 encoded data
|
||||
const encoded = try allocator.alloc(u8, encoded_len);
|
||||
defer allocator.free(encoded);
|
||||
|
||||
// Encode text to base64
|
||||
_ = base64.standard.Encoder.encode(encoded, text);
|
||||
|
||||
// Write OSC 52 sequence
|
||||
try writer.writeAll(OSC_START);
|
||||
try writer.writeAll(selection.toParam());
|
||||
try writer.writeAll(";");
|
||||
try writer.writeAll(encoded);
|
||||
try writer.writeAll(BEL);
|
||||
}
|
||||
|
||||
/// Copies text using a stack buffer (no allocation needed for small strings).
|
||||
///
|
||||
/// Maximum text length is 1024 bytes (results in ~1365 base64 chars).
|
||||
/// For longer text, use `copy()` which allocates.
|
||||
pub fn copySmall(writer: anytype, text: []const u8) !void {
|
||||
try copySmallTo(writer, text, .clipboard);
|
||||
}
|
||||
|
||||
/// Copies text to the specified selection using a stack buffer.
|
||||
pub fn copySmallTo(writer: anytype, text: []const u8, selection: Selection) !void {
|
||||
const max_text_len = 1024;
|
||||
if (text.len > max_text_len) {
|
||||
return error.TextTooLong;
|
||||
}
|
||||
|
||||
// Stack buffer for base64 (1024 bytes -> 1368 base64 chars max)
|
||||
var encoded: [1368]u8 = undefined;
|
||||
const encoded_len = base64.standard.Encoder.calcSize(text.len);
|
||||
|
||||
_ = base64.standard.Encoder.encode(encoded[0..encoded_len], text);
|
||||
|
||||
// Write OSC 52 sequence
|
||||
try writer.writeAll(OSC_START);
|
||||
try writer.writeAll(selection.toParam());
|
||||
try writer.writeAll(";");
|
||||
try writer.writeAll(encoded[0..encoded_len]);
|
||||
try writer.writeAll(BEL);
|
||||
}
|
||||
|
||||
/// Clears the clipboard contents.
|
||||
pub fn clear(writer: anytype) !void {
|
||||
try clearSelection(writer, .clipboard);
|
||||
}
|
||||
|
||||
/// Clears the specified clipboard selection.
|
||||
pub fn clearSelection(writer: anytype, selection: Selection) !void {
|
||||
// Empty base64 data clears the clipboard
|
||||
try writer.writeAll(OSC_START);
|
||||
try writer.writeAll(selection.toParam());
|
||||
try writer.writeAll(";");
|
||||
try writer.writeAll(BEL);
|
||||
}
|
||||
|
||||
/// Queries the clipboard contents (not widely supported).
|
||||
///
|
||||
/// Note: Most terminals don't support this for security reasons.
|
||||
/// When supported, the terminal will respond with OSC 52 containing
|
||||
/// the clipboard contents in base64.
|
||||
pub fn query(writer: anytype) !void {
|
||||
try querySelection(writer, .clipboard);
|
||||
}
|
||||
|
||||
/// Queries the specified clipboard selection.
|
||||
pub fn querySelection(writer: anytype, selection: Selection) !void {
|
||||
// '?' requests clipboard contents
|
||||
try writer.writeAll(OSC_START);
|
||||
try writer.writeAll(selection.toParam());
|
||||
try writer.writeAll(";?");
|
||||
try writer.writeAll(BEL);
|
||||
}
|
||||
|
||||
/// Parses an OSC 52 response to extract clipboard contents.
|
||||
///
|
||||
/// Returns null if the response is not a valid OSC 52 sequence.
|
||||
/// The returned slice is allocated and must be freed by the caller.
|
||||
pub fn parseResponse(allocator: Allocator, response: []const u8) !?[]u8 {
|
||||
// Check for OSC 52 prefix
|
||||
if (!std.mem.startsWith(u8, response, OSC_START)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the semicolon after selection parameter
|
||||
const data_start = std.mem.indexOfPos(u8, response, OSC_START.len, ";") orelse return null;
|
||||
|
||||
// Find the terminator (BEL or ST)
|
||||
const data_end = blk: {
|
||||
if (std.mem.indexOf(u8, response[data_start..], BEL)) |pos| {
|
||||
break :blk data_start + pos;
|
||||
}
|
||||
if (std.mem.indexOf(u8, response[data_start..], ST)) |pos| {
|
||||
break :blk data_start + pos;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const base64_data = response[data_start + 1 .. data_end];
|
||||
|
||||
if (base64_data.len == 0) {
|
||||
// Empty clipboard
|
||||
return try allocator.alloc(u8, 0);
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
const decoded_len = base64.standard.Decoder.calcSizeForSlice(base64_data) catch return null;
|
||||
const decoded = try allocator.alloc(u8, decoded_len);
|
||||
errdefer allocator.free(decoded);
|
||||
|
||||
base64.standard.Decoder.decode(decoded, base64_data) catch {
|
||||
allocator.free(decoded);
|
||||
return null;
|
||||
};
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
test "copy generates correct OSC 52 sequence" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var stream = std.io.fixedBufferStream(&buf);
|
||||
|
||||
try copySmall(stream.writer(), "hello");
|
||||
|
||||
const expected = "\x1b]52;c;aGVsbG8=\x07";
|
||||
try std.testing.expectEqualStrings(expected, stream.getWritten());
|
||||
}
|
||||
|
||||
test "copyTo with primary selection" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var stream = std.io.fixedBufferStream(&buf);
|
||||
|
||||
try copySmallTo(stream.writer(), "test", .primary);
|
||||
|
||||
const expected = "\x1b]52;p;dGVzdA==\x07";
|
||||
try std.testing.expectEqualStrings(expected, stream.getWritten());
|
||||
}
|
||||
|
||||
test "copyTo with both selections" {
|
||||
var buf: [256]u8 = undefined;
|
||||
var stream = std.io.fixedBufferStream(&buf);
|
||||
|
||||
try copySmallTo(stream.writer(), "test", .both);
|
||||
|
||||
const expected = "\x1b]52;pc;dGVzdA==\x07";
|
||||
try std.testing.expectEqualStrings(expected, stream.getWritten());
|
||||
}
|
||||
|
||||
test "clear clipboard" {
|
||||
var buf: [64]u8 = undefined;
|
||||
var stream = std.io.fixedBufferStream(&buf);
|
||||
|
||||
try clear(stream.writer());
|
||||
|
||||
const expected = "\x1b]52;c;\x07";
|
||||
try std.testing.expectEqualStrings(expected, stream.getWritten());
|
||||
}
|
||||
|
||||
test "copy with allocator" {
|
||||
const allocator = std.testing.allocator;
|
||||
var buf: [256]u8 = undefined;
|
||||
var stream = std.io.fixedBufferStream(&buf);
|
||||
|
||||
try copy(allocator, stream.writer(), "hello world");
|
||||
|
||||
const expected = "\x1b]52;c;aGVsbG8gd29ybGQ=\x07";
|
||||
try std.testing.expectEqualStrings(expected, stream.getWritten());
|
||||
}
|
||||
|
||||
test "parseResponse valid response" {
|
||||
const allocator = std.testing.allocator;
|
||||
const response = "\x1b]52;c;aGVsbG8=\x07";
|
||||
|
||||
const result = try parseResponse(allocator, response);
|
||||
defer if (result) |r| allocator.free(r);
|
||||
|
||||
try std.testing.expect(result != null);
|
||||
try std.testing.expectEqualStrings("hello", result.?);
|
||||
}
|
||||
|
||||
test "parseResponse invalid response" {
|
||||
const allocator = std.testing.allocator;
|
||||
const result = try parseResponse(allocator, "not an osc sequence");
|
||||
try std.testing.expect(result == null);
|
||||
}
|
||||
|
||||
test "copySmall rejects long text" {
|
||||
var buf: [2048]u8 = undefined;
|
||||
var stream = std.io.fixedBufferStream(&buf);
|
||||
|
||||
// Create text longer than 1024 bytes
|
||||
var long_text: [1025]u8 = undefined;
|
||||
@memset(&long_text, 'x');
|
||||
|
||||
const result = copySmall(stream.writer(), &long_text);
|
||||
try std.testing.expectError(error.TextTooLong, result);
|
||||
}
|
||||
16
src/root.zig
16
src/root.zig
|
|
@ -129,10 +129,6 @@ pub const widgets = struct {
|
|||
pub const Monthly = calendar_mod.Monthly;
|
||||
pub const Date = calendar_mod.Date;
|
||||
pub const CalendarEventStore = calendar_mod.CalendarEventStore;
|
||||
|
||||
pub const input_mod = @import("widgets/input.zig");
|
||||
pub const Input = input_mod.Input;
|
||||
pub const InputState = input_mod.InputState;
|
||||
};
|
||||
|
||||
// Backend
|
||||
|
|
@ -160,18 +156,6 @@ pub const cursor = @import("cursor.zig");
|
|||
pub const Cursor = cursor.Cursor;
|
||||
pub const CursorStyle = cursor.CursorStyle;
|
||||
|
||||
// Animation system
|
||||
pub const animation = @import("animation.zig");
|
||||
pub const Animation = animation.Animation;
|
||||
pub const AnimationGroup = animation.AnimationGroup;
|
||||
pub const Easing = animation.Easing;
|
||||
pub const Timer = animation.Timer;
|
||||
|
||||
// Clipboard (OSC 52)
|
||||
pub const clipboard = @import("clipboard.zig");
|
||||
pub const Clipboard = clipboard;
|
||||
pub const ClipboardSelection = clipboard.Selection;
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,755 +0,0 @@
|
|||
//! Text input widget with readline-style editing and history.
|
||||
//!
|
||||
//! The Input widget provides a single-line text input field with:
|
||||
//! - Basic editing (insert, delete, backspace)
|
||||
//! - Cursor movement (home, end, left, right, word navigation)
|
||||
//! - Kill/yank (Ctrl+K, Ctrl+Y)
|
||||
//! - Clear line (Ctrl+U)
|
||||
//! - History navigation (up/down arrows)
|
||||
//!
|
||||
//! This widget is stateful - use InputState to track the input state
|
||||
//! across frames and event handling.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const style_mod = @import("../style.zig");
|
||||
const Style = style_mod.Style;
|
||||
const Color = style_mod.Color;
|
||||
const buffer_mod = @import("../buffer.zig");
|
||||
const Buffer = buffer_mod.Buffer;
|
||||
const Rect = buffer_mod.Rect;
|
||||
const text_mod = @import("../text.zig");
|
||||
const Line = text_mod.Line;
|
||||
const event_mod = @import("../event.zig");
|
||||
const Event = event_mod.Event;
|
||||
const KeyEvent = event_mod.KeyEvent;
|
||||
const KeyCode = event_mod.KeyCode;
|
||||
const KeyModifiers = event_mod.KeyModifiers;
|
||||
const block_mod = @import("block.zig");
|
||||
const Block = block_mod.Block;
|
||||
|
||||
// ============================================================================
|
||||
// InputState
|
||||
// ============================================================================
|
||||
|
||||
/// State for the Input widget.
|
||||
///
|
||||
/// This struct manages the input buffer, cursor position, history,
|
||||
/// and the kill ring (for Ctrl+K/Ctrl+Y operations).
|
||||
pub const InputState = struct {
|
||||
/// The current input buffer.
|
||||
buffer: std.ArrayListUnmanaged(u8) = .{},
|
||||
/// Current cursor position (byte offset).
|
||||
cursor: usize = 0,
|
||||
/// View offset for scrolling (byte offset of first visible char).
|
||||
view_offset: usize = 0,
|
||||
/// Command history.
|
||||
history: std.ArrayListUnmanaged([]u8) = .{},
|
||||
/// Current position in history (-1 = current input).
|
||||
history_index: isize = -1,
|
||||
/// Saved current input when navigating history.
|
||||
saved_input: ?[]u8 = null,
|
||||
/// Kill ring (for Ctrl+K/Ctrl+Y).
|
||||
kill_ring: ?[]u8 = null,
|
||||
/// Allocator for dynamic memory.
|
||||
allocator: Allocator,
|
||||
|
||||
/// Creates a new InputState.
|
||||
pub fn init(allocator: Allocator) InputState {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Deinitializes and frees all memory.
|
||||
pub fn deinit(self: *InputState) void {
|
||||
self.buffer.deinit(self.allocator);
|
||||
for (self.history.items) |item| {
|
||||
self.allocator.free(item);
|
||||
}
|
||||
self.history.deinit(self.allocator);
|
||||
if (self.saved_input) |saved| {
|
||||
self.allocator.free(saved);
|
||||
}
|
||||
if (self.kill_ring) |ring| {
|
||||
self.allocator.free(ring);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current input as a string slice.
|
||||
pub fn value(self: *const InputState) []const u8 {
|
||||
return self.buffer.items;
|
||||
}
|
||||
|
||||
/// Sets the input value and moves cursor to end.
|
||||
pub fn setValue(self: *InputState, text: []const u8) !void {
|
||||
self.buffer.clearRetainingCapacity();
|
||||
try self.buffer.appendSlice(self.allocator, text);
|
||||
self.cursor = self.buffer.items.len;
|
||||
}
|
||||
|
||||
/// Clears the input.
|
||||
pub fn clear(self: *InputState) void {
|
||||
self.buffer.clearRetainingCapacity();
|
||||
self.cursor = 0;
|
||||
self.view_offset = 0;
|
||||
}
|
||||
|
||||
/// Adds the current input to history and clears input.
|
||||
pub fn submit(self: *InputState) ![]const u8 {
|
||||
if (self.buffer.items.len == 0) return "";
|
||||
|
||||
// Copy current input for return
|
||||
const result = try self.allocator.dupe(u8, self.buffer.items);
|
||||
|
||||
// Add to history (if not duplicate of last entry)
|
||||
const should_add = if (self.history.items.len > 0)
|
||||
!std.mem.eql(u8, self.history.items[self.history.items.len - 1], self.buffer.items)
|
||||
else
|
||||
true;
|
||||
|
||||
if (should_add) {
|
||||
const hist_copy = try self.allocator.dupe(u8, self.buffer.items);
|
||||
try self.history.append(self.allocator, hist_copy);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
self.clear();
|
||||
self.history_index = -1;
|
||||
if (self.saved_input) |saved| {
|
||||
self.allocator.free(saved);
|
||||
self.saved_input = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Cursor movement
|
||||
// ========================================================================
|
||||
|
||||
/// Moves cursor left by one character.
|
||||
pub fn cursorLeft(self: *InputState) void {
|
||||
if (self.cursor > 0) {
|
||||
// Move back one UTF-8 character
|
||||
self.cursor = prevCharBoundary(self.buffer.items, self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves cursor right by one character.
|
||||
pub fn cursorRight(self: *InputState) void {
|
||||
if (self.cursor < self.buffer.items.len) {
|
||||
// Move forward one UTF-8 character
|
||||
self.cursor = nextCharBoundary(self.buffer.items, self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves cursor to start of line.
|
||||
pub fn cursorHome(self: *InputState) void {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Moves cursor to end of line.
|
||||
pub fn cursorEnd(self: *InputState) void {
|
||||
self.cursor = self.buffer.items.len;
|
||||
}
|
||||
|
||||
/// Moves cursor to start of previous word.
|
||||
pub fn cursorWordLeft(self: *InputState) void {
|
||||
if (self.cursor == 0) return;
|
||||
|
||||
var pos = self.cursor;
|
||||
|
||||
// Skip any spaces before cursor
|
||||
while (pos > 0 and isSpace(self.buffer.items[pos - 1])) {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Skip to start of word
|
||||
while (pos > 0 and !isSpace(self.buffer.items[pos - 1])) {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
self.cursor = pos;
|
||||
}
|
||||
|
||||
/// Moves cursor to start of next word.
|
||||
pub fn cursorWordRight(self: *InputState) void {
|
||||
if (self.cursor >= self.buffer.items.len) return;
|
||||
|
||||
var pos = self.cursor;
|
||||
|
||||
// Skip current word
|
||||
while (pos < self.buffer.items.len and !isSpace(self.buffer.items[pos])) {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Skip spaces
|
||||
while (pos < self.buffer.items.len and isSpace(self.buffer.items[pos])) {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
self.cursor = pos;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Editing
|
||||
// ========================================================================
|
||||
|
||||
/// Inserts a single byte at cursor position.
|
||||
pub fn insertByte(self: *InputState, byte: u8) !void {
|
||||
try self.buffer.insert(self.allocator, self.cursor, byte);
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
/// Inserts a Unicode codepoint at cursor position (UTF-8 encoded).
|
||||
pub fn insert(self: *InputState, codepoint: u21) !void {
|
||||
var buf: [4]u8 = undefined;
|
||||
const len = std.unicode.utf8Encode(codepoint, &buf) catch return;
|
||||
try self.buffer.insertSlice(self.allocator, self.cursor, buf[0..len]);
|
||||
self.cursor += len;
|
||||
}
|
||||
|
||||
/// Inserts a string at cursor position.
|
||||
pub fn insertSlice(self: *InputState, text: []const u8) !void {
|
||||
try self.buffer.insertSlice(self.allocator, self.cursor, text);
|
||||
self.cursor += text.len;
|
||||
}
|
||||
|
||||
/// Deletes character before cursor (backspace).
|
||||
pub fn backspace(self: *InputState) void {
|
||||
if (self.cursor > 0) {
|
||||
const prev = prevCharBoundary(self.buffer.items, self.cursor);
|
||||
const char_len = self.cursor - prev;
|
||||
_ = self.buffer.orderedRemove(prev);
|
||||
for (1..char_len) |_| {
|
||||
if (prev < self.buffer.items.len) {
|
||||
_ = self.buffer.orderedRemove(prev);
|
||||
}
|
||||
}
|
||||
self.cursor = prev;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes character at cursor (delete key).
|
||||
pub fn delete(self: *InputState) void {
|
||||
if (self.cursor < self.buffer.items.len) {
|
||||
const next = nextCharBoundary(self.buffer.items, self.cursor);
|
||||
const char_len = next - self.cursor;
|
||||
for (0..char_len) |_| {
|
||||
if (self.cursor < self.buffer.items.len) {
|
||||
_ = self.buffer.orderedRemove(self.cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes word before cursor (Ctrl+W).
|
||||
pub fn deleteWordBack(self: *InputState) void {
|
||||
if (self.cursor == 0) return;
|
||||
|
||||
const start = self.cursor;
|
||||
|
||||
// Find start of word
|
||||
self.cursorWordLeft();
|
||||
const word_start = self.cursor;
|
||||
|
||||
// Delete from word_start to start
|
||||
if (word_start < start) {
|
||||
const len = start - word_start;
|
||||
for (0..len) |_| {
|
||||
if (word_start < self.buffer.items.len) {
|
||||
_ = self.buffer.orderedRemove(word_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kills from cursor to end of line (Ctrl+K).
|
||||
pub fn killToEnd(self: *InputState) !void {
|
||||
if (self.cursor >= self.buffer.items.len) return;
|
||||
|
||||
// Save to kill ring
|
||||
const killed = self.buffer.items[self.cursor..];
|
||||
if (self.kill_ring) |ring| {
|
||||
self.allocator.free(ring);
|
||||
}
|
||||
self.kill_ring = try self.allocator.dupe(u8, killed);
|
||||
|
||||
// Remove from buffer
|
||||
self.buffer.shrinkRetainingCapacity(self.cursor);
|
||||
}
|
||||
|
||||
/// Kills from start to cursor (Ctrl+U).
|
||||
pub fn killToStart(self: *InputState) !void {
|
||||
if (self.cursor == 0) return;
|
||||
|
||||
// Save to kill ring
|
||||
const killed = self.buffer.items[0..self.cursor];
|
||||
if (self.kill_ring) |ring| {
|
||||
self.allocator.free(ring);
|
||||
}
|
||||
self.kill_ring = try self.allocator.dupe(u8, killed);
|
||||
|
||||
// Remove from buffer
|
||||
for (0..self.cursor) |_| {
|
||||
_ = self.buffer.orderedRemove(0);
|
||||
}
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Yanks (pastes) from kill ring (Ctrl+Y).
|
||||
pub fn yank(self: *InputState) !void {
|
||||
if (self.kill_ring) |ring| {
|
||||
try self.insertSlice(ring);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// History
|
||||
// ========================================================================
|
||||
|
||||
/// Goes to previous history entry (up arrow).
|
||||
pub fn historyPrev(self: *InputState) !void {
|
||||
if (self.history.items.len == 0) return;
|
||||
|
||||
// Save current input if at bottom of history
|
||||
if (self.history_index == -1) {
|
||||
if (self.saved_input) |saved| {
|
||||
self.allocator.free(saved);
|
||||
}
|
||||
self.saved_input = try self.allocator.dupe(u8, self.buffer.items);
|
||||
}
|
||||
|
||||
// Move up in history
|
||||
if (self.history_index < @as(isize, @intCast(self.history.items.len)) - 1) {
|
||||
self.history_index += 1;
|
||||
const hist_idx: usize = @intCast(@as(isize, @intCast(self.history.items.len)) - 1 - self.history_index);
|
||||
try self.setValue(self.history.items[hist_idx]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Goes to next history entry (down arrow).
|
||||
pub fn historyNext(self: *InputState) !void {
|
||||
if (self.history_index < 0) return;
|
||||
|
||||
self.history_index -= 1;
|
||||
|
||||
if (self.history_index < 0) {
|
||||
// Restore saved input
|
||||
if (self.saved_input) |saved| {
|
||||
try self.setValue(saved);
|
||||
self.allocator.free(saved);
|
||||
self.saved_input = null;
|
||||
} else {
|
||||
self.clear();
|
||||
}
|
||||
} else {
|
||||
const hist_idx: usize = @intCast(@as(isize, @intCast(self.history.items.len)) - 1 - self.history_index);
|
||||
try self.setValue(self.history.items[hist_idx]);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Event handling
|
||||
// ========================================================================
|
||||
|
||||
/// Handles a key event. Returns true if the event was handled.
|
||||
pub fn handleEvent(self: *InputState, event: Event) !bool {
|
||||
switch (event) {
|
||||
.key => |key| return try self.handleKey(key),
|
||||
else => return false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a key event. Returns true if the event was handled.
|
||||
pub fn handleKey(self: *InputState, key: KeyEvent) !bool {
|
||||
const ctrl = key.modifiers.ctrl;
|
||||
|
||||
switch (key.code) {
|
||||
.char => |c| {
|
||||
if (ctrl) {
|
||||
switch (c) {
|
||||
'a', 'A' => self.cursorHome(),
|
||||
'e', 'E' => self.cursorEnd(),
|
||||
'b', 'B' => self.cursorLeft(),
|
||||
'f', 'F' => self.cursorRight(),
|
||||
'k', 'K' => try self.killToEnd(),
|
||||
'u', 'U' => try self.killToStart(),
|
||||
'y', 'Y' => try self.yank(),
|
||||
'w', 'W' => self.deleteWordBack(),
|
||||
'h', 'H' => self.backspace(),
|
||||
'd', 'D' => self.delete(),
|
||||
else => return false,
|
||||
}
|
||||
} else {
|
||||
try self.insert(c);
|
||||
}
|
||||
},
|
||||
.left => {
|
||||
if (ctrl) {
|
||||
self.cursorWordLeft();
|
||||
} else {
|
||||
self.cursorLeft();
|
||||
}
|
||||
},
|
||||
.right => {
|
||||
if (ctrl) {
|
||||
self.cursorWordRight();
|
||||
} else {
|
||||
self.cursorRight();
|
||||
}
|
||||
},
|
||||
.home => self.cursorHome(),
|
||||
.end => self.cursorEnd(),
|
||||
.backspace => {
|
||||
if (ctrl) {
|
||||
self.deleteWordBack();
|
||||
} else {
|
||||
self.backspace();
|
||||
}
|
||||
},
|
||||
.delete => self.delete(),
|
||||
.up => try self.historyPrev(),
|
||||
.down => try self.historyNext(),
|
||||
else => return false,
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Input Widget
|
||||
// ============================================================================
|
||||
|
||||
/// A single-line text input widget.
|
||||
///
|
||||
/// Use this with InputState for a complete readline-style input experience.
|
||||
pub const Input = struct {
|
||||
/// Optional block to wrap the input.
|
||||
block: ?Block = null,
|
||||
/// Base style for the widget.
|
||||
style: Style = Style.default,
|
||||
/// Style for the cursor.
|
||||
cursor_style: Style = Style.default.reversed(),
|
||||
/// Placeholder text when empty.
|
||||
placeholder: ?[]const u8 = null,
|
||||
/// Placeholder style.
|
||||
placeholder_style: Style = Style.default.fg(Color.white),
|
||||
/// Whether to show the cursor.
|
||||
show_cursor: bool = true,
|
||||
/// Mask character for passwords (null = no mask).
|
||||
mask: ?u8 = null,
|
||||
|
||||
/// Creates a new Input widget.
|
||||
pub fn init() Input {
|
||||
return .{};
|
||||
}
|
||||
|
||||
/// Sets the block wrapper.
|
||||
pub fn setBlock(self: Input, b: Block) Input {
|
||||
var input = self;
|
||||
input.block = b;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Sets the base style.
|
||||
pub fn setStyle(self: Input, s: Style) Input {
|
||||
var input = self;
|
||||
input.style = s;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Sets the cursor style.
|
||||
pub fn cursorStyle(self: Input, s: Style) Input {
|
||||
var input = self;
|
||||
input.cursor_style = s;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Sets the placeholder text.
|
||||
pub fn setPlaceholder(self: Input, text: []const u8) Input {
|
||||
var input = self;
|
||||
input.placeholder = text;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Sets the placeholder style.
|
||||
pub fn placeholderStyle(self: Input, s: Style) Input {
|
||||
var input = self;
|
||||
input.placeholder_style = s;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Sets whether to show the cursor.
|
||||
pub fn showCursor(self: Input, show: bool) Input {
|
||||
var input = self;
|
||||
input.show_cursor = show;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Sets the mask character for password input.
|
||||
pub fn setMask(self: Input, char: u8) Input {
|
||||
var input = self;
|
||||
input.mask = char;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Renders the input to a buffer with state.
|
||||
pub fn render(self: Input, area: Rect, buf: *Buffer, state: *InputState) void {
|
||||
if (area.isEmpty()) return;
|
||||
|
||||
buf.setStyle(area, self.style);
|
||||
|
||||
// Render block if present
|
||||
const input_area = if (self.block) |b| blk: {
|
||||
b.render(area, buf);
|
||||
break :blk b.inner(area);
|
||||
} else area;
|
||||
|
||||
if (input_area.isEmpty()) return;
|
||||
|
||||
const content = state.buffer.items;
|
||||
const available_width = input_area.width;
|
||||
|
||||
// Update view offset to ensure cursor is visible
|
||||
self.updateViewOffset(state, available_width);
|
||||
|
||||
const y = input_area.top();
|
||||
const x = input_area.left();
|
||||
|
||||
// If empty and has placeholder, show placeholder
|
||||
if (content.len == 0 and self.placeholder != null) {
|
||||
const placeholder = self.placeholder.?;
|
||||
const len = @min(placeholder.len, available_width);
|
||||
_ = buf.setString(x, y, placeholder[0..len], self.placeholder_style);
|
||||
|
||||
// Show cursor at start
|
||||
if (self.show_cursor) {
|
||||
buf.setStyle(Rect.init(x, y, 1, 1), self.cursor_style);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Render content starting from view_offset
|
||||
var char_idx: usize = 0;
|
||||
var byte_idx: usize = 0;
|
||||
var col: u16 = 0;
|
||||
|
||||
while (byte_idx < content.len and col < available_width) {
|
||||
const is_cursor_pos = byte_idx == state.cursor;
|
||||
|
||||
// Skip bytes before view_offset (but still track cursor)
|
||||
if (char_idx < state.view_offset) {
|
||||
byte_idx = nextCharBoundary(content, byte_idx);
|
||||
char_idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the character to display
|
||||
const display_char: u8 = if (self.mask) |m| m else content[byte_idx];
|
||||
|
||||
// Render character
|
||||
const char_style = if (is_cursor_pos and self.show_cursor)
|
||||
self.cursor_style
|
||||
else
|
||||
self.style;
|
||||
|
||||
_ = buf.setString(x + col, y, &[_]u8{display_char}, char_style);
|
||||
col += 1;
|
||||
|
||||
byte_idx = nextCharBoundary(content, byte_idx);
|
||||
char_idx += 1;
|
||||
}
|
||||
|
||||
// If cursor is at end, render cursor in empty space
|
||||
if (state.cursor >= content.len and self.show_cursor and col < available_width) {
|
||||
buf.setStyle(Rect.init(x + col, y, 1, 1), self.cursor_style);
|
||||
}
|
||||
}
|
||||
|
||||
fn updateViewOffset(self: Input, state: *InputState, width: u16) void {
|
||||
_ = self;
|
||||
|
||||
// Count characters to cursor
|
||||
var char_count: usize = 0;
|
||||
var byte_idx: usize = 0;
|
||||
while (byte_idx < state.cursor) {
|
||||
byte_idx = nextCharBoundary(state.buffer.items, byte_idx);
|
||||
char_count += 1;
|
||||
}
|
||||
|
||||
// Ensure cursor is visible
|
||||
if (char_count < state.view_offset) {
|
||||
state.view_offset = char_count;
|
||||
} else if (char_count >= state.view_offset + width) {
|
||||
state.view_offset = char_count - width + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions
|
||||
// ============================================================================
|
||||
|
||||
fn isSpace(c: u8) bool {
|
||||
return c == ' ' or c == '\t';
|
||||
}
|
||||
|
||||
fn prevCharBoundary(text: []const u8, pos: usize) usize {
|
||||
if (pos == 0) return 0;
|
||||
var p = pos - 1;
|
||||
// Skip UTF-8 continuation bytes
|
||||
while (p > 0 and (text[p] & 0xC0) == 0x80) {
|
||||
p -= 1;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
fn nextCharBoundary(text: []const u8, pos: usize) usize {
|
||||
if (pos >= text.len) return text.len;
|
||||
var p = pos + 1;
|
||||
// Skip UTF-8 continuation bytes
|
||||
while (p < text.len and (text[p] & 0xC0) == 0x80) {
|
||||
p += 1;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
test "InputState init and deinit" {
|
||||
var state = InputState.init(std.testing.allocator);
|
||||
defer state.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), state.value().len);
|
||||
try std.testing.expectEqual(@as(usize, 0), state.cursor);
|
||||
}
|
||||
|
||||
test "InputState insert and cursor" {
|
||||
var state = InputState.init(std.testing.allocator);
|
||||
defer state.deinit();
|
||||
|
||||
try state.insert('h');
|
||||
try state.insert('e');
|
||||
try state.insert('l');
|
||||
try state.insert('l');
|
||||
try state.insert('o');
|
||||
|
||||
try std.testing.expectEqualStrings("hello", state.value());
|
||||
try std.testing.expectEqual(@as(usize, 5), state.cursor);
|
||||
}
|
||||
|
||||
test "InputState cursor movement" {
|
||||
var state = InputState.init(std.testing.allocator);
|
||||
defer state.deinit();
|
||||
|
||||
try state.setValue("hello world");
|
||||
|
||||
state.cursorHome();
|
||||
try std.testing.expectEqual(@as(usize, 0), state.cursor);
|
||||
|
||||
state.cursorEnd();
|
||||
try std.testing.expectEqual(@as(usize, 11), state.cursor);
|
||||
|
||||
state.cursorLeft();
|
||||
try std.testing.expectEqual(@as(usize, 10), state.cursor);
|
||||
|
||||
state.cursorRight();
|
||||
try std.testing.expectEqual(@as(usize, 11), state.cursor);
|
||||
}
|
||||
|
||||
test "InputState word navigation" {
|
||||
var state = InputState.init(std.testing.allocator);
|
||||
defer state.deinit();
|
||||
|
||||
try state.setValue("hello world test");
|
||||
state.cursorEnd(); // Start at end
|
||||
|
||||
state.cursorWordLeft(); // To start of "test"
|
||||
try std.testing.expectEqual(@as(usize, 12), state.cursor);
|
||||
|
||||
state.cursorWordLeft(); // To start of "world"
|
||||
try std.testing.expectEqual(@as(usize, 6), state.cursor);
|
||||
|
||||
state.cursorWordRight(); // To start of "test"
|
||||
try std.testing.expectEqual(@as(usize, 12), state.cursor);
|
||||
}
|
||||
|
||||
test "InputState backspace and delete" {
|
||||
var state = InputState.init(std.testing.allocator);
|
||||
defer state.deinit();
|
||||
|
||||
try state.setValue("hello");
|
||||
|
||||
state.backspace(); // Remove 'o'
|
||||
try std.testing.expectEqualStrings("hell", state.value());
|
||||
|
||||
state.cursorHome();
|
||||
state.delete(); // Remove 'h'
|
||||
try std.testing.expectEqualStrings("ell", state.value());
|
||||
}
|
||||
|
||||
test "InputState kill and yank" {
|
||||
var state = InputState.init(std.testing.allocator);
|
||||
defer state.deinit();
|
||||
|
||||
try state.setValue("hello world");
|
||||
state.cursor = 6; // After "hello "
|
||||
|
||||
try state.killToEnd(); // Kill "world"
|
||||
try std.testing.expectEqualStrings("hello ", state.value());
|
||||
try std.testing.expectEqualStrings("world", state.kill_ring.?);
|
||||
|
||||
try state.yank(); // Paste "world"
|
||||
try std.testing.expectEqualStrings("hello world", state.value());
|
||||
}
|
||||
|
||||
test "InputState history" {
|
||||
var state = InputState.init(std.testing.allocator);
|
||||
defer state.deinit();
|
||||
|
||||
// Add some history
|
||||
try state.setValue("first");
|
||||
_ = try state.submit();
|
||||
|
||||
try state.setValue("second");
|
||||
_ = try state.submit();
|
||||
|
||||
try state.setValue("third");
|
||||
_ = try state.submit();
|
||||
|
||||
// Navigate history
|
||||
try state.historyPrev();
|
||||
try std.testing.expectEqualStrings("third", state.value());
|
||||
|
||||
try state.historyPrev();
|
||||
try std.testing.expectEqualStrings("second", state.value());
|
||||
|
||||
try state.historyNext();
|
||||
try std.testing.expectEqualStrings("third", state.value());
|
||||
|
||||
try state.historyNext();
|
||||
try std.testing.expectEqualStrings("", state.value()); // Back to empty
|
||||
}
|
||||
|
||||
test "Input default" {
|
||||
const input = Input.init();
|
||||
try std.testing.expect(input.block == null);
|
||||
try std.testing.expect(input.show_cursor);
|
||||
try std.testing.expect(input.mask == null);
|
||||
}
|
||||
|
||||
test "Input setters" {
|
||||
const input = Input.init()
|
||||
.setPlaceholder("Enter text...")
|
||||
.showCursor(false)
|
||||
.setMask('*');
|
||||
|
||||
try std.testing.expectEqualStrings("Enter text...", input.placeholder.?);
|
||||
try std.testing.expect(!input.show_cursor);
|
||||
try std.testing.expectEqual(@as(u8, '*'), input.mask.?);
|
||||
}
|
||||
|
|
@ -28,16 +28,6 @@ const Constraint = layout_mod.Constraint;
|
|||
const list_mod = @import("list.zig");
|
||||
const HighlightSpacing = list_mod.HighlightSpacing;
|
||||
|
||||
// ============================================================================
|
||||
// ColumnPos
|
||||
// ============================================================================
|
||||
|
||||
/// Column position information used during rendering.
|
||||
const ColumnPos = struct {
|
||||
x: u16,
|
||||
width: u16,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Cell
|
||||
// ============================================================================
|
||||
|
|
@ -47,7 +37,7 @@ const ColumnPos = struct {
|
|||
/// You can style the cell and its content independently.
|
||||
pub const Cell = struct {
|
||||
/// The text content of the cell.
|
||||
content: Text = Text.empty,
|
||||
content: Text = Text.default,
|
||||
/// Style applied to the cell area.
|
||||
style: Style = Style.default,
|
||||
|
||||
|
|
@ -433,12 +423,7 @@ pub const Table = struct {
|
|||
/// Returns the selection column width.
|
||||
fn selectionWidth(self: Table, state: *TableState) u16 {
|
||||
const has_selection = state.selected != null;
|
||||
const should_show = switch (self.highlight_spacing) {
|
||||
.always => true,
|
||||
.when_selected => has_selection,
|
||||
.never => false,
|
||||
};
|
||||
if (should_show) {
|
||||
if (self.highlight_spacing.shouldAdd(has_selection)) {
|
||||
return @intCast(text_mod.unicodeWidth(self.highlight_symbol));
|
||||
}
|
||||
return 0;
|
||||
|
|
@ -485,7 +470,7 @@ pub const Table = struct {
|
|||
const sel_width = self.selectionWidth(state);
|
||||
|
||||
// Calculate column widths
|
||||
var column_positions: [64]ColumnPos = undefined;
|
||||
var column_positions: [64]struct { x: u16, width: u16 } = undefined;
|
||||
const positions = self.calculateColumnPositions(table_area.width, sel_width, col_count, &column_positions);
|
||||
|
||||
// Calculate layout areas
|
||||
|
|
@ -534,8 +519,8 @@ pub const Table = struct {
|
|||
max_width: u16,
|
||||
selection_width: u16,
|
||||
col_count: usize,
|
||||
out: []ColumnPos,
|
||||
) []ColumnPos {
|
||||
out: []struct { x: u16, width: u16 },
|
||||
) []struct { x: u16, width: u16 } {
|
||||
if (col_count == 0) return out[0..0];
|
||||
|
||||
const actual_count = @min(col_count, out.len);
|
||||
|
|
@ -558,13 +543,13 @@ pub const Table = struct {
|
|||
const space_for_cols = available_width -| total_spacing;
|
||||
|
||||
for (out[0..actual_count], 0..) |*pos, i| {
|
||||
const constraint = if (i < self.widths.len) self.widths[i] else Constraint.min(0);
|
||||
const constraint = if (i < self.widths.len) self.widths[i] else Constraint{ .min = 0 };
|
||||
const width: u16 = switch (constraint) {
|
||||
.len => |l| @min(l, space_for_cols),
|
||||
.pct => |p| @intCast((space_for_cols * p) / 100),
|
||||
.min_size => |m| m,
|
||||
.max_size => |m| @min(m, space_for_cols),
|
||||
.rat => |r| @intCast(@as(u32, space_for_cols) * r.num / @max(1, r.den)),
|
||||
.length => |l| @min(l, space_for_cols),
|
||||
.percentage => |p| @intCast((space_for_cols * p) / 100),
|
||||
.min => |m| m,
|
||||
.max => |m| @min(m, space_for_cols),
|
||||
.ratio => |r| @intCast(@as(u32, space_for_cols) * r.num / @max(1, r.den)),
|
||||
};
|
||||
pos.x = x;
|
||||
pos.width = width;
|
||||
|
|
@ -580,7 +565,7 @@ pub const Table = struct {
|
|||
row: Row,
|
||||
area: Rect,
|
||||
buf: *Buffer,
|
||||
positions: []ColumnPos,
|
||||
positions: []struct { x: u16, width: u16 },
|
||||
) void {
|
||||
_ = self;
|
||||
for (positions, 0..) |pos, i| {
|
||||
|
|
@ -602,7 +587,7 @@ pub const Table = struct {
|
|||
buf: *Buffer,
|
||||
state: *TableState,
|
||||
selection_width: u16,
|
||||
positions: []ColumnPos,
|
||||
positions: []struct { x: u16, width: u16 },
|
||||
) void {
|
||||
if (self.rows.len == 0) return;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue