//! 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); }