Browse Source

feat: single-line progress bar — 1/3 text + 2/3 bar zone, overall percentage, resize-aware, chunky chars, green completion, dedup, per-stage logs

clanker 1 week ago
parent
commit
8ca533095d
2 changed files with 1407 additions and 1395 deletions
  1. 1200 1215
      installer/install-usm.sh
  2. 207 180
      src/cli/ProgressBar.vala

File diff suppressed because it is too large
+ 1200 - 1215
installer/install-usm.sh


+ 207 - 180
src/cli/ProgressBar.vala

@@ -1,28 +1,40 @@
 namespace Usm.Cli {
 
     /**
-     * Two-line terminal progress display for a running
-     * {@link Usm.Transaction}. The bottom line is a bar for the ENTIRE
-     * transaction — `(task + task progress) / total tasks` taken from
-     * {@link Usm.Transaction.progress_updated} — and the line above it
-     * carries the current action with its own percentage. When a package
-     * finishes, its action line is erased and replaced by the completion
-     * log, which scrolls up as the next action takes the slot:
+     * Single-line, in-place progress display for a running
+     * {@link Usm.Transaction}:
      *
      * ```
-     * ✓ falpha-1.0.0 installed
-     *   Building fgamma-1.0.0 (67%)
-     * [████████████████░░░░░░░░░░░░░░░░░░░] 42%
+     * → [3/14] Installing invercargill      ████████████████░░░░░░░░░░░ 42%
      * ```
      *
-     * The bar's cell count is picked once, at construction, from the
-     * terminal width and never changes for the bar's lifetime (a resize
-     * mid-transaction is not re-fetched). Colours follow the current
-     * {@link Usm.TransactionTask} (green unpack/install, blue build,
+     * The line splits into two fixed zones: the leftmost third of the
+     * terminal carries the text — arrow, action counter and
+     * present-tense action label, ellipsised and space-padded to
+     * exactly the zone width — and the right two thirds hold the bar:
+     * a gap, the `█`/`░` cells (`[####    ]` with brackets in the
+     * ASCII fallback), a second gap and the OVERALL transaction
+     * percentage, right-aligned in a `100%`-wide field so the cell
+     * count never changes. Bar fill and trailing number both track
+     * `(task + task progress) / total tasks` from
+     * {@link Usm.Transaction.progress_updated}; the current action's
+     * own percentage is not shown. The width is re-queried on every
+     * draw, so a resized terminal re-proportions the zones on the
+     * next report.
+     *
+     * Each completed action leaves a persistent log line that scrolls
+     * up — `✓ [3/14] Installed invercargill` (past tense, task
+     * colour) — by consuming the in-place line; repeated 100% reports
+     * for one action never duplicate it. A successful close prints
+     * `✓ Transaction completed` in green; failures print
+     * `✗ [3/14] Failed invercargill` in red. Colours per
+     * {@link Usm.TransactionTask}: green unpack/install, blue build,
      * magenta test, yellow remove/rebuild/clean-up, cyan strategy, red
-     * failure). When stderr is not a terminal the display degrades to
-     * plain `[42%] Installing invercargill (2/5)` lines on 10% steps
-     * with no ANSI codes.
+     * failure.
+     *
+     * When stderr is not a terminal the same lines print as plain text
+     * — one per significant change, no ANSI — with the ASCII fallback
+     * characters in non-Unicode locales.
      *
      * Connect {@link on_transaction_progress} to
      * {@link Usm.Transaction.progress_updated}:
@@ -49,13 +61,10 @@ namespace Usm.Cli {
         private const ulong TIOCGWINSZ = 0x5413;
 
         private bool interactive;
-        private int width;
-
-        /** Bar cell count, fixed for the bar's lifetime so the bar never grows or shrinks. */
-        private int bar_capacity;
 
         private string bar_filled_char;
         private string bar_empty_char;
+        private string action_mark;
         private string success_mark;
         private string failure_mark;
         private bool unicode;
@@ -66,11 +75,6 @@ namespace Usm.Cli {
         private int current_index;
         private int current_total;
 
-        /** A package's completion log fires exactly once, at CLEANING_UP's final 1.0 report. */
-        private bool completion_printed;
-        private bool current_was_removal;
-        private bool current_was_rebuild;
-
         /**
          * Whole-transaction fraction: the running maximum of
          * `(task + task progress) / total tasks`. Task weighting is the
@@ -80,9 +84,16 @@ namespace Usm.Cli {
          */
         private float overall = 0.0f;
 
-        /** Managed-region state: the bar row exists once drawn; the action row above it comes and goes. */
-        private bool bar_started;
-        private bool action_live;
+        /** Whether the transient in-place line is currently on screen. */
+        private bool line_live;
+
+        /**
+         * Action counter of the last logged completion. Actions log at
+         * most once however many 100% reports they emit (installs fire
+         * several); the counter is monotonic within a transaction, so
+         * `action > last_logged_action` is a sufficient duplicate guard.
+         */
+        private int last_logged_action = -1;
 
         private string plain_key = "";
         private int plain_decile = -1;
@@ -90,15 +101,14 @@ namespace Usm.Cli {
         construct {
             // Charset probing needs the locale applied before it reflects
             // the environment; without this the C locale forces the ASCII
-            // bar/mark fallbacks even on UTF-8 terminals
+            // fallbacks even on UTF-8 terminals
             Intl.setlocale(LocaleCategory.ALL, "");
             interactive = Posix.isatty(Posix.STDERR_FILENO);
-            width = detect_terminal_width();
-            bar_capacity = int.max(10, int.min(40, width - 40));
             unowned string charset;
             unicode = get_charset(out charset);
             bar_filled_char = unicode ? "█" : "#";
-            bar_empty_char = unicode ? "░" : "-";
+            bar_empty_char = unicode ? "░" : " ";
+            action_mark = unicode ? "→" : ">";
             success_mark = unicode ? "✓" : "+";
             failure_mark = unicode ? "✗" : "x";
         }
@@ -112,208 +122,222 @@ namespace Usm.Cli {
         }
 
         /**
-         * Records one progress report and redraws the two-line display
-         * with the cursor left at the end of the bar row. A package whose
-         * CLEANING_UP reaches 100% — its last report — instead has its
-         * action line erased and replaced by the completion log (see
-         * {@link complete_line}).
+         * Records one progress report and redraws the in-place line. A
+         * report that moves the action counter on, or the current
+         * action's first 100%, retires the current action: it consumes
+         * the in-place line and leaves the persistent
+         * `✓ [n/m] <past tense> <package>` log above it — once only,
+         * however many further 100% reports the action emits.
          */
         public void update(TransactionTask task, float progress, string? package_name, int index, int total) {
-            var completes_now = task == TransactionTask.CLEANING_UP && progress >= 1.0f
-                && package_name != null && package_name == current_package && !completion_printed;
-            var outcome = current_was_removal ? TransactionTask.REMOVING
-                : current_was_rebuild ? TransactionTask.REBUILDING : TransactionTask.INSTALLING;
-
-            if(package_name != current_package) {
-                current_package = package_name;
-                completion_printed = false;
-                current_was_removal = false;
-                current_was_rebuild = false;
-            }
-            if(task == TransactionTask.REMOVING) {
-                current_was_removal = true;
-            }
-            if(task == TransactionTask.REBUILDING) {
-                current_was_rebuild = true;
+            var same_action = index == current_index;
+            var retire = line_live && current_index > last_logged_action
+                && (index > current_index || (same_action && progress >= 1.0f));
+
+            if(retire) {
+                if(!interactive && same_action) {
+                    print_plain();
+                }
+                print_action_log();
+                last_logged_action = current_index;
             }
 
             current_task = task;
+            current_package = package_name;
             current_progress = progress < 0.0f ? 0.0f : progress > 1.0f ? 1.0f : progress;
             current_index = index;
             current_total = total;
-            var reported = current_total > 0 ? ((float)current_index + current_progress) / (float)current_total : 0.0f;
+            var reported = total > 0 ? ((float)index + current_progress) / (float)total : 0.0f;
             overall = reported > overall ? reported : overall;
 
-            if(completes_now) {
-                if(!interactive) {
+            // A retired action's own 100% report does not redraw — its
+            // log just consumed the line; the next action draws fresh
+            if(!(retire && same_action)) {
+                if(interactive) {
+                    draw_line();
+                }
+                else {
                     print_plain();
                 }
-                print_completion(package_name, outcome, true);
-                completion_printed = true;
-                return;
-            }
-
-            if(interactive) {
-                draw_region();
-            }
-            else {
-                print_plain();
             }
         }
 
         /**
-         * Replaces the current action line with a completion log —
-         * `✓ falpha-1.0.0 installed` in the task colour, or
-         * `✗ falpha-1.0.0 failed` in red — which scrolls up as history,
-         * then redraws the bar underneath it.
+         * Prints a completion log for an explicit ({@link package_name},
+         * {@link task}) pair — the same `✓ [n/m] <past tense> <package>`
+         * line actions leave automatically, or `✗ [n/m] Failed <package>`
+         * in red when {@link success} is false.
          */
         public void complete_line(string package_name, TransactionTask task, bool success) {
-            print_completion(package_name, task, success);
+            var counter = current_total > 0 ? @"[$(current_index + 1)/$current_total] " : "";
+            var outcome = success ? past_tense(task) : "Failed";
+            if(interactive) {
+                stderr.printf("\r\033[K%s%s %s%s %s%s\n",
+                    success ? colour_for_task(task) : COLOUR_RED,
+                    success ? success_mark : failure_mark, counter, outcome, package_name, COLOUR_RESET);
+                line_live = false;
+            }
+            else {
+                stderr.printf("%s %s%s %s\n", success ? success_mark : failure_mark, counter, outcome, package_name);
+            }
         }
 
         /**
-         * Marks the in-flight package failed (a thrown
-         * {@link Usm.TransactionError} aborts the run): erases the whole
-         * managed region and prints `✗ <package> failed` in red — no
-         * redraw, the transaction is over. Harmless when nothing was
-         * drawn yet.
+         * Marks the in-flight action failed (a thrown
+         * {@link Usm.TransactionError} aborts the run): erases the
+         * in-place line and prints `✗ [n/m] Failed <package>` in red.
+         * Harmless when nothing was drawn yet.
          */
         public void fail() {
-            if(!interactive) {
-                if(current_package != null && !completion_printed) {
-                    completion_printed = true;
-                    stderr.printf("%s %s failed\n", failure_mark, current_package);
+            var counter = current_total > 0 ? @"[$(current_index + 1)/$current_total] " : "";
+            if(interactive) {
+                if(line_live) {
+                    stderr.printf("\r\033[K");
                 }
-                return;
+                stderr.printf("%s%s %sFailed %s%s\n", COLOUR_RED, failure_mark, counter, current_package ?? "transaction", COLOUR_RESET);
+                line_live = false;
             }
-
-            if(bar_started) {
-                collapse_region();
-            }
-            if(current_package != null && !completion_printed) {
-                completion_printed = true;
-                stderr.printf("%s%s %s failed%s\r\n", COLOUR_RED, failure_mark, current_package, COLOUR_RESET);
+            else {
+                stderr.printf("%s %sFailed %s\n", failure_mark, counter, current_package ?? "transaction");
             }
         }
 
         /**
-         * Erases the managed region and prints the closing summary line,
-         * leaving the cursor on a fresh row for the caller's own totals.
+         * Closes the display after a successful transaction: an action
+         * that never reported 100% still leaves its log, the transient
+         * line is erased, and `✓ Transaction completed` prints in
+         * green, scrolling up with the other logs.
          */
         public void finish() {
-            if(!interactive || !bar_started) {
-                return;
+            if(line_live && current_index > last_logged_action) {
+                print_action_log();
+                last_logged_action = current_index;
+            }
+            if(interactive) {
+                if(line_live) {
+                    stderr.printf("\r\033[K");
+                }
+                line_live = false;
+                stderr.printf("%s%s Transaction completed%s\n", COLOUR_GREEN, success_mark, COLOUR_RESET);
+            }
+            else {
+                stderr.printf("%s Transaction completed\n", success_mark);
             }
-            collapse_region();
-            stderr.printf("%s%s Transaction completed%s\r\n", COLOUR_GREEN, success_mark, COLOUR_RESET);
         }
 
         /**
-         * Redraws both managed rows. The cursor starts (and ends) on the
-         * bar row; the action row is one `\033[1A` above it:
-         * up, erase, action; down, erase, bar. When the action row was
-         * consumed by a completion log, `\r\n` first opens a fresh row
-         * below the bar so the bar can move onto it while the action
-         * text takes the old bar row.
+         * The persistent log for the current action, in the past tense;
+         * printing it consumes the transient line, so the log scrolls
+         * up as the next action redraws below.
          */
-        private void draw_region() {
-            var action = colourised_action_line();
-            var bar = bar_line();
-            if(!bar_started) {
-                stderr.printf("%s\r\n\033[K%s", action, bar);
-                bar_started = true;
-                action_live = true;
-            }
-            else if(!action_live) {
-                stderr.printf("\r\n\033[1A\033[K%s\r\033[1B\033[K%s", action, bar);
-                action_live = true;
+        private void print_action_log() {
+            var counter = @"[$(current_index + 1)/$current_total]";
+            if(interactive) {
+                stderr.printf("\r\033[K%s%s %s %s %s%s\n", colour_for_task(current_task), success_mark,
+                    counter, past_tense(current_task), current_package ?? "transaction", COLOUR_RESET);
+                line_live = false;
             }
             else {
-                stderr.printf("\r\033[1A\033[K%s\r\033[1B\033[K%s", action, bar);
+                stderr.printf("%s %s %s %s\n", success_mark, counter, past_tense(current_task), current_package ?? "transaction");
             }
         }
 
-        private void print_completion(string package_name, TransactionTask task, bool success) {
-            var outcome = success ? past_tense(task) : "failed";
-            var mark = success ? success_mark : failure_mark;
-            var colour = success ? colour_for_task(task) : COLOUR_RED;
-            var log = @"$colour$(fit(@"$mark $package_name $outcome"))$COLOUR_RESET";
+        private void draw_line() {
+            stderr.printf("\r\033[K%s%s%s", colour_for_task(current_task), line_text(), COLOUR_RESET);
+            line_live = true;
+        }
 
-            if(!interactive) {
-                stderr.printf("%s %s %s\n", mark, package_name, outcome);
-                return;
-            }
+        /**
+         * One full terminal row: the action text space-padded to the
+         * left third, then the bar zone filling the right two thirds —
+         * gap, cells, gap and the overall percentage right-aligned in
+         * a `100%`-wide field. The width is re-queried on every call
+         * so a resized terminal re-proportions the layout immediately.
+         */
+        private string line_text() {
+            var width = detect_terminal_width();
+            var text_zone = int.max(14, width / 3);
+            var bar_zone = width - text_zone;
 
-            // The log takes the row the action line (or, without one, the
-            // bar itself) occupied; the bar redraws below it, so the log
-            // stays above the region as scrolling history
-            if(action_live) {
-                stderr.printf("\r\033[1A\033[K%s\r\n\033[K%s", log, bar_line());
-                action_live = false;
-            }
-            else if(bar_started) {
-                stderr.printf("\r\n\033[1A\033[K%s\r\n\033[K%s", log, bar_line());
-            }
-            else {
-                stderr.printf("%s\r\n", log);
+            var cells = new StringBuilder();
+            cells.append(zone_text(text_zone));
+            var padding = text_zone - cells.str.char_count();
+            for(var i = 0; i < padding; i++) {
+                cells.append(" ");
             }
-        }
 
-        /** Erases the bar row and, when live, the action row above it, leaving the cursor at the action row's column zero. */
-        private void collapse_region() {
-            stderr.printf("\r\033[K");
-            if(action_live) {
-                stderr.printf("\r\033[1A\033[K");
-                action_live = false;
+            // The percentage field is fixed at "100%" width, so the
+            // cell count stays constant from 0% to 100%
+            var percent = (int)(overall * 100);
+            var capacity = bar_zone - 6 - (unicode ? 0 : 2);
+            // + 0.5 rounding in plain arithmetic: the CLI target does not
+            // link libm, so Math.roundf is unavailable here
+            var filled = (int)(overall * capacity + 0.5f);
+            if(filled > capacity) {
+                filled = capacity;
             }
-            bar_started = false;
-        }
-
-        private string colourised_action_line() {
-            var colour = colour_for_task(current_task);
-            var percent = (int)(current_progress * 100);
-            return @"$colour  $(fit(@"$(label_for(current_task, current_package)) ($percent%)"))$COLOUR_RESET";
-        }
 
-        private string bar_line() {
-            var colour = colour_for_task(current_task);
-            var percent = (int)(overall * 100);
-            var filled = (int)(overall * bar_capacity);
-            var cells = new StringBuilder();
+            cells.append(" ");
+            if(!unicode) {
+                cells.append("[");
+            }
             for(var i = 0; i < filled; i++) {
                 cells.append(bar_filled_char);
             }
-            for(var i = filled; i < bar_capacity; i++) {
+            for(var i = filled; i < capacity; i++) {
                 cells.append(bar_empty_char);
             }
-            return @"$colour[$(cells.str)] $percent%$COLOUR_RESET";
+            if(!unicode) {
+                cells.append("]");
+            }
+            cells.append(" ");
+            cells.append("%3d%%".printf(percent));
+            return cells.str;
+        }
+
+        /**
+         * `→ [3/14] Installing pkg`: the action label is ellipsised
+         * into whatever the text zone leaves it; when even the arrow
+         * and counter overflow a very narrow zone the whole text is
+         * ellipsised instead.
+         */
+        private string zone_text(int text_zone) {
+            var counter = @"[$(current_index + 1)/$current_total]";
+            var head = @"$action_mark $counter ";
+            var label = label_for(current_task, current_package);
+            var allowed = text_zone - head.char_count();
+            if(allowed >= 4) {
+                return @"$head$(ellipsise(label, allowed))";
+            }
+            return ellipsise(@"$head$label", text_zone);
         }
 
         private void print_plain() {
-            var key = @"$current_package|$(current_task)";
+            var key = @"$current_package|$(current_task)|$current_index";
             var decile = (int)(current_progress * 10);
             if(key == plain_key && decile == plain_decile) {
                 return;
             }
             plain_key = key;
             plain_decile = decile;
-
-            var counts = current_total > 0 ? @" ($(current_index + 1)/$current_total)" : "";
-            stderr.printf("[%d%%] %s%s\n", (int)(current_progress * 100), label_for(current_task, current_package), counts);
+            stderr.printf("%s\n", line_text());
         }
 
         /**
-         * Truncates {@link text} to one terminal row — a wrapped action
-         * or log line would break the two-line geometry. Applied to
-         * plain text only, before colour wrapping, so the budget counts
-         * visible characters and never cuts inside an escape sequence.
+         * Truncates {@link label} to {@link allowed} visible characters
+         * with an ellipsis, char-aligned so a multi-byte name is never
+         * cut mid-codepoint.
          */
-        private string fit(string text) {
-            var limit = int.max(8, width - 1);
-            if(text.char_count() <= limit) {
-                return text;
+        private string ellipsise(string label, int allowed) {
+            if(label.char_count() <= allowed) {
+                return label;
             }
-            return text.substring(0, text.index_of_nth_char(limit - 1)) + (unicode ? "…" : "~");
+            var marker = unicode ? "…" : "...";
+            var cut = allowed - marker.char_count();
+            if(cut <= 0) {
+                return marker;
+            }
+            return label.substring(0, label.index_of_nth_char(cut)) + marker;
         }
 
         private string label_for(TransactionTask task, string? package_name) {
@@ -347,20 +371,22 @@ namespace Usm.Cli {
 
         private string past_tense(TransactionTask task) {
             switch(task) {
-                case TransactionTask.INSTALLING:
-                    return "installed";
-                case TransactionTask.REMOVING:
-                    return "removed";
-                case TransactionTask.REBUILDING:
-                    return "rebuilt";
+                case TransactionTask.STRATEGISING:
+                    return "Prepared";
                 case TransactionTask.UNPACKING:
-                    return "unpacked";
+                    return "Unpacked";
                 case TransactionTask.BUILDING:
-                    return "built";
+                    return "Built";
                 case TransactionTask.TESTING:
-                    return "tested";
+                    return "Tested";
+                case TransactionTask.INSTALLING:
+                    return "Installed";
+                case TransactionTask.REMOVING:
+                    return "Removed";
+                case TransactionTask.REBUILDING:
+                    return "Rebuilt";
                 case TransactionTask.CLEANING_UP:
-                    return "cleaned up";
+                    return "Cleaned up";
                 default:
                     return task.get_verb();
             }
@@ -380,7 +406,8 @@ namespace Usm.Cli {
         /**
          * The terminal width of stderr: TIOCGWINSZ first, then
          * `$COLUMNS`, then 80 — mirroring how shells and common CLI
-         * tooling pick a fallback width.
+         * tooling pick a fallback width. Queried per draw rather than
+         * cached, which is what makes the display resize-responsive.
          */
         private int detect_terminal_width() {
             WinSize window = WinSize();

Some files were not shown because too many files changed in this diff