progress

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

commit b0bcbb073ea80c00359a4648cc60e20d579bcbe4
Author: Brook J <jeynesbrook@gmail.com>
Date:   Fri,  9 Aug 2024 00:09:19 +1000

0.1.0 release

Diffstat:
A.gitignore | 2++
AREADME | 39+++++++++++++++++++++++++++++++++++++++
Abuild.zig | 46++++++++++++++++++++++++++++++++++++++++++++++
Abuild.zig.zon | 14++++++++++++++
Aexamples/complex.zig | 19+++++++++++++++++++
Aexamples/simple.zig | 12++++++++++++
Aexamples/thread.zig | 31+++++++++++++++++++++++++++++++
Asrc/escape_codes.zig | 20++++++++++++++++++++
Asrc/progress.zig | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/termsize.zig | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 445 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +.zig-cache/ +zig-out/ diff --git a/README b/README @@ -0,0 +1,39 @@ +progress +======== + +A simple thread safe progress bar. + +Adding to your program +---------------------- +1. Fetch the package. + `zig fetch --save git+https://github.com/BrookJeynes/progress` +2. Add to your `build.zig`. + ``` + const progress = b.dependency("progress", .{}).module("progress"); + exe.root_module.addImport("progress", progress); + ``` + +Minimal example +--------------- +```zig +const std = @import("std"); +const ProgressBar = @import("progress"); + +pub fn main() !void { + const stdout = std.io.getStdOut().writer(); + var pb = ProgressBar.init(10, stdout.any(), .{}); + + while (!pb.finished()) { + try pb.add(1); + std.time.sleep(std.time.ns_per_ms * 150); + } +} +``` + +You can find more examples in the `examples/` folder. +For more information, see the source code or documentation (`zig build docs`). + +Contributing +----------- +Contributions, issues, and feature requests are always welcome! This project is +using the latest stable release of Zig (0.13.0). diff --git a/build.zig b/build.zig @@ -0,0 +1,46 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const progress = b.addModule("progress", .{ + .root_source_file = b.path("src/progress.zig"), + .target = target, + .optimize = optimize, + }); + + const docs = b.addInstallDirectory(.{ + .source_dir = b.addStaticLibrary(.{ + .name = "progress", + .root_source_file = b.path("src/progress.zig"), + .target = target, + .optimize = optimize, + }).getEmittedDocs(), + .install_dir = .prefix, + .install_subdir = "docs", + }); + + const docs_step = b.step("docs", "Generate docs"); + docs_step.dependOn(&docs.step); + + for ([_][]const u8{ "simple", "complex", "thread" }) |example| { + const run_step = b.step(b.fmt("run-{s}", .{example}), b.fmt("Run {s}.zig example", .{example})); + const exe = b.addExecutable(.{ + .name = example, + .root_source_file = b.path(b.fmt("examples/{s}.zig", .{example})), + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("progress", progress); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(&b.addInstallArtifact(exe, .{}).step); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + run_step.dependOn(&run_cmd.step); + } +} diff --git a/build.zig.zon b/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = "progress", + .version = "0.1.0", + + .minimum_zig_version = "0.13.0", + + .dependencies = .{}, + + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/examples/complex.zig b/examples/complex.zig @@ -0,0 +1,19 @@ +const std = @import("std"); +const ProgressBar = @import("progress"); + +pub fn main() !void { + const stdout = std.io.getStdOut().writer(); + var pb = ProgressBar.init(28, stdout.any(), .{ + .bar_prefix = '[', + .bar_suffix = ']', + .bar_fill_char = '=', + .description = "A complex progress bar", + .show_iterations = true, + .show_percentage = true, + }); + + while (!pb.finished()) { + try pb.add(1); + std.time.sleep(std.time.ns_per_ms * 150); + } +} diff --git a/examples/simple.zig b/examples/simple.zig @@ -0,0 +1,12 @@ +const std = @import("std"); +const ProgressBar = @import("progress"); + +pub fn main() !void { + const stdout = std.io.getStdOut().writer(); + var pb = ProgressBar.init(10, stdout.any(), .{}); + + while (!pb.finished()) { + try pb.add(1); + std.time.sleep(std.time.ns_per_ms * 150); + } +} diff --git a/examples/thread.zig b/examples/thread.zig @@ -0,0 +1,31 @@ +const std = @import("std"); +const ProgressBar = @import("progress"); + +pub fn threadWorker(bar: *ProgressBar, seed: usize) !void { + var rand_impl = std.rand.DefaultPrng.init(seed); + const num = @mod(rand_impl.random().int(u64), 1000); + + for (0..5) |_| { + try bar.add(1); + std.time.sleep(std.time.ns_per_ms * num); + } +} + +pub fn main() !void { + const stdout = std.io.getStdOut().writer(); + var pb = ProgressBar.init(100, stdout.any(), .{ + .description = "Progress bar with threads", + .show_iterations = true, + .show_percentage = true, + }); + + const thread_count = 20; + var threads: [thread_count]std.Thread = undefined; + for (0..thread_count) |i| { + threads[i] = try std.Thread.spawn(.{}, threadWorker, .{ &pb, i }); + } + + for (threads) |thread| { + thread.join(); + } +} diff --git a/src/escape_codes.zig b/src/escape_codes.zig @@ -0,0 +1,20 @@ +const std = @import("std"); + +const esc = "\x1B"; +const csi = esc ++ "["; + +pub fn clearCurrentLine(writer: anytype) !void { + try writer.writeAll(csi ++ "2K"); +} + +pub fn hideCursor(writer: anytype) !void { + try writer.writeAll(csi ++ "?25l"); +} + +pub fn setCursorColumn(writer: anytype, column: usize) !void { + try writer.print(csi ++ "{d}G", .{column}); +} + +pub fn cursorForward(writer: anytype, columns: usize) !void { + try writer.print(csi ++ "{d}C", .{columns}); +} diff --git a/src/progress.zig b/src/progress.zig @@ -0,0 +1,174 @@ +const std = @import("std"); +const termsize = @import("termsize.zig"); +const escape_codes = @import("escape_codes.zig"); + +const Error = error{ + MaxIsZero, + FailedToRender, + NumGreaterThanMax, +}; + +const Config = struct { + /// Progress bar description. + description: ?[]const u8 = null, + /// The progress bar prefix. + bar_prefix: u8 = '|', + /// The progress bar suffix. + bar_suffix: u8 = '|', + /// The charater to fill the progress bar with. + bar_fill_char: u8 = '#', + /// Whether to show the iteration count. + show_iterations: bool = false, + /// Whether to show the percentage. + show_percentage: bool = false, +}; + +const ProgressBar = @This(); + +current_progress: usize = 0, +max_progress: usize = 0, +bw: std.io.BufferedWriter(4096, std.io.AnyWriter), +config: Config, +mutex: std.Thread.Mutex = std.Thread.Mutex{}, + +pub fn init(max_progress: usize, writer: std.io.AnyWriter, config: Config) ProgressBar { + return ProgressBar{ + .max_progress = max_progress, + .bw = std.io.bufferedWriter(writer), + .config = config, + }; +} + +/// Add `num` to the progress bar. If `num` is greater than the `max_progress`, +/// `current_progress` will be set to the max. +pub fn add(self: *ProgressBar, num: usize) Error!void { + self.mutex.lock(); + defer self.mutex.unlock(); + + if (self.max_progress == 0) { + return Error.MaxIsZero; + } + + if (self.current_progress + num < self.max_progress) { + self.current_progress += num; + } else { + self.current_progress = self.max_progress; + } + + self.render() catch return Error.FailedToRender; +} + +/// Render the progress bar. The progress bar must have a `max_progress` greater +/// than 0. +/// +/// This function is not thread safe so it must be called with an acquired lock. +pub fn render(self: *ProgressBar) !void { + if (self.max_progress == 0) { + return Error.MaxIsZero; + } + + const winsize = try termsize.termSize(std.io.getStdOut()); + + try self.clearLine(); + + const percentage = @as(f32, @floatFromInt(self.current_progress)) / @as(f32, @floatFromInt(self.max_progress)); + + const extra_front_chars = brk: { + var count: usize = 0; + + if (self.config.description) |desc| { + count += (desc.len + 3); // "{desc} ||" + } else { + count += 2; // "||" + } + + if (self.config.show_percentage) count += 5; + + break :brk count; + }; + + const extra_back_chars = brk: { + var count: usize = 0; + + if (self.config.show_iterations) { + const num_len: usize = @intFromFloat(@ceil(@log10(@as(f32, @floatFromInt(self.max_progress + 1))))); + count += 8 + (num_len * 2); // [ {num_len} / {num_len} ] + try escape_codes.setCursorColumn(self.bw.writer(), winsize.?.width - count + 2); + _ = try self.bw.writer().print("[ {[curr]: >[padding]} / {[max]} ]\r", .{ .curr = self.current_progress, .padding = num_len, .max = self.max_progress }); + } + + break :brk count; + }; + + if (self.config.description) |desc| { + try self.bw.writer().print("{s}", .{desc}); + try escape_codes.cursorForward(self.bw.writer(), 1); + } + + if (self.config.show_percentage) { + try self.bw.writer().print("{d: >3}%", .{@as(u32, @intFromFloat(percentage * 100))}); + try escape_codes.cursorForward(self.bw.writer(), 1); + } + + _ = try self.bw.writer().writeByte(self.config.bar_prefix); + + const range: usize = @intFromFloat(percentage * @as(f32, @floatFromInt(std.math.sub(usize, winsize.?.width, extra_front_chars + extra_back_chars) catch 0))); + for (0..range) |_| { + _ = try self.bw.writer().writeByte(self.config.bar_fill_char); + } + + try escape_codes.hideCursor(self.bw.writer()); + try escape_codes.setCursorColumn(self.bw.writer(), winsize.?.width - extra_back_chars); + + _ = try self.bw.writer().writeByte(self.config.bar_suffix); + + if (self.current_progress >= self.max_progress) { + _ = try self.bw.write("\n"); + } + + try self.bw.flush(); +} + +/// Returns `true` if the progress bar is finished and `false` otherwise. +pub fn finished(self: *ProgressBar) bool { + self.mutex.lock(); + defer self.mutex.unlock(); + + return self.current_progress >= self.max_progress; +} + +/// Finish the progress bar. +pub fn finish(self: *ProgressBar) void { + self.mutex.lock(); + defer self.mutex.unlock(); + + self.current_progress = self.max_progress; +} + +/// Reset the progress bar. +pub fn reset(self: *ProgressBar) void { + self.mutex.lock(); + defer self.mutex.unlock(); + + self.current_progress = 0; +} + +/// Set the progress bar to the `num`. If `num` is greater than the `max_progress`, +/// a `NumGreaterThanMax` error will be returned. +pub fn set(self: *ProgressBar, num: usize) Error!void { + if (num > self.max_progress) return Error.NumGreaterThanMax; + self.current_progress = num; +} + +/// Update the description. +pub fn update_description(self: *ProgressBar, description: []const u8) void { + self.mutex.lock(); + defer self.mutex.unlock(); + + self.config.description = description; +} + +fn clearLine(self: *ProgressBar) !void { + try escape_codes.clearCurrentLine(self.bw.writer()); + try escape_codes.setCursorColumn(self.bw.writer(), 0); +} diff --git a/src/termsize.zig b/src/termsize.zig @@ -0,0 +1,88 @@ +// Copyright (c) 2024 Doug Tangren +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +// https://github.com/softprops/zig-termsize + +const std = @import("std"); +const builtin = @import("builtin"); +const io = std.io; +const os = std.os; + +/// Terminal size dimensions +pub const TermSize = struct { + /// Terminal width as measured number of characters that fit into a terminal horizontally + width: u16, + /// terminal height as measured number of characters that fit into terminal vertically + height: u16, +}; + +/// supports windows, linux, macos +/// +/// ## example +/// +/// ```zig +/// const std = @import("std"); +/// const termSize = @import("termSize"); +/// +/// fn main() !void { +/// std.debug.print( +/// "{any}", +/// termSize.termSize(std.os.getStdOut()), +/// ); +/// } +/// ``` +pub fn termSize(file: std.fs.File) !?TermSize { + if (!file.supportsAnsiEscapeCodes()) { + return null; + } + return switch (builtin.os.tag) { + .windows => blk: { + var buf: os.windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; + break :blk switch (os.windows.kernel32.GetConsoleScreenBufferInfo( + file.handle, + &buf, + )) { + os.windows.TRUE => TermSize{ + .width = @intCast(buf.srWindow.Right - buf.srWindow.Left + 1), + .height = @intCast(buf.srWindow.Bottom - buf.srWindow.Top + 1), + }, + else => error.Unexpected, + }; + }, + .linux, .macos => blk: { + var buf: std.posix.system.winsize = undefined; + break :blk switch (std.posix.errno( + std.posix.system.ioctl( + file.handle, + std.posix.T.IOCGWINSZ, + @intFromPtr(&buf), + ), + )) { + .SUCCESS => TermSize{ + .width = buf.ws_col, + .height = buf.ws_row, + }, + else => error.IoctlError, + }; + }, + else => error.Unsupported, + }; +}