| | | 1 | | namespace Kestrun.Tool; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// Writes in-place progress updates for module operations. |
| | | 5 | | /// </summary> |
| | 5 | 6 | | internal sealed class ConsoleProgressBar(string label, long? total, Func<long, long?, string>? detailFormatter) : IDispo |
| | | 7 | | { |
| | | 8 | | private const int ProgressBarWidth = 28; |
| | | 9 | | private const int MinimumBarWidth = 8; |
| | 1 | 10 | | private static readonly TimeSpan RenderThrottle = TimeSpan.FromMilliseconds(80); |
| | | 11 | | |
| | 5 | 12 | | private readonly string _label = label; |
| | 5 | 13 | | private readonly long? _total = total.HasValue && total.Value > 0 ? total : null; |
| | 5 | 14 | | private readonly Func<long, long?, string>? _detailFormatter = detailFormatter; |
| | 5 | 15 | | private readonly bool _enabled = !Console.IsOutputRedirected; |
| | | 16 | | private int _lastRenderedLength; |
| | 5 | 17 | | private int _lastPercent = -1; |
| | 5 | 18 | | private long _lastValue = -1; |
| | 5 | 19 | | private DateTime _lastRenderUtc = DateTime.MinValue; |
| | | 20 | | private bool _hasRendered; |
| | | 21 | | private bool _isComplete; |
| | | 22 | | |
| | | 23 | | public void Report(long value, bool force = false) |
| | | 24 | | { |
| | 1 | 25 | | if (!_enabled || _isComplete) |
| | | 26 | | { |
| | 1 | 27 | | return; |
| | | 28 | | } |
| | | 29 | | |
| | 0 | 30 | | var sanitizedValue = Math.Max(0, value); |
| | 0 | 31 | | var percent = GetPercent(sanitizedValue); |
| | 0 | 32 | | var now = DateTime.UtcNow; |
| | | 33 | | |
| | 0 | 34 | | if (!force |
| | 0 | 35 | | && sanitizedValue == _lastValue |
| | 0 | 36 | | && percent == _lastPercent) |
| | | 37 | | { |
| | 0 | 38 | | return; |
| | | 39 | | } |
| | | 40 | | |
| | 0 | 41 | | if (!force |
| | 0 | 42 | | && percent == _lastPercent |
| | 0 | 43 | | && now - _lastRenderUtc < RenderThrottle) |
| | | 44 | | { |
| | 0 | 45 | | return; |
| | | 46 | | } |
| | | 47 | | |
| | 0 | 48 | | _lastValue = sanitizedValue; |
| | 0 | 49 | | _lastPercent = percent; |
| | 0 | 50 | | _lastRenderUtc = now; |
| | 0 | 51 | | Render(sanitizedValue, percent); |
| | 0 | 52 | | } |
| | | 53 | | |
| | | 54 | | public void Complete(long value) |
| | | 55 | | { |
| | 1 | 56 | | if (!_enabled || _isComplete) |
| | | 57 | | { |
| | 1 | 58 | | return; |
| | | 59 | | } |
| | | 60 | | |
| | 0 | 61 | | var completionValue = _total ?? Math.Max(0, value); |
| | 0 | 62 | | Report(completionValue, force: true); |
| | 0 | 63 | | Console.WriteLine(); |
| | 0 | 64 | | _isComplete = true; |
| | 0 | 65 | | } |
| | | 66 | | |
| | | 67 | | public void Dispose() |
| | | 68 | | { |
| | 1 | 69 | | if (_enabled && _hasRendered && !_isComplete) |
| | | 70 | | { |
| | 0 | 71 | | Console.WriteLine(); |
| | | 72 | | } |
| | 1 | 73 | | } |
| | | 74 | | |
| | | 75 | | /// <summary> |
| | | 76 | | /// Returns the percentage (0-100) for the given value based on the total, or -1 if percentage cannot be calculated. |
| | | 77 | | /// </summary> |
| | | 78 | | /// <param name="value">The current value.</param> |
| | | 79 | | /// <returns>The percentage (0-100) or -1 if percentage cannot be calculated.</returns> |
| | | 80 | | private int GetPercent(long value) |
| | | 81 | | { |
| | 3 | 82 | | if (!_total.HasValue || _total.Value <= 0) |
| | | 83 | | { |
| | 1 | 84 | | return -1; |
| | | 85 | | } |
| | | 86 | | // Use long math to avoid overflow, then clamp to 0-100. |
| | 2 | 87 | | return (int)Math.Clamp(value * 100L / _total.Value, 0, 100); |
| | | 88 | | } |
| | | 89 | | |
| | | 90 | | /// <summary> |
| | | 91 | | /// Renders the progress bar line based on the current value and percentage. |
| | | 92 | | /// </summary> |
| | | 93 | | /// <param name="value">The current value.</param> |
| | | 94 | | /// <param name="percent">The current percentage (0-100) or -1 if percentage cannot be calculated.</param> |
| | | 95 | | private void Render(long value, int percent) |
| | | 96 | | { |
| | 1 | 97 | | var detail = _detailFormatter is null |
| | 1 | 98 | | ? _total.HasValue |
| | 1 | 99 | | ? $"{value}/{_total.Value}" |
| | 1 | 100 | | : value.ToString() |
| | 1 | 101 | | : _detailFormatter(value, _total); |
| | | 102 | | |
| | 1 | 103 | | var line = BuildRenderedLine(detail, percent, GetUsableConsoleWidth()); |
| | | 104 | | |
| | 1 | 105 | | var paddedLength = Math.Min(_lastRenderedLength, Math.Max(0, GetUsableConsoleWidth())); |
| | 1 | 106 | | if (line.Length < paddedLength) |
| | | 107 | | { |
| | 0 | 108 | | line = line.PadRight(paddedLength); |
| | | 109 | | } |
| | | 110 | | |
| | 1 | 111 | | _lastRenderedLength = line.Length; |
| | 1 | 112 | | _hasRendered = true; |
| | | 113 | | |
| | 1 | 114 | | Console.Write('\r'); |
| | 1 | 115 | | Console.Write(line); |
| | 1 | 116 | | } |
| | | 117 | | |
| | | 118 | | /// <summary> |
| | | 119 | | /// Builds the progress bar line with the given detail, percentage, and usable width. It will attempt to fit the lab |
| | | 120 | | /// </summary> |
| | | 121 | | /// <param name="detail">The detail text to display on the right side of the progress bar.</param> |
| | | 122 | | /// <param name="percent">The current percentage (0-100) or -1 if percentage cannot be calculated.</param> |
| | | 123 | | /// <param name="usableWidth">The total usable width of the console for rendering the progress bar line.</param> |
| | | 124 | | /// <returns>The constructed progress bar line that fits within the usable width.</returns> |
| | | 125 | | private string BuildRenderedLine(string detail, int percent, int usableWidth) |
| | | 126 | | { |
| | 2 | 127 | | if (percent < 0) |
| | | 128 | | { |
| | 0 | 129 | | return FitSimpleLineToWidth(_label, detail, usableWidth); |
| | | 130 | | } |
| | | 131 | | |
| | 2 | 132 | | var percentText = $"{percent,3}%"; |
| | 2 | 133 | | var barWidth = ProgressBarWidth; |
| | 2 | 134 | | var line = $"{_label} {BuildBarWithWidth(percent, barWidth)} {percentText} {detail}"; |
| | 2 | 135 | | if (line.Length <= usableWidth) |
| | | 136 | | { |
| | 0 | 137 | | return line; |
| | | 138 | | } |
| | | 139 | | |
| | 2 | 140 | | var reservedWithoutDetail = _label.Length + 1 + 2 + barWidth + 1 + percentText.Length + 1; |
| | 2 | 141 | | var maxDetailLength = usableWidth - reservedWithoutDetail; |
| | 2 | 142 | | if (maxDetailLength < 0) |
| | | 143 | | { |
| | 1 | 144 | | var availableBarWidth = usableWidth - (_label.Length + 1 + 2 + 1 + percentText.Length + 1); |
| | 1 | 145 | | barWidth = Math.Clamp(availableBarWidth, MinimumBarWidth, ProgressBarWidth); |
| | 1 | 146 | | reservedWithoutDetail = _label.Length + 1 + 2 + barWidth + 1 + percentText.Length + 1; |
| | 1 | 147 | | maxDetailLength = usableWidth - reservedWithoutDetail; |
| | | 148 | | } |
| | | 149 | | |
| | 2 | 150 | | var shortenedDetail = TrimProgressText(detail, maxDetailLength); |
| | 2 | 151 | | line = $"{_label} {BuildBarWithWidth(percent, barWidth)} {percentText} {shortenedDetail}".TrimEnd(); |
| | | 152 | | // If the line is still too long, we will trim the detail completely. |
| | 2 | 153 | | return line.Length <= usableWidth ? line : line[..Math.Max(0, usableWidth)]; |
| | | 154 | | } |
| | | 155 | | /// <summary> |
| | | 156 | | /// Fits a simple line with a label and detail to the usable width by trimming the detail if necessary. |
| | | 157 | | /// The label will be preserved as much as possible, and the detail will be trimmed with ellipsis if it cannot fit. |
| | | 158 | | /// If the label itself cannot fit, it will be trimmed without ellipsis. |
| | | 159 | | /// </summary> |
| | | 160 | | /// <param name="label">The label text to display on the left side of the line.</param> |
| | | 161 | | /// <param name="detail">The detail text to display on the right side of the line.</param> |
| | | 162 | | /// <param name="usableWidth">The total usable width of the console for rendering the line.</param> |
| | | 163 | | /// <returns>The constructed line that fits within the usable width.</returns> |
| | | 164 | | private static string FitSimpleLineToWidth(string label, string detail, int usableWidth) |
| | | 165 | | { |
| | 0 | 166 | | var line = $"{label} {detail}".TrimEnd(); |
| | 0 | 167 | | if (line.Length <= usableWidth) |
| | | 168 | | { |
| | 0 | 169 | | return line; |
| | | 170 | | } |
| | | 171 | | |
| | 0 | 172 | | var maxDetailLength = usableWidth - label.Length - 1; |
| | | 173 | | // If we cannot fit any detail, just return the label trimmed to the usable width. |
| | 0 | 174 | | if (maxDetailLength <= 0) |
| | | 175 | | { |
| | 0 | 176 | | return label[..Math.Min(label.Length, Math.Max(0, usableWidth))]; |
| | | 177 | | } |
| | | 178 | | // If detail cannot fit, we will trim it with ellipsis if possible. If even the ellipsis cannot fit, we will jus |
| | 0 | 179 | | return $"{label} {TrimProgressText(detail, maxDetailLength)}".TrimEnd(); |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | /// <summary> |
| | | 183 | | /// Trims the given text to fit within the max length by adding ellipsis if it exceeds the max length. |
| | | 184 | | /// If the max length is too small to fit any text, it will return an empty string or a truncated string without ell |
| | | 185 | | /// </summary> |
| | | 186 | | /// <param name="text">The text to be trimmed.</param> |
| | | 187 | | /// <param name="maxLength">The maximum allowed length for the text.</param> |
| | | 188 | | /// <returns>The trimmed text, possibly with ellipsis.</returns> |
| | | 189 | | private static string TrimProgressText(string text, int maxLength) |
| | | 190 | | { |
| | | 191 | | // If maxLength is zero or negative, we cannot show any text. |
| | 2 | 192 | | if (maxLength <= 0) |
| | | 193 | | { |
| | 1 | 194 | | return string.Empty; |
| | | 195 | | } |
| | | 196 | | // If the text fits within the max length, return it as is. |
| | 1 | 197 | | if (text.Length <= maxLength) |
| | | 198 | | { |
| | 0 | 199 | | return text; |
| | | 200 | | } |
| | | 201 | | // If the text is too long, trim it and add ellipsis. Ensure that the total length does not exceed maxLength. |
| | 1 | 202 | | if (maxLength <= 3) |
| | | 203 | | { |
| | 0 | 204 | | return text[..maxLength]; |
| | | 205 | | } |
| | | 206 | | // Trim the text to fit within maxLength, accounting for the length of the ellipsis. |
| | 1 | 207 | | return $"{text[..(maxLength - 3)]}..."; |
| | | 208 | | } |
| | | 209 | | |
| | | 210 | | private static int GetUsableConsoleWidth() |
| | | 211 | | { |
| | | 212 | | try |
| | | 213 | | { |
| | 2 | 214 | | var width = Console.WindowWidth; |
| | 2 | 215 | | if (width <= 1) |
| | | 216 | | { |
| | 2 | 217 | | width = Console.BufferWidth; |
| | | 218 | | } |
| | | 219 | | |
| | 2 | 220 | | return Math.Max(1, width - 1); |
| | | 221 | | } |
| | 0 | 222 | | catch |
| | | 223 | | { |
| | 0 | 224 | | return 120; |
| | | 225 | | } |
| | 2 | 226 | | } |
| | | 227 | | |
| | | 228 | | /// <summary> |
| | | 229 | | /// Builds a progress bar string with the specified width. The bar is filled proportionally to the given percentage, |
| | | 230 | | /// The total width of the bar is determined by the 'width' parameter, and the filled portion is calculated based on |
| | | 231 | | /// for a 50% completion with a width of 10. |
| | | 232 | | /// </summary> |
| | | 233 | | /// <param name="percent">Progress percentage (0-100).</param> |
| | | 234 | | /// <param name="width">Total width of the progress bar.</param> |
| | | 235 | | /// <returns>Progress bar text enclosed in brackets.</returns> |
| | | 236 | | private static string BuildBarWithWidth(int percent, int width) |
| | | 237 | | { |
| | 7 | 238 | | var normalizedWidth = Math.Max(1, width); |
| | 7 | 239 | | var filled = (int)Math.Round(percent / 100d * normalizedWidth, MidpointRounding.AwayFromZero); |
| | 7 | 240 | | filled = Math.Clamp(filled, 0, normalizedWidth); |
| | 7 | 241 | | return $"[{new string('#', filled)}{new string('-', normalizedWidth - filled)}]"; |
| | | 242 | | } |
| | | 243 | | } |