Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390//! Progress bar with optional gradient fill and percentage label.//!//! Set `full_colors` to one color for a solid fill, or two+ colors for a//! linear gradient across the bar. With the default half-block fill char//! (`▌`), the gradient is rendered at double resolution by using a different//! foreground and background color per cell.//!//! Set `scale_blend = true` to stretch the gradient across only the filled//! portion of the bar (so the full gradient is visible at every percent).//! When `false`, the gradient spans the full bar width and the full range is//! only visible at 100%.//!//! Set the displayed percentage with `setPercent` (clamps to [0, 1]). Render//! via `view(w)` for the current percent, or `viewAs(w, p)` for a one-off//! percentage without mutating state.const std = @import("std");const matcha = @import("matcha");const blush = @import("blush");const util = @import("util.zig");
const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;const Cmd = matcha.Cmd;const Msg = matcha.Msg;const Color = blush.Style.Color;
const Progress = @This();
const half_block: []const u8 = "▌";const full_block: []const u8 = "█";
/// Lipgloss "blueberry" — matches bubbletea's default solid fill color./// Lipgloss "slate gray" — matches bubbletea's default empty color.model: matcha.Model = .{ .vtable = &.{ .init = modelInit, .update = modelUpdate, .view = modelView, },},
width: u16 = 40,full_char: []const u8 = half_block,empty_char: []const u8 = "░",/// 0 colors: no fg styling. 1: solid fill. 2+: gradient.full_colors: []const Color = &.{.{ .rgb = .{ .r = 0x75, .g = 0x71, .b = 0xF9 } }},empty_color: ?Color = .{ .rgb = .{ .r = 0x60, .g = 0x60, .b = 0x60 } },/// When true and `full_colors.len >= 2`, the gradient is scaled to the/// filled width, so the full color range is visible at every percent.scale_blend: bool = false,show_percentage: bool = true,percent_style: ?blush.Style = null,/// Currently displayed (animated) percentage in [0, 1]. `setPercent`/// updates `target_percent` and runs a spring to walk this toward it.percent: f64 = 0,
/// Animation target. Equal to `percent` when not animating.target_percent: f64 = 0,/// Spring velocity. Tracked by `Update` while animating.velocity: f64 = 0,/// Spring coefficients, recomputed when `freq`/`damping`/`fps` change.spring: Spring = .{},spring_freq: f64 = 18.0,spring_damping: f64 = 1.0,fps: u32 = 60,/// True while a chain of tick Cmds is in flight.chain_active: bool = false,
fn modelInit(_: *matcha.Model) ?Cmd { return null;}
fn modelUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *Progress = @fieldParentPtr("model", m); return self.update(m.gpa, msg);}
fn modelView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { const self: *Progress = @fieldParentPtr("model", m); try self.view(w); return .{};}
/// Set the displayed percent immediately, no animation. Use for the/// "pure" ViewAs pattern where the caller owns the percent.pub fn setPercentStatic(self: *Progress, p: f64) void { const clamped = std.math.clamp(p, 0, 1); self.percent = clamped; self.target_percent = clamped; self.velocity = 0;}
/// Update the target percent and return a Cmd that drives spring/// animation toward it. The returned Cmd must be dispatched; subsequent/// `tick` msgs go through `update`. Returns null when no allocator-backed/// tick can be scheduled.pub fn setPercent(self: *Progress, allocator: Allocator, p: f64) ?Cmd { self.target_percent = std.math.clamp(p, 0, 1); self.refreshSpring(); if (self.chain_active) return null; // existing chain absorbs the new target self.chain_active = true; return self.scheduleTick(allocator);}
pub fn incrPercent(self: *Progress, allocator: Allocator, delta: f64) ?Cmd { return self.setPercent(allocator, self.target_percent + delta);}
pub fn setSpringOptions(self: *Progress, freq: f64, damping: f64) void { self.spring_freq = freq; self.spring_damping = damping; self.refreshSpring();}
pub fn setWidth(self: *Progress, cells: u16) void { self.width = cells;}
pub fn isAnimating(self: *const Progress) bool { const dist = @abs(self.percent - self.target_percent); return !(dist < 0.001 and @abs(self.velocity) < 0.01);}
pub fn update(self: *Progress, allocator: Allocator, msg: Msg) ?Cmd { switch (msg) { .custom => |c| { if (c.type_id != self.tickTypeId()) return null; if (!self.chain_active) return null; if (!self.isAnimating()) { self.chain_active = false; // Snap to target so we don't leave a sub-pixel residue. self.percent = self.target_percent; self.velocity = 0; return null; } const out = self.spring.update(self.percent, self.velocity, self.target_percent); self.percent = out.pos; self.velocity = out.vel; return self.scheduleTick(allocator); }, else => return null, }}
fn tickTypeId(self: *const Progress) usize { return @intFromPtr(self);}
fn scheduleTick(self: *Progress, allocator: Allocator) ?Cmd { const dt_ns = std.time.ns_per_s / @max(self.fps, 1); return Cmd.tick(allocator, dt_ns, .{ .custom = .{ .type_id = self.tickTypeId() }, }) catch { self.chain_active = false; return null; };}
fn refreshSpring(self: *Progress) void { const dt: f64 = 1.0 / @as(f64, @floatFromInt(@max(self.fps, 1))); self.spring = Spring.init(dt, self.spring_freq, self.spring_damping);}
pub fn view(self: *const Progress, w: *Writer) Writer.Error!void { try self.viewAs(w, self.percent);}
pub fn viewAs(self: *const Progress, w: *Writer, percent: f64) Writer.Error!void { const p = std.math.clamp(percent, 0, 1); var label_buf: [16]u8 = undefined; const label: []const u8 = if (self.show_percentage) std.fmt.bufPrint(&label_buf, " {d:>3}%", .{@as(u32, @intFromFloat(@round(p * 100)))}) catch "" else "";
const total: u16 = self.width -| @as(u16, @intCast(label.len)); const filled_f: f64 = @round(@as(f64, @floatFromInt(total)) * p); const filled: u16 = @intFromFloat(@max(0, @min(@as(f64, @floatFromInt(total)), filled_f))); const empty: u16 = total - filled;
try self.writeFill(w, total, filled); if (empty > 0) try writeRepeatedSolid(w, self.empty_color, self.empty_char, empty); if (label.len > 0) try util.writeInline(w, self.percent_style, label);}
fn writeFill(self: *const Progress, w: *Writer, total: u16, filled: u16) Writer.Error!void { if (filled == 0) return;
if (self.full_colors.len >= 2) { try self.writeGradientFill(w, total, filled); return; }
const fg: ?Color = if (self.full_colors.len == 1) self.full_colors[0] else null; try writeRepeatedSolid(w, fg, self.full_char, filled);}
fn writeGradientFill(self: *const Progress, w: *Writer, total: u16, filled: u16) Writer.Error!void { const is_half_block = std.mem.eql(u8, self.full_char, half_block); const multiplier: usize = if (is_half_block) 2 else 1; const span: usize = if (self.scale_blend) filled else total; const steps: usize = span * multiplier;
var i: u16 = 0; while (i < filled) : (i += 1) { if (is_half_block) { const fg = Color.blendAt(self.full_colors, steps, @as(usize, i) * 2); const bg = Color.blendAt(self.full_colors, steps, @as(usize, i) * 2 + 1); try fg.writeSgr(w, .fg); try bg.writeSgr(w, .bg); } else { const fg = Color.blendAt(self.full_colors, steps, @as(usize, i)); try fg.writeSgr(w, .fg); } try w.writeAll(self.full_char); } try w.writeAll("\x1b[0m");}
fn writeRepeatedSolid(w: *Writer, color: ?Color, glyph: []const u8, count: u16) Writer.Error!void { if (count == 0) return; if (color) |c| try c.writeSgr(w, .fg); var i: u16 = 0; while (i < count) : (i += 1) try w.writeAll(glyph); if (color != null) try w.writeAll("\x1b[0m");}
/// Damped harmonic oscillator. Ported from charmbracelet/harmonica.pub const Spring = struct { pos_pos: f64 = 1, pos_vel: f64 = 0, vel_pos: f64 = 0, vel_vel: f64 = 1,
pub fn init(delta_time: f64, angular_freq: f64, damping_ratio: f64) Spring { const ang = @max(0.0, angular_freq); const damp = @max(0.0, damping_ratio); const epsilon: f64 = std.math.floatEps(f64);
var s: Spring = .{}; if (ang < epsilon) { // No frequency → identity (no movement). return s; }
if (damp > 1.0 + epsilon) { // Over-damped. const za = -ang * damp; const zb = ang * @sqrt(damp * damp - 1.0); const z1 = za - zb; const z2 = za + zb; const e1 = @exp(z1 * delta_time); const e2 = @exp(z2 * delta_time); const inv_two_zb = 1.0 / (2.0 * zb); const e1_two = e1 * inv_two_zb; const e2_two = e2 * inv_two_zb; const z1e1 = z1 * e1_two; const z2e2 = z2 * e2_two; s.pos_pos = e1_two * z2 - z2e2 + e2; s.pos_vel = -e1_two + e2_two; s.vel_pos = (z1e1 - z2e2 + e2) * z2; s.vel_vel = -z1e1 + z2e2; } else if (damp < 1.0 - epsilon) { // Under-damped. const omega_z = ang * damp; const alpha = ang * @sqrt(1.0 - damp * damp); const exp_t = @exp(-omega_z * delta_time); const cos_t = @cos(alpha * delta_time); const sin_t = @sin(alpha * delta_time); const inv_a = 1.0 / alpha; const exp_sin = exp_t * sin_t; const exp_cos = exp_t * cos_t; const exp_omega_sin_over_a = exp_t * omega_z * sin_t * inv_a; s.pos_pos = exp_cos + exp_omega_sin_over_a; s.pos_vel = exp_sin * inv_a; s.vel_pos = -exp_sin * alpha - omega_z * exp_omega_sin_over_a; s.vel_vel = exp_cos - exp_omega_sin_over_a; } else { // Critically damped. const exp_t = @exp(-ang * delta_time); const time_exp = delta_time * exp_t; const time_exp_freq = time_exp * ang; s.pos_pos = time_exp_freq + exp_t; s.pos_vel = time_exp; s.vel_pos = -ang * time_exp_freq; s.vel_vel = -time_exp_freq + exp_t; } return s; }
pub fn update(s: Spring, pos: f64, vel: f64, target: f64) struct { pos: f64, vel: f64 } { const old_pos = pos - target; const old_vel = vel; return .{ .pos = old_pos * s.pos_pos + old_vel * s.pos_vel + target, .vel = old_pos * s.vel_pos + old_vel * s.vel_vel, }; }};
test "setPercentStatic clamps" { var p: Progress = .{}; p.setPercentStatic(1.5); try std.testing.expectEqual(@as(f64, 1), p.percent); p.setPercentStatic(-0.5); try std.testing.expectEqual(@as(f64, 0), p.percent);}
test "spring critically damped converges" { const s: Spring = .init(1.0 / 60.0, 6.0, 1.0); var pos: f64 = 0; var vel: f64 = 0; var i: usize = 0; while (i < 600) : (i += 1) { const out = s.update(pos, vel, 1.0); pos = out.pos; vel = out.vel; } try std.testing.expect(@abs(pos - 1.0) < 0.01);}
test "view 50% no color no percent" { var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); const p: Progress = .{ .width = 10, .show_percentage = false, .percent = 0.5, .full_char = full_block, .full_colors = &.{}, .empty_color = null, }; try p.view(&w); try std.testing.expectEqualStrings("█████░░░░░", w.buffered());}
test "view 100% with percent label" { var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); const p: Progress = .{ .width = 10, .percent = 1, .full_char = full_block, .full_colors = &.{}, .empty_color = null, }; try p.view(&w); try std.testing.expectEqualStrings("█████ 100%", w.buffered());}
test "view 0% empty bar" { var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); const p: Progress = .{ .width = 10, .show_percentage = false, .percent = 0, .full_char = full_block, .full_colors = &.{}, .empty_color = null, }; try p.view(&w); try std.testing.expectEqualStrings("░░░░░░░░░░", w.buffered());}
test "gradient endpoints scale_blend" { var buf: [1024]u8 = undefined; var w: Writer = .fixed(&buf); const stops = [_]Color{ .{ .rgb = .{ .r = 0, .g = 0, .b = 0 } }, .{ .rgb = .{ .r = 255, .g = 255, .b = 255 } }, }; const p: Progress = .{ .width = 4, .show_percentage = false, .percent = 1, .full_char = full_block, .full_colors = &stops, .empty_color = null, .scale_blend = true, }; try p.view(&w); // 4 cells full-block, gradient scaled to filled=4: t = 0, 1/3, 2/3, 1 const out = w.buffered(); // first cell should be pure black fg try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[38;2;0;0;0m") != null); // last cell should be pure white fg try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[38;2;255;255;255m") != null);}