diff --git a/lib/std/Progress.zig b/lib/std/Progress.zig index fe2f7bc795b0..d077bb7002de 100644 --- a/lib/std/Progress.zig +++ b/lib/std/Progress.zig @@ -1,23 +1,30 @@ -//! This API non-allocating, non-fallible, and thread-safe. +//! This is a non-allocating, non-fallible, and thread-safe API for printing +//! progress indicators to the terminal. //! The tradeoff is that users of this API must provide the storage //! for each `Progress.Node`. //! +//! This library purposefully keeps its output simple and is ASCII-compatible. +//! //! Initialize the struct directly, overriding these fields as desired: //! * `refresh_rate_ms` //! * `initial_delay_ms` +//! * `dont_print_on_dumb` +//! * `max_width` const std = @import("std"); const builtin = @import("builtin"); const windows = std.os.windows; const testing = std.testing; const assert = std.debug.assert; +const os = std.os; +const time = std.time; const Progress = @This(); /// `null` if the current node (and its children) should /// not print on update() terminal: ?std.fs.File = undefined, -/// Is this a windows API terminal (note: this is not the same as being run on windows +/// Is this a Windows API terminal (note: this is not the same as being run on Windows /// because other terminals exist like MSYS/git-bash) is_windows_terminal: bool = false, @@ -35,7 +42,7 @@ 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 = null, +timer: ?time.Timer = null, /// When the previous refresh was written to the terminal. /// Used to compare with `refresh_rate_ms`. @@ -43,13 +50,20 @@ 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, +output_buffer: [256]u8 = undefined, +output_buffer_slice: []u8 = undefined, + +/// This is the maximum number of bytes written to the terminal with each refresh. +/// +/// It is recommended to leave this as `null` so that `start` can automatically decide an +/// optimal width for the terminal. +max_width: ?usize = null, /// How many nanoseconds between writing updates to the terminal. -refresh_rate_ns: u64 = 50 * std.time.ns_per_ms, +refresh_rate_ns: u64 = 50 * time.ns_per_ms, -/// How many nanoseconds to keep the output hidden -initial_delay_ns: u64 = 500 * std.time.ns_per_ms, +/// How many nanoseconds to keep the output hidden. +initial_delay_ns: u64 = 500 * time.ns_per_ms, done: bool = true, @@ -62,11 +76,14 @@ update_mutex: std.Thread.Mutex = .{}, /// we can move the cursor back later. columns_written: usize = undefined, +const truncation_suffix = "... "; + /// 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, + /// The name that will be displayed for this node. name: []const u8, /// Must be handled atomically to be thread-safe. recently_updated_child: ?*Node = null, @@ -155,6 +172,22 @@ pub fn start(self: *Progress, name: []const u8, estimated_total_items: usize) *N // we are in a "dumb" terminal like in acme or writing to a file self.terminal = stderr; } + if (self.max_width == null) { + if (self.terminal) |terminal| { + // choose an optimal width and account for progress output that could have been printed + // before us by another `std.Progress` instance + const terminal_width = self.getTerminalWidth(terminal.handle) catch 100; + const chars_already_printed = self.getTerminalCursorColumn(terminal) catch 0; + self.max_width = terminal_width - chars_already_printed; + } else { + self.max_width = 100; + } + } + self.max_width = std.math.clamp( + self.max_width.?, + truncation_suffix.len, // make sure we can at least truncate + self.output_buffer.len - 1, + ); self.root = Node{ .context = self, .parent = null, @@ -164,11 +197,64 @@ pub fn start(self: *Progress, name: []const u8, estimated_total_items: usize) *N }; self.columns_written = 0; self.prev_refresh_timestamp = 0; - self.timer = std.time.Timer.start() catch null; + self.timer = time.Timer.start() catch null; self.done = false; return &self.root; } +fn getTerminalWidth(self: Progress, file_handle: os.fd_t) !u16 { + if (builtin.os.tag == .linux) { + // TODO: figure out how to get this working on FreeBSD, macOS etc. too. + // they too should have capabilities to figure out the cursor column. + var winsize: os.linux.winsize = undefined; + switch (os.errno(os.linux.ioctl(file_handle, os.linux.T.IOCGWINSZ, @ptrToInt(&winsize)))) { + .SUCCESS => return winsize.ws_col, + else => return error.Unexpected, + } + } else if (builtin.os.tag == .windows) { + std.debug.assert(self.is_windows_terminal); + var info: windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; + if (windows.kernel32.GetConsoleScreenBufferInfo(file_handle, &info) != windows.TRUE) + return error.Unexpected; + return @intCast(u16, info.dwSize.X); + } else { + return error.Unsupported; + } +} + +fn getTerminalCursorColumn(self: Progress, file: std.fs.File) !u16 { + // TODO: figure out how to get this working on FreeBSD, macOS etc. too. + // they too should have termios or capabilities to figure out the terminal width. + if (builtin.os.tag == .linux and self.supports_ansi_escape_codes) { + // First, disable echo and enable non-canonical mode + // (so that no enter press required for us to read the output of the escape sequence below) + const original_termios = try os.tcgetattr(file.handle); + var new_termios = original_termios; + new_termios.lflag &= ~(os.linux.ECHO | os.linux.ICANON); + try os.tcsetattr(file.handle, .NOW, new_termios); + defer os.tcsetattr(file.handle, .NOW, original_termios) catch { + // Sorry for ruining your terminal + }; + + try file.writeAll("\x1b[6n"); + var buf: ["\x1b[256;256R".len]u8 = undefined; + const output = try file.reader().readUntilDelimiter(&buf, 'R'); + var splitter = std.mem.split(u8, output, ";"); + _ = splitter.next().?; // skip first half + const column_half = splitter.next() orelse return error.UnexpectedEnd; + const column = try std.fmt.parseUnsigned(u16, column_half, 10); + return column - 1; // it's one-based + } else if (builtin.os.tag == .windows) { + std.debug.assert(self.is_windows_terminal); + var info: windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; + if (windows.kernel32.GetConsoleScreenBufferInfo(file.handle, &info) != windows.TRUE) + return error.Unexpected; + return @intCast(u16, info.dwCursorPosition.X); + } else { + return error.Unsupported; + } +} + /// Updates the terminal if enough time has passed since last update. Thread-safe. pub fn maybeRefresh(self: *Progress) void { if (self.timer) |*timer| { @@ -198,14 +284,16 @@ fn refreshWithHeldLock(self: *Progress) void { const file = self.terminal orelse return; + // prepare for printing unprintable characters + self.output_buffer_slice = &self.output_buffer; + var end: usize = 0; if (self.columns_written > 0) { // restore the cursor position by moving the cursor - // `columns_written` cells to the left, then clear the rest of the - // line + // `columns_written` cells to the left, then clear the rest of the line if (self.supports_ansi_escape_codes) { - end += (std.fmt.bufPrint(self.output_buffer[end..], "\x1b[{d}D", .{self.columns_written}) catch unreachable).len; - end += (std.fmt.bufPrint(self.output_buffer[end..], "\x1b[0K", .{}) catch unreachable).len; + end += (std.fmt.bufPrint(self.output_buffer_slice[end..], "\x1b[{d}D", .{self.columns_written}) catch unreachable).len; + end += (std.fmt.bufPrint(self.output_buffer_slice[end..], "\x1b[0K", .{}) catch unreachable).len; } else if (builtin.os.tag == .windows) winapi: { std.debug.assert(self.is_windows_terminal); @@ -247,47 +335,53 @@ fn refreshWithHeldLock(self: *Progress) void { unreachable; } else { // we are in a "dumb" terminal like in acme or writing to a file - self.output_buffer[end] = '\n'; + self.output_buffer_slice[end] = '\n'; end += 1; } self.columns_written = 0; } + // from here on we will write printable characters. we also make sure the unprintable characters + // we possibly wrote previously don't affect whether we truncate the line in `bufWrite`. + const unprintables = end; + end = 0; + self.output_buffer_slice = self.output_buffer[unprintables .. unprintables + self.max_width.?]; + if (!self.done) { - var need_ellipse = false; + var need_ellipsis = false; var maybe_node: ?*Node = &self.root; while (maybe_node) |node| { - if (need_ellipse) { + if (need_ellipsis) { self.bufWrite(&end, "... ", .{}); } - need_ellipse = false; - const eti = @atomicLoad(usize, &node.unprotected_estimated_total_items, .Monotonic); + need_ellipsis = false; + const estimated_total_items = @atomicLoad(usize, &node.unprotected_estimated_total_items, .Monotonic); const completed_items = @atomicLoad(usize, &node.unprotected_completed_items, .Monotonic); const current_item = completed_items + 1; - if (node.name.len != 0 or eti > 0) { + if (node.name.len != 0 or estimated_total_items > 0) { if (node.name.len != 0) { self.bufWrite(&end, "{s}", .{node.name}); - need_ellipse = true; + need_ellipsis = true; } - if (eti > 0) { - if (need_ellipse) self.bufWrite(&end, " ", .{}); - self.bufWrite(&end, "[{d}/{d}] ", .{ current_item, eti }); - need_ellipse = false; + if (estimated_total_items > 0) { + if (need_ellipsis) self.bufWrite(&end, " ", .{}); + self.bufWrite(&end, "[{d}/{d}] ", .{ current_item, estimated_total_items }); + need_ellipsis = false; } else if (completed_items != 0) { - if (need_ellipse) self.bufWrite(&end, " ", .{}); + if (need_ellipsis) self.bufWrite(&end, " ", .{}); self.bufWrite(&end, "[{d}] ", .{current_item}); - need_ellipse = false; + need_ellipsis = false; } } maybe_node = @atomicLoad(?*Node, &node.recently_updated_child, .Acquire); } - if (need_ellipse) { + if (need_ellipsis) { self.bufWrite(&end, "... ", .{}); } } - _ = file.write(self.output_buffer[0..end]) catch { + _ = file.write(self.output_buffer[0 .. end + unprintables]) catch { // Stop trying to write to this file once it errors. self.terminal = null; }; @@ -310,32 +404,113 @@ pub fn log(self: *Progress, comptime format: []const u8, args: anytype) void { } fn bufWrite(self: *Progress, end: *usize, comptime format: []const u8, args: anytype) void { - if (std.fmt.bufPrint(self.output_buffer[end.*..], format, args)) |written| { + if (std.fmt.bufPrint(self.output_buffer_slice[end.*..], format, args)) |written| { const amt = written.len; end.* += amt; self.columns_written += amt; } else |err| switch (err) { error.NoSpaceLeft => { - self.columns_written += self.output_buffer.len - end.*; - end.* = self.output_buffer.len; - const suffix = "... "; - std.mem.copy(u8, self.output_buffer[self.output_buffer.len - suffix.len ..], suffix); + // truncate the line with a suffix. + // for example if we have "hello world" (len=11) and 10 is the limit, + // it would become "hello w... " + self.columns_written += self.output_buffer_slice.len - end.*; + end.* = self.output_buffer_slice.len; + std.mem.copy( + u8, + self.output_buffer_slice[self.output_buffer_slice.len - truncation_suffix.len ..], + truncation_suffix, + ); }, } } -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. +// By default these tests are disabled because they use time.sleep() +// and are therefore slow. They also prints bogus progress data to stderr. +const skip_tests = true; + +test "behavior on buffer overflow" { + if (skip_tests) + return error.SkipZigTest; + + // move the cursor + std.debug.print("{s}", .{"A" ** 300}); + + var progress = Progress{}; + + const long_string = "A" ** 300; + var node = progress.start(long_string, 0); + + const speed_factor = time.ns_per_s / 4; + + time.sleep(speed_factor); + node.activate(); + time.sleep(speed_factor); + node.end(); +} + +test "multiple tasks with long names" { + if (skip_tests) return error.SkipZigTest; + + var progress = Progress{}; + + const tasks = [_][]const u8{ + "A" ** 99, + "A" ** 100, + "A" ** 101, + "A" ** 102, + "A" ** 103, + }; + + const speed_factor = time.ns_per_s / 6; + + for (tasks) |task| { + var node = progress.start(task, 3); + time.sleep(speed_factor); + node.activate(); + + time.sleep(speed_factor); + node.completeOne(); + time.sleep(speed_factor); + node.completeOne(); + time.sleep(speed_factor); + node.completeOne(); + + node.end(); } +} + +test "very short max width" { + if (skip_tests) + return error.SkipZigTest; + + var progress = Progress{ .max_width = 4 }; + + const task = "A" ** 300; + + const speed_factor = time.ns_per_s / 2; + + var node = progress.start(task, 3); + time.sleep(speed_factor); + node.activate(); + + time.sleep(speed_factor); + node.completeOne(); + time.sleep(speed_factor); + node.completeOne(); + + node.end(); +} + +test "basic functionality" { + if (skip_tests) + return error.SkipZigTest; + var progress = Progress{}; const root_node = progress.start("", 100); defer root_node.end(); - const speed_factor = std.time.ns_per_ms; + const speed_factor = time.ns_per_ms; const sub_task_names = [_][]const u8{ "reticulating splines", @@ -352,24 +527,24 @@ test "basic functionality" { next_sub_task = (next_sub_task + 1) % sub_task_names.len; node.completeOne(); - std.time.sleep(5 * speed_factor); + time.sleep(5 * speed_factor); node.completeOne(); node.completeOne(); - std.time.sleep(5 * speed_factor); + time.sleep(5 * speed_factor); node.completeOne(); node.completeOne(); - std.time.sleep(5 * speed_factor); + time.sleep(5 * speed_factor); node.end(); - std.time.sleep(5 * speed_factor); + time.sleep(5 * speed_factor); } { 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 * speed_factor); + time.sleep(10 * speed_factor); progress.refresh(); - std.time.sleep(10 * speed_factor); + time.sleep(10 * speed_factor); node.end(); } }