diff --git a/examples/Console/Progress/Program.cs b/examples/Console/Progress/Program.cs index 6215b9b..05a25ef 100644 --- a/examples/Console/Progress/Program.cs +++ b/examples/Console/Progress/Program.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Threading; using Spectre.Console; diff --git a/src/Spectre.Console.Tests/Unit/Progress/DownloadedColumnTests.cs b/src/Spectre.Console.Tests/Unit/Progress/DownloadedColumnTests.cs new file mode 100644 index 0000000..bfcc38c --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/Progress/DownloadedColumnTests.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using Shouldly; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + public sealed class DownloadedColumnTests + { + [Theory] + [InlineData(0, 1, "0/1 byte")] + [InlineData(37, 101, "37/101 bytes")] + [InlineData(101, 101, "101 bytes")] + [InlineData(512, 1024, "0.5/1.0 KB")] + [InlineData(1024, 1024, "1.0 KB")] + [InlineData(1024 * 512, 5 * 1024 * 1024, "0.5/5.0 MB")] + [InlineData(5 * 1024 * 1024, 5 * 1024 * 1024, "5.0 MB")] + public void Should_Return_Correct_Value(double value, double total, string expected) + { + // Given + var fixture = new ProgressColumnFixture(value, total); + fixture.Column.Culture = CultureInfo.InvariantCulture; + + // When + var result = fixture.Render(); + + // Then + result.ShouldBe(expected); + } + } +} diff --git a/src/Spectre.Console.Tests/Unit/Progress/ProgressColumnFixture.cs b/src/Spectre.Console.Tests/Unit/Progress/ProgressColumnFixture.cs new file mode 100644 index 0000000..5609628 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/Progress/ProgressColumnFixture.cs @@ -0,0 +1,29 @@ +using System; +using System.Text; +using Spectre.Console.Rendering; +using Spectre.Console.Testing; + +namespace Spectre.Console.Tests.Unit +{ + public sealed class ProgressColumnFixture + where T : ProgressColumn, new() + { + public T Column { get; } + public ProgressTask Task { get; set; } + + public ProgressColumnFixture(double completed, double total) + { + Column = new T(); + Task = new ProgressTask(1, "Foo", total); + Task.Increment(completed); + } + + public string Render() + { + var console = new FakeConsole(); + var context = new RenderContext(Encoding.UTF8, false); + console.Render(Column.Render(context, Task, TimeSpan.Zero)); + return console.Output; + } + } +} diff --git a/src/Spectre.Console.Tests/Unit/ProgressTests.cs b/src/Spectre.Console.Tests/Unit/Progress/ProgressTests.cs similarity index 100% rename from src/Spectre.Console.Tests/Unit/ProgressTests.cs rename to src/Spectre.Console.Tests/Unit/Progress/ProgressTests.cs diff --git a/src/Spectre.Console/Extensions/Progress/SpinnerColumnExtensions.cs b/src/Spectre.Console/Extensions/Progress/SpinnerColumnExtensions.cs index 2bd00e7..291c438 100644 --- a/src/Spectre.Console/Extensions/Progress/SpinnerColumnExtensions.cs +++ b/src/Spectre.Console/Extensions/Progress/SpinnerColumnExtensions.cs @@ -23,5 +23,40 @@ namespace Spectre.Console column.Style = style; return column; } + + /// + /// Sets the text that should be shown instead of the spinner + /// once a task completes. + /// + /// The column. + /// The text. + /// The same instance so that multiple calls can be chained. + public static SpinnerColumn CompletedText(this SpinnerColumn column, string? text) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + column.CompletedText = text; + return column; + } + + /// + /// Sets the completed style of the spinner. + /// + /// The column. + /// The style. + /// The same instance so that multiple calls can be chained. + public static SpinnerColumn CompletedStyle(this SpinnerColumn column, Style? style) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + column.CompletedStyle = style; + return column; + } } } diff --git a/src/Spectre.Console/Internal/FileSize.cs b/src/Spectre.Console/Internal/FileSize.cs new file mode 100644 index 0000000..9bebaf7 --- /dev/null +++ b/src/Spectre.Console/Internal/FileSize.cs @@ -0,0 +1,89 @@ +using System; +using System.Globalization; + +namespace Spectre.Console.Internal +{ + internal struct FileSize + { + public double Bytes { get; } + public FileSizeUnit Unit { get; } + public string Suffix => GetSuffix(); + + public FileSize(double bytes) + { + Bytes = bytes; + Unit = Detect(bytes); + } + + public FileSize(double bytes, FileSizeUnit unit) + { + Bytes = bytes; + Unit = unit; + } + + public string Format(CultureInfo? culture = null) + { + var @base = GetBase(Unit); + if (@base == 0) + { + @base = 1; + } + + var bytes = Bytes / @base; + + return Unit == FileSizeUnit.Bytes + ? ((int)bytes).ToString(culture ?? CultureInfo.InvariantCulture) + : bytes.ToString("F1", culture ?? CultureInfo.InvariantCulture); + } + + public override string ToString() + { + return ToString(suffix: true, CultureInfo.InvariantCulture); + } + + public string ToString(bool suffix = true, CultureInfo? culture = null) + { + if (suffix) + { + return $"{Format(culture)} {Suffix}"; + } + + return Format(culture); + } + + private string GetSuffix() + { + return (Bytes, Unit) switch + { + (_, FileSizeUnit.KiloByte) => "KB", + (_, FileSizeUnit.MegaByte) => "MB", + (_, FileSizeUnit.GigaByte) => "GB", + (_, FileSizeUnit.TeraByte) => "TB", + (_, FileSizeUnit.PetaByte) => "PB", + (_, FileSizeUnit.ExaByte) => "EB", + (_, FileSizeUnit.ZettaByte) => "ZB", + (_, FileSizeUnit.YottaByte) => "YB", + (1, _) => "byte", + (_, _) => "bytes", + }; + } + + private static FileSizeUnit Detect(double bytes) + { + foreach (var unit in (FileSizeUnit[])Enum.GetValues(typeof(FileSizeUnit))) + { + if (bytes < (GetBase(unit) * 1024)) + { + return unit; + } + } + + return FileSizeUnit.Bytes; + } + + private static double GetBase(FileSizeUnit unit) + { + return Math.Pow(1024, (int)unit); + } + } +} diff --git a/src/Spectre.Console/Internal/FileSizeUnit.cs b/src/Spectre.Console/Internal/FileSizeUnit.cs new file mode 100644 index 0000000..17f522f --- /dev/null +++ b/src/Spectre.Console/Internal/FileSizeUnit.cs @@ -0,0 +1,17 @@ +using System; + +namespace Spectre.Console.Internal +{ + internal enum FileSizeUnit + { + Bytes = 0, + KiloByte = 1, + MegaByte = 2, + GigaByte = 3, + TeraByte = 4, + PetaByte = 5, + ExaByte = 6, + ZettaByte = 7, + YottaByte = 8, + } +} diff --git a/src/Spectre.Console/Widgets/Progress/Columns/DownloadedColumn.cs b/src/Spectre.Console/Widgets/Progress/Columns/DownloadedColumn.cs new file mode 100644 index 0000000..a4c8690 --- /dev/null +++ b/src/Spectre.Console/Widgets/Progress/Columns/DownloadedColumn.cs @@ -0,0 +1,42 @@ +using System; +using System.Globalization; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A column showing download progress. + /// + public sealed class DownloadedColumn : ProgressColumn + { + /// + /// Gets or sets the to use. + /// + public CultureInfo? Culture { get; set; } + + /// + public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime) + { + var total = new FileSize(task.MaxValue); + + if (task.IsFinished) + { + return new Markup(string.Format( + "[green]{0} {1}[/]", + total.Format(Culture), + total.Suffix)); + } + else + { + var downloaded = new FileSize(task.Value, total.Unit); + + return new Markup(string.Format( + "{0}[grey]/[/]{1} [grey]{2}[/]", + downloaded.Format(Culture), + total.Format(Culture), + total.Suffix)); + } + } + } +} diff --git a/src/Spectre.Console/Widgets/Progress/Columns/ElapsedTimeColumn.cs b/src/Spectre.Console/Widgets/Progress/Columns/ElapsedTimeColumn.cs new file mode 100644 index 0000000..5a28d82 --- /dev/null +++ b/src/Spectre.Console/Widgets/Progress/Columns/ElapsedTimeColumn.cs @@ -0,0 +1,37 @@ +using System; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A column showing the elapsed time of a task. + /// + public sealed class ElapsedTimeColumn : ProgressColumn + { + /// + protected internal override bool NoWrap => true; + + /// + /// Gets or sets the style of the remaining time text. + /// + public Style Style { get; set; } = new Style(foreground: Color.Blue); + + /// + public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime) + { + var elapsed = task.ElapsedTime; + if (elapsed == null) + { + return new Markup("-:--:--"); + } + + return new Text($"{elapsed.Value:h\\:mm\\:ss}", Style ?? Style.Plain); + } + + /// + public override int? GetColumnWidth(RenderContext context) + { + return 7; + } + } +} diff --git a/src/Spectre.Console/Widgets/Progress/Columns/SpinnerColumn.cs b/src/Spectre.Console/Widgets/Progress/Columns/SpinnerColumn.cs index 480c3af..00b16dd 100644 --- a/src/Spectre.Console/Widgets/Progress/Columns/SpinnerColumn.cs +++ b/src/Spectre.Console/Widgets/Progress/Columns/SpinnerColumn.cs @@ -16,6 +16,7 @@ namespace Spectre.Console private readonly object _lock; private Spinner _spinner; private int? _maxWidth; + private string? _completed; /// protected internal override bool NoWrap => true; @@ -36,6 +37,25 @@ namespace Spectre.Console } } + /// + /// Gets or sets the text that should be shown instead + /// of the spinner once a task completes. + /// + public string? CompletedText + { + get => _completed; + set + { + _completed = value; + _maxWidth = null; + } + } + + /// + /// Gets or sets the completed style. + /// + public Style? CompletedStyle { get; set; } + /// /// Gets or sets the style of the spinner. /// @@ -67,7 +87,7 @@ namespace Spectre.Console if (!task.IsStarted || task.IsFinished) { - return new Markup(new string(' ', GetMaxWidth(context))); + return new Markup(CompletedText ?? " ", CompletedStyle ?? Style.Plain); } var accumulated = task.State.Update(ACCUMULATED, acc => acc + deltaTime.TotalMilliseconds); @@ -97,7 +117,9 @@ namespace Spectre.Console var useAscii = (context.LegacyConsole || !context.Unicode) && _spinner.IsUnicode; var spinner = useAscii ? Spinner.Known.Ascii : _spinner ?? Spinner.Known.Default; - _maxWidth = spinner.Frames.Max(frame => Cell.GetCellLength(context, frame)); + _maxWidth = Math.Max( + ((IRenderable)new Markup(CompletedText ?? " ")).Measure(context, int.MaxValue).Max, + spinner.Frames.Max(frame => Cell.GetCellLength(context, frame))); } return _maxWidth.Value; diff --git a/src/Spectre.Console/Widgets/Progress/Columns/TransferSpeedColumn.cs b/src/Spectre.Console/Widgets/Progress/Columns/TransferSpeedColumn.cs new file mode 100644 index 0000000..ed08f4d --- /dev/null +++ b/src/Spectre.Console/Widgets/Progress/Columns/TransferSpeedColumn.cs @@ -0,0 +1,30 @@ +using System; +using System.Globalization; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A column showing transfer speed. + /// + public sealed class TransferSpeedColumn : ProgressColumn + { + /// + /// Gets or sets the to use. + /// + public CultureInfo? Culture { get; set; } + + /// + public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime) + { + if (task.Speed == null) + { + return new Text("?/s"); + } + + var size = new FileSize(task.Speed.Value); + return new Markup(string.Format("{0}/s", size.ToString(suffix: true, Culture))); + } + } +} diff --git a/src/Spectre.Console/Widgets/Progress/ProgressTask.cs b/src/Spectre.Console/Widgets/Progress/ProgressTask.cs index 1aec5ec..9f7fd69 100644 --- a/src/Spectre.Console/Widgets/Progress/ProgressTask.cs +++ b/src/Spectre.Console/Widgets/Progress/ProgressTask.cs @@ -89,7 +89,14 @@ namespace Spectre.Console /// public TimeSpan? RemainingTime => GetRemainingTime(); - internal ProgressTask(int id, string description, double maxValue, bool autoStart) + /// + /// Initializes a new instance of the class. + /// + /// The task ID. + /// The task description. + /// The task max value. + /// Whether or not the task should start automatically. + public ProgressTask(int id, string description, double maxValue, bool autoStart = true) { _samples = new List(); _lock = new object();