// SPDX-License-Identifier: MIT // Copyright (c) 2015-2021 Zig Contributors // This file is part of [zig](https://ziglang.org/), which is MIT licensed. // The MIT license requires this copyright notice to be included in all copies // and substantial portions of the software. //! This API non-allocating, non-fallible, and thread-safe. //! The tradeoff is that users of this API must provide the storage //! for each `Progress.Node`. //! //! Initialize the struct directly, overriding these fields as desired: //! * `refresh_rate_ms` //! * `initial_delay_ms` const std = @import("std"); const windows = std.os.windows; const testing = std.testing; const assert = std.debug.assert; const Progress = @This(); /// `null` if the current node (and its children) should /// not print on update() terminal: ?std.fs.File = undefined, /// Whether the terminal supports ANSI escape codes. supports_ansi_escape_codes: bool = false, /// If the terminal is "dumb", don't print output. /// This can be useful if you don't want to print all /// the stages of code generation if there are a lot. /// You should not use it if the user should see output /// for example showing the user what tests run. dont_print_on_dumb: bool = false, root: Node = undefined, /// Keeps track of how much time has passed since the beginning. /// Used to compare with `initial_delay_ms` and `refresh_rate_ms`. timer: std.time.Timer = undefined, /// When the previous refresh was written to the terminal. /// Used to compare with `refresh_rate_ms`. prev_refresh_timestamp: u64 = undefined, /// This buffer represents the maximum number of bytes written to the terminal /// with each refresh. output_buffer: [100]u8 = undefined, /// How many nanoseconds between writing updates to the terminal. refresh_rate_ns: u64 = 50 * std.time.ns_per_ms, /// How many nanoseconds to keep the output hidden initial_delay_ns: u64 = 500 * std.time.ns_per_ms, done: bool = true, /// Protects the `refresh` function, as well as `node.recently_updated_child`. /// Without this, callsites would call `Node.end` and then free `Node` memory /// while it was still being accessed by the `refresh` function. update_lock: std.Thread.Mutex = .{}, /// Represents one unit of progress. Each node can have children nodes, or /// one can use integers with `update`. pub const Node = struct { context: *Progress, parent: ?*Node, name: []const u8, /// Must be handled atomically to be thread-safe. recently_updated_child: ?*Node = null, /// Must be handled atomically to be thread-safe. 0 means null. unprotected_estimated_total_items: usize, /// Must be handled atomically to be thread-safe. unprotected_completed_items: usize, /// Create a new child progress node. Thread-safe. /// Call `Node.end` when done. /// TODO solve https://github.com/ziglang/zig/issues/2765 and then change this /// API to set `self.parent.recently_updated_child` with the return value. /// Until that is fixed you probably want to call `activate` on the return value. /// Passing 0 for `estimated_total_items` means unknown. pub fn start(self: *Node, name: []const u8, estimated_total_items: usize) Node { return Node{ .context = self.context, .parent = self, .name = name, .unprotected_estimated_total_items = estimated_total_items, .unprotected_completed_items = 0, }; } /// This is the same as calling `start` and then `end` on the returned `Node`. Thread-safe. pub fn completeOne(self: *Node) void { self.activate(); _ = @atomicRmw(usize, &self.unprotected_completed_items, .Add, 1, .Monotonic); self.context.maybeRefresh(); } /// Finish a started `Node`. Thread-safe. pub fn end(self: *Node) void { self.context.maybeRefresh(); if (self.parent) |parent| { { const held = self.context.update_lock.acquire(); defer held.release(); _ = @cmpxchgStrong(?*Node, &parent.recently_updated_child, self, null, .Monotonic, .Monotonic); } parent.completeOne(); } else { const held = self.context.update_lock.acquire(); defer held.release(); self.context.done = true; self.context.refreshWithHeldLock(); } } /// Tell the parent node that this node is actively being worked on. Thread-safe. pub fn activate(self: *Node) void { if (self.parent) |parent| { @atomicStore(?*Node, &parent.recently_updated_child, self, .Release); } } /// Thread-safe. 0 means unknown. pub fn setEstimatedTotalItems(self: *Node, count: usize) void { @atomicStore(usize, &self.unprotected_estimated_total_items, count, .Monotonic); } /// Thread-safe. pub fn setCompletedItems(self: *Node, completed_items: usize) void { @atomicStore(usize, &self.unprotected_completed_items, completed_items, .Monotonic); } }; /// Create a new progress node. /// Call `Node.end` when done. /// TODO solve https://github.com/ziglang/zig/issues/2765 and then change this /// API to return Progress rather than accept it as a parameter. /// `estimated_total_items` value of 0 means unknown. pub fn start(self: *Progress, name: []const u8, estimated_total_items: usize) !*Node { const stderr = std.io.getStdErr(); self.terminal = null; if (stderr.supportsAnsiEscapeCodes()) { self.terminal = stderr; self.supports_ansi_escape_codes = true; } else if (std.builtin.os.tag == .windows and stderr.isTty()) { self.terminal = stderr; } else if (std.builtin.os.tag != .windows) { // we are in a "dumb" terminal like in acme or writing to a file self.terminal = stderr; } self.root = Node{ .context = self, .parent = null, .name = name, .unprotected_estimated_total_items = estimated_total_items, .unprotected_completed_items = 0, }; self.prev_refresh_timestamp = 0; self.timer = try std.time.Timer.start(); self.done = false; return &self.root; } /// Updates the terminal if enough time has passed since last update. Thread-safe. pub fn maybeRefresh(self: *Progress) void { const now = self.timer.read(); if (now < self.initial_delay_ns) return; const held = self.update_lock.tryAcquire() orelse return; defer held.release(); // TODO I have observed this to happen sometimes. I think we need to follow Rust's // lead and guarantee monotonically increasing times in the std lib itself. if (now < self.prev_refresh_timestamp) return; if (now - self.prev_refresh_timestamp < self.refresh_rate_ns) return; return self.refreshWithHeldLock(); } /// Updates the terminal and resets `self.next_refresh_timestamp`. Thread-safe. pub fn refresh(self: *Progress) void { const held = self.update_lock.tryAcquire() orelse return; defer held.release(); return self.refreshWithHeldLock(); } // ED -- Clear screen const ED = "\x1b[J"; // DECSC -- Save cursor position const DECSC = "\x1b7"; // DECRC -- Restore cursor position const DECRC = "\x1b8"; // Note that ESC7/ESC8 are used instead of CSI s/CSI u as the latter are not // supported by some terminals (eg. Terminal.app). fn refreshWithHeldLock(self: *Progress) void { const is_dumb = !self.supports_ansi_escape_codes and !(std.builtin.os.tag == .windows); if (is_dumb and self.dont_print_on_dumb) return; const file = self.terminal orelse return; var end: usize = 0; // Save the cursor position and clear the part of the screen below. // Clearing only the line is not enough as the terminal may wrap the line // when it becomes too long. var saved_cursor_pos: windows.COORD = undefined; if (self.supports_ansi_escape_codes) { const seq_before = DECSC ++ ED; std.mem.copy(u8, self.output_buffer[end..], seq_before); end += seq_before.len; } else if (std.builtin.os.tag == .windows) winapi: { var info: windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; if (windows.kernel32.GetConsoleScreenBufferInfo(file.handle, &info) != windows.TRUE) unreachable; saved_cursor_pos = info.dwCursorPosition; const window_height = @intCast(windows.DWORD, info.srWindow.Bottom - info.srWindow.Top + 1); const window_width = @intCast(windows.DWORD, info.srWindow.Right - info.srWindow.Left + 1); // Number of terminal cells to clear, starting from the cursor position // and ending at the window bottom right corner. const fill_chars = if (window_width == 0 or window_height == 0) 0 else chars: { break :chars window_width * (window_height - @intCast(windows.DWORD, info.dwCursorPosition.Y - info.srWindow.Top)) - @intCast(windows.DWORD, info.dwCursorPosition.X - info.srWindow.Left); }; var written: windows.DWORD = undefined; if (windows.kernel32.FillConsoleOutputAttribute( file.handle, info.wAttributes, fill_chars, saved_cursor_pos, &written, ) != windows.TRUE) { // Stop trying to write to this file. self.terminal = null; break :winapi; } if (windows.kernel32.FillConsoleOutputCharacterW( file.handle, ' ', fill_chars, saved_cursor_pos, &written, ) != windows.TRUE) { unreachable; } } if (!self.done) { var need_ellipse = false; var maybe_node: ?*Node = &self.root; while (maybe_node) |node| { if (need_ellipse) { self.bufWrite(&end, "... ", .{}); } need_ellipse = false; const eti = @atomicLoad(usize, &node.unprotected_estimated_total_items, .Monotonic); const completed_items = @atomicLoad(usize, &node.unprotected_completed_items, .Monotonic); if (node.name.len != 0 or eti > 0) { if (node.name.len != 0) { self.bufWrite(&end, "{s}", .{node.name}); need_ellipse = true; } if (eti > 0) { if (need_ellipse) self.bufWrite(&end, " ", .{}); self.bufWrite(&end, "[{d}/{d}] ", .{ completed_items + 1, eti }); need_ellipse = false; } else if (completed_items != 0) { if (need_ellipse) self.bufWrite(&end, " ", .{}); self.bufWrite(&end, "[{d}] ", .{completed_items + 1}); need_ellipse = false; } } maybe_node = @atomicLoad(?*Node, &node.recently_updated_child, .Acquire); } if (need_ellipse) { self.bufWrite(&end, "... ", .{}); } } // We're done printing the updated message, restore the cursor position. if (self.supports_ansi_escape_codes) { const seq_after = DECRC; std.mem.copy(u8, self.output_buffer[end..], seq_after); end += seq_after.len; } else if (std.builtin.os.tag != .windows) { self.output_buffer[end] = '\n'; end += 1; } _ = file.write(self.output_buffer[0..end]) catch |e| { // Stop trying to write to this file once it errors. self.terminal = null; }; if (std.builtin.os.tag == .windows) { if (windows.kernel32.SetConsoleCursorPosition(file.handle, saved_cursor_pos) != windows.TRUE) unreachable; } self.prev_refresh_timestamp = self.timer.read(); } pub fn log(self: *Progress, comptime format: []const u8, args: anytype) void { const file = self.terminal orelse return; self.refresh(); file.writer().print(format, args) catch { self.terminal = null; return; }; } fn bufWrite(self: *Progress, end: *usize, comptime format: []const u8, args: anytype) void { if (std.fmt.bufPrint(self.output_buffer[end.*..], format, args)) |written| { const amt = written.len; end.* += amt; } else |err| switch (err) { error.NoSpaceLeft => { end.* = self.output_buffer.len; }, } const bytes_needed_for_esc_codes_at_end = if (std.builtin.os.tag == .windows) 0 else 11; const max_end = self.output_buffer.len - bytes_needed_for_esc_codes_at_end; if (end.* > max_end) { const suffix = "... "; std.mem.copy(u8, self.output_buffer[max_end..], suffix); end.* = max_end + suffix.len; } } test "basic functionality" { var disable = true; if (disable) { // This test is disabled because it uses time.sleep() and is therefore slow. It also // prints bogus progress data to stderr. return error.SkipZigTest; } var progress = Progress{}; const root_node = try progress.start("", 100); defer root_node.end(); const sub_task_names = [_][]const u8{ "reticulating splines", "adjusting shoes", "climbing towers", "pouring juice", }; var next_sub_task: usize = 0; var i: usize = 0; while (i < 100) : (i += 1) { var node = root_node.start(sub_task_names[next_sub_task], 5); node.activate(); next_sub_task = (next_sub_task + 1) % sub_task_names.len; node.completeOne(); std.time.sleep(5 * std.time.ns_per_ms); node.completeOne(); node.completeOne(); std.time.sleep(5 * std.time.ns_per_ms); node.completeOne(); node.completeOne(); std.time.sleep(5 * std.time.ns_per_ms); node.end(); std.time.sleep(5 * std.time.ns_per_ms); } { var node = root_node.start("this is a really long name designed to activate the truncation code. let's find out if it works", 0); node.activate(); std.time.sleep(10 * std.time.ns_per_ms); progress.refresh(); std.time.sleep(10 * std.time.ns_per_ms); node.end(); } }