progress

progress
git clone git://brookjeynes.dev/bjeynes/progress.git
Log | Files | Refs | README | LICENSE

commit 49bcc8df9df3a0ed4b011a1fe6297faa490d3095
parent ce96ac1f3314284542eb28ea5e80be9e18fdd1a5
Author: pepe <91209772+pepedinho@users.noreply.github.com>
Date:   Mon, 27 Apr 2026 02:37:39 +0200

feat: add multibar (#3)


---------

Co-authored-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
Mbuild.zig | 2+-
Aexamples/bar/multibar-static.zig | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aexamples/bar/multibar.zig | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ansi-term.zig | 16++++++++++++++++
Msrc/bar.zig | 6+++---
Asrc/multi-bar.zig | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/root.zig | 1+
7 files changed, 238 insertions(+), 4 deletions(-)

diff --git a/build.zig b/build.zig @@ -26,7 +26,7 @@ pub fn build(b: *std.Build) void { const docs_step = b.step("docs", "Generate docs"); docs_step.dependOn(&docs.step); - for ([_][]const u8{ "simple", "complex", "thread" }) |example| { + for ([_][]const u8{ "simple", "complex", "thread", "multibar", "multibar-static" }) |example| { const run_step = b.step(b.fmt("run-bar-{s}", .{example}), b.fmt("Run bar/{s}.zig example", .{example})); const exe = b.addExecutable(.{ .name = example, diff --git a/examples/bar/multibar-static.zig b/examples/bar/multibar-static.zig @@ -0,0 +1,50 @@ +const std = @import("std"); +const progress = @import("progress"); +const MultiBar = progress.MultiBar.MultiBar; + +pub fn threadWorker(manager: *MultiBar, index: usize, seed: usize) !void { + var rand_impl = std.Random.DefaultPrng.init(seed); + const delay = @mod(rand_impl.random().int(i64), 100) + 20; + + const pb = try manager.bar(index); + + while (!pb.isFinished()) { + pb.add(1); + try manager.render(); + + try std.Io.sleep(manager.writer.io, std.Io.Duration.fromMilliseconds(delay), .awake); + } +} + +pub fn main(init: std.process.Init) !void { + var stdout_buf: [4096]u8 = undefined; + var stdout_writer = std.Io.File.stdout().writer(init.io, &stdout_buf); + + const num_bars = 5; + var desc_bufs: [num_bars][32]u8 = undefined; + var buf: [MultiBar.bufSize(num_bars)]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + + var manager = MultiBar.init(fba.allocator(), &stdout_writer); + defer manager.deinit(); + + for (0..num_bars) |i| { + const desc = try std.fmt.bufPrint(&desc_bufs[i], "Task {d}", .{i + 1}); + + _ = try manager.addBar(100, .{ + .description = desc, + .show_percentage = true, + .write_newline_on_finish = false, + .clear_on_finish = true, + }); + } + + var threads: [num_bars]std.Thread = undefined; + for (0..num_bars) |i| { + threads[i] = try std.Thread.spawn(.{}, threadWorker, .{ &manager, i, i * 100 }); + } + + for (threads) |thread| { + thread.join(); + } +} diff --git a/examples/bar/multibar.zig b/examples/bar/multibar.zig @@ -0,0 +1,53 @@ +const std = @import("std"); +const progress = @import("progress"); +const MultiBar = progress.MultiBar.MultiBar; + +pub fn threadWorker(manager: *MultiBar, index: usize, seed: usize) !void { + var rand_impl = std.Random.DefaultPrng.init(seed); + const delay = @mod(rand_impl.random().int(i64), 100) + 20; + + const pb = try manager.bar(index); + + while (!pb.isFinished()) { + pb.add(1); + try manager.render(); + + try std.Io.sleep(manager.writer.io, std.Io.Duration.fromMilliseconds(delay), .awake); + } +} + +pub fn main(init: std.process.Init) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var stdout_buf: [4096]u8 = undefined; + var stdout_writer = std.Io.File.stdout().writer(init.io, &stdout_buf); + + var manager = MultiBar.init(allocator, &stdout_writer); + defer manager.deinit(); + + const num_bars = 5; + + for (0..num_bars) |i| { + var buf: [32]u8 = undefined; + const temp_desc = try std.fmt.bufPrint(&buf, "Task {d}", .{i + 1}); + const desc = try allocator.dupe(u8, temp_desc); + + _ = try manager.addBar(100, .{ + .description = desc, + .show_percentage = true, + .write_newline_on_finish = false, + .clear_on_finish = false, + }); + } + + var threads: [num_bars]std.Thread = undefined; + for (0..num_bars) |i| { + threads[i] = try std.Thread.spawn(.{}, threadWorker, .{ &manager, i, i * 100 }); + } + + for (threads) |thread| { + thread.join(); + } +} diff --git a/src/ansi-term.zig b/src/ansi-term.zig @@ -65,6 +65,22 @@ pub fn setCursorColumn(writer: anytype, column: usize) !void { try writer.print(csi ++ "{d}G", .{column}); } +pub fn setCursorPos(writer: anytype, row: usize, column: usize) !void { + try writer.print(csi ++ "{d};{d}H", .{ row, column }); +} + +pub fn cursorUp(writer: anytype, rows: usize) !void { + if (rows > 0) { + try writer.print(csi ++ "{d}A", .{rows}); + } +} + +pub fn cursorDown(writer: anytype, rows: usize) !void { + if (rows > 0) { + try writer.print(csi ++ "{d}B", .{rows}); + } +} + pub fn cursorForward(writer: anytype, columns: usize) !void { try writer.print(csi ++ "{d}C", .{columns}); } diff --git a/src/bar.zig b/src/bar.zig @@ -10,7 +10,7 @@ const Error = error{ ///Used when the terminal width cannot be retrieved. const default_bar_width: u16 = 40; -const Config = struct { +pub const Config = struct { ///Progress bar description. description: ?[]const u8 = null, ///The progress bar prefix. @@ -27,7 +27,7 @@ const Config = struct { show_percentage: bool = false, ///Clear the line when the progress bar finishes. clear_on_finish: bool = false, - ///Write a newline when the progress bar finishes. + ///Write a newline when the progress bar finishes. In the case of a `MultiBar`, this will always be `false`. write_newline_on_finish: bool = true, ///Custom progress bar width. ///If the width is greater than the terminal width, the terminal width will be used. @@ -39,7 +39,7 @@ const Config = struct { bg_colour: ansi_term.Colour = .Default, }; -const Bar = @This(); +pub const Bar = @This(); ///Direct access is not thread safe. Use `currentProgress()` if you need thread safety. current_progress: usize = 0, diff --git a/src/multi-bar.zig b/src/multi-bar.zig @@ -0,0 +1,114 @@ +const std = @import("std"); +const bar = @import("bar.zig"); +const ansi_term = @import("ansi-term.zig"); +const Bar = bar.Bar; +const Config = bar.Config; + +pub const MultiBar = struct { + bars: std.ArrayList(Bar), + writer: *std.Io.File.Writer, + mutex: std.Io.Mutex, + allocator: std.mem.Allocator, + active_line: usize = 0, + + pub fn init( + allocator: std.mem.Allocator, + writer: *std.Io.File.Writer, + ) MultiBar { + return .{ + .bars = .empty, + .writer = writer, + .mutex = std.Io.Mutex.init, + .allocator = allocator, + }; + } + + pub fn deinit(self: *MultiBar) void { + self.mutex.lockUncancelable(self.writer.io); + defer self.mutex.unlock(self.writer.io); + + ansi_term.showCursor(&self.writer.interface) catch {}; + self.writer.interface.flush() catch {}; + self.bars.deinit(self.allocator); + } + + /// Returns the minimum buffer size in bytes needed to hold `n` bars. + pub fn bufSize(n: usize) usize { + return n * @sizeOf(Bar) + @alignOf(Bar) - 1; + } + + /// Add a new progress bar to the manager. + /// Overrides `write_newline_on_finish` to false to ensure terminal visual integrity. + /// When statically backed, `error.OutOfMemory` will be returned when the bar limit is exceeded. + /// Returns the index of the newly added bar. + pub fn addBar(self: *MultiBar, max_progress: usize, config: Config) !usize { + var safe_config = config; + safe_config.write_newline_on_finish = false; + + const new_pb = Bar.init(max_progress, self.writer, safe_config); + const index = self.bars.items.len; + + try self.bars.append(self.allocator, new_pb); + try self.writer.interface.writeAll("\n"); + self.active_line += 1; + return index; + } + + /// Returns a pointer to the bar at the given index. + /// Not thread safe. The caller is responsible for synchronization + /// if the bar is accessed concurrently with `render()` or other threads. + pub fn bar(self: *MultiBar, index: usize) !*Bar { + if (index >= self.bars.items.len) { + return error.IndexOutOfBounds; + } + return &self.bars.items[index]; + } + + /// Render all active progress bars. + /// Handles terminal cursor repositioning and dynamic reflowing if bars finish. + pub fn render(self: *MultiBar) !void { + self.mutex.lockUncancelable(self.writer.io); + defer self.mutex.unlock(self.writer.io); + + if (self.active_line == 0) return; + + try ansi_term.cursorUp(&self.writer.interface, self.active_line); + + var newly_active: usize = 0; + var all_finished: bool = true; + + for (self.bars.items) |*pb| { + if (!pb.isFinished()) { + all_finished = false; + } + + if (pb.isFinished() and pb.config.clear_on_finish) { + continue; + } + + try pb.render(); + try ansi_term.cursorDown(&self.writer.interface, 1); + try ansi_term.setCursorColumn(&self.writer.interface, 0); + + newly_active += 1; + } + + if (newly_active < self.active_line) { + const diff = self.active_line - newly_active; + for (0..diff) |_| { + try ansi_term.clearCurrentLine(&self.writer.interface); + try ansi_term.cursorDown(&self.writer.interface, 1); + } + try ansi_term.cursorUp(&self.writer.interface, diff); + } + + if (all_finished) { + try ansi_term.showCursor(&self.writer.interface); + self.active_line = 0; + } else { + self.active_line = newly_active; + } + + try self.writer.interface.flush(); + } +}; diff --git a/src/root.zig b/src/root.zig @@ -1,2 +1,3 @@ pub const Bar = @import("./bar.zig"); pub const Spinner = @import("./spinner.zig"); +pub const MultiBar = @import("multi-bar.zig");