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