Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541//! 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 tea = @import("blacktea");const matte = @import("matte");const util = @import("util.zig");
const t = std.testing;const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;const Color = matte.Color;
const Progress = @This();
const half_block: []const u8 = "▌";const full_block: []const u8 = "█";
/// Bubbles' default solid fill color ("#7571F9"), also used to reset the/// fill when `setColors` is given an empty list (progress.go:90-94).const default_fill_color: Color = .fromRGB(0x75, 0x71, 0xF9);
model: tea.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 = &.{default_fill_color},/// Lipgloss "blueberry" — matches bubbletea's default solid fill color./// Lipgloss "slate gray" — matches bubbletea's default empty color.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,/// Applied inline to the rendered percentage (bubbles' `PercentageStyle`).percent_style: matte.Style = .none,/// printf-style format for the numeric percentage, fed `percent * 100`/// (bubbles' `PercentFormat`, default `" %3.0f%%"`). Supports `%[-0][width]/// [.prec](f|d)` verbs and `%%`.percent_format: []const u8 = " %3.0f%%",/// 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(_: *tea.Model) ?tea.Cmd { return null;}
fn modelUpdate(m: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *Progress = @fieldParentPtr("model", m); return self.update(m.gpa, msg);}
fn modelView(m: *tea.Model, writer: *Writer) Writer.Error!tea.View { const self: *Progress = @fieldParentPtr("model", m); try self.view(writer); 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) ?tea.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) ?tea.Cmd { return self.setPercent(allocator, self.target_percent + delta);}
pub fn decrPercent(self: *Progress, allocator: Allocator, delta: f64) ?tea.Cmd { return self.setPercent(allocator, self.target_percent - delta);}
/// Replace the fill colors. An empty list resets to the default solid fill/// color, mirroring bubbles' `WithColors`/`SetColors` (progress.go:90-94).pub fn setColors(self: *Progress, colors: []const Color) void { self.full_colors = if (colors.len == 0) &.{default_fill_color} else colors;}
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: tea.Msg) ?tea.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) ?tea.Cmd { const dt_ns = std.time.ns_per_s / @max(self.fps, 1); return tea.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, writer: *Writer) Writer.Error!void { try self.viewAs(writer, self.percent);}
pub fn viewAs(self: *const Progress, writer: *Writer, percent: f64) Writer.Error!void { const p = std.math.clamp(percent, 0, 1); var label_buf: [64]u8 = undefined; const label: []const u8 = if (self.show_percentage) formatPercentage(&label_buf, self.percent_format, p * 100) else "";
// Size the bar against the label's display width (bubbles measures the // rendered percentage's ansi width). const label_w: u16 = @intCast(@min(matte.measure.cellWidth(label), self.width)); const total: u16 = self.width -| label_w; 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(writer, total, filled); if (empty > 0) try writeRepeatedSolid(writer, self.empty_color, self.empty_char, empty); if (label.len > 0) try util.writeInline(writer, self.percent_style, label);}
/// Renders `value` through a printf-style `format` (bubbles' `PercentFormat`)./// Supports one numeric verb (`f` or `d`) with optional `-`/`0` flags, width,/// and `.precision`, plus `%%` for a literal percent. Output is truncated to/// `buf`. Unrecognized `%` sequences are emitted verbatim.fn formatPercentage(buf: []u8, format: []const u8, value: f64) []const u8 { var out: Writer = .fixed(buf); var i: usize = 0; while (i < format.len) { if (format[i] != '%') { out.writeByte(format[i]) catch break; i += 1; continue; } // At a '%'. Handle "%%" first. if (i + 1 < format.len and format[i + 1] == '%') { out.writeByte('%') catch break; i += 2; continue; } // Parse [flags][width][.precision]verb. var j = i + 1; var left_align = false; var zero_pad = false; while (j < format.len) : (j += 1) { switch (format[j]) { '-' => left_align = true, '0' => zero_pad = true, '+', ' ' => {}, // accepted but unused else => break, } } var width: usize = 0; while (j < format.len and format[j] >= '0' and format[j] <= '9') : (j += 1) width = width * 10 + (format[j] - '0'); var precision: ?usize = null; if (j < format.len and format[j] == '.') { j += 1; var prec: usize = 0; while (j < format.len and format[j] >= '0' and format[j] <= '9') : (j += 1) prec = prec * 10 + (format[j] - '0'); precision = prec; } if (j >= format.len) { // Malformed trailing '%': emit verbatim. out.writeAll(format[i..]) catch {}; break; } const verb = format[j]; var num_buf: [64]u8 = undefined; const num: []const u8 = switch (verb) { 'f' => std.fmt.float.render(&num_buf, value, .{ .mode = .decimal, .precision = precision orelse 6, }) catch "", 'd' => std.fmt.bufPrint(&num_buf, "{d}", .{@as(i64, @intFromFloat(@round(value)))}) catch "", else => blk: { // Unknown verb: emit the raw spec. out.writeAll(format[i .. j + 1]) catch {}; break :blk null; }, } orelse { i = j + 1; continue; }; writePadded(&out, num, width, left_align, zero_pad); i = j + 1; } return out.buffered();}
fn writePadded(out: *Writer, s: []const u8, width: usize, left_align: bool, zero_pad: bool) void { const pad = if (width > s.len) width - s.len else 0; const pad_char: u8 = if (zero_pad and !left_align) '0' else ' '; if (!left_align) { var k: usize = 0; while (k < pad) : (k += 1) out.writeByte(pad_char) catch return; } out.writeAll(s) catch return; if (left_align) { var k: usize = 0; while (k < pad) : (k += 1) out.writeByte(' ') catch return; }}
fn writeFill(self: *const Progress, writer: *Writer, total: u16, filled: u16) Writer.Error!void { if (filled == 0) return;
if (self.full_colors.len >= 2) { try self.writeGradientFill(writer, total, filled); return; }
const fg: ?Color = if (self.full_colors.len == 1) self.full_colors[0] else null; try writeRepeatedSolid(writer, fg, self.full_char, filled);}
fn writeGradientFill(self: *const Progress, writer: *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(writer, .fg); try bg.writeSgr(writer, .bg); } else { const fg: Color = .blendAt(self.full_colors, steps, @as(usize, i)); try fg.writeSgr(writer, .fg); } try writer.writeAll(self.full_char); } try writer.writeAll("\x1b[0m");}
fn writeRepeatedSolid(writer: *Writer, color: ?Color, glyph: []const u8, count: u16) Writer.Error!void { if (count == 0) return; if (color) |c| try c.writeSgr(writer, .fg); var i: u16 = 0; while (i < count) : (i += 1) try writer.writeAll(glyph); if (color != null) try writer.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 t.expectEqual(@as(f64, 1), p.percent); p.setPercentStatic(-0.5); try t.expectEqual(@as(f64, 0), p.percent);}
test "decrPercent lowers the target and clamps" { var p: Progress = .{}; p.setPercentStatic(0.5); p.chain_active = true; // suppress tick scheduling (no allocation) in the test // Drive the target below zero; it must clamp to 0. _ = p.decrPercent(std.testing.allocator, 0.8); try t.expectEqual(@as(f64, 0), p.target_percent);}
test "setColors resets to default fill when empty" { var p: Progress = .{}; p.setColors(&.{}); try t.expectEqual(@as(usize, 1), p.full_colors.len); try t.expectEqual(default_fill_color, p.full_colors[0]); const stops = [_]Color{ default_fill_color, .{ .rgb = .{ .r = 0, .g = 0, .b = 0 } } }; p.setColors(&stops); try t.expectEqual(@as(usize, 2), p.full_colors.len);}
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 t.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 t.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 t.expectEqualStrings("█████ 100%", w.buffered());}
test "custom percent_format renders a float with precision" { var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); const p: Progress = .{ .width = 12, .percent = 0.5, .full_char = full_block, .full_colors = &.{}, .empty_color = null, .percent_format = "%.1f%%", }; try p.view(&w); // Label "50.0%" is 5 cells → 7-cell bar, half filled. try t.expectEqualStrings("████░░░50.0%", w.buffered());}
test "formatPercentage handles width, flags and %%" { var buf: [64]u8 = undefined; // " %3.0f%%" == literal space + width-3 number + literal '%'. try t.expectEqualStrings(" 50%", formatPercentage(&buf, " %3.0f%%", 50)); try t.expectEqualStrings(" 5%", formatPercentage(&buf, " %3.0f%%", 5)); try t.expectEqualStrings("100%", formatPercentage(&buf, "%3.0f%%", 100)); try t.expectEqualStrings("7.5%", formatPercentage(&buf, "%.1f%%", 7.5)); try t.expectEqualStrings("42 %", formatPercentage(&buf, "%-4.0f%%", 42)); try t.expectEqualStrings("007%", formatPercentage(&buf, "%03.0f%%", 7));}
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 t.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 t.expect(std.mem.find(u8, out, "\x1b[38;2;0;0;0m") != null); // last cell should be pure white fg try t.expect(std.mem.find(u8, out, "\x1b[38;2;255;255;255m") != null);}