diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c67b41a..d116ede 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -69,14 +69,7 @@ jobs: shell: bash run: | dotnet tool restore - dotnet example info - dotnet example tables - dotnet example grids - dotnet example panels - dotnet example colors - dotnet example emojis - dotnet example exceptions - dotnet example calendars + dotnet example --all - name: Build shell: bash diff --git a/docs/input/assets/images/progress.gif b/docs/input/assets/images/progress.gif new file mode 100644 index 0000000..7ec3868 Binary files /dev/null and b/docs/input/assets/images/progress.gif differ diff --git a/docs/input/assets/images/progress.png b/docs/input/assets/images/progress.png new file mode 100644 index 0000000..dd331ed Binary files /dev/null and b/docs/input/assets/images/progress.png differ diff --git a/docs/input/assets/images/progress_fallback.png b/docs/input/assets/images/progress_fallback.png new file mode 100644 index 0000000..cab074d Binary files /dev/null and b/docs/input/assets/images/progress_fallback.png differ diff --git a/docs/input/progress.md b/docs/input/progress.md new file mode 100644 index 0000000..ccfbbec --- /dev/null +++ b/docs/input/progress.md @@ -0,0 +1,78 @@ +Title: Progress +Order: 5 +--- + +Spectre.Console can display information about long running tasks in the console. + + + +If the current terminal isn't considered "interactive", such as when running +in a continuous integration system, or the terminal can't display +ANSI control sequence, any progress will be displayed in a simpler way. + + + +# Usage + +```csharp +// Synchronous +AnsiConsole.Progress() + .Start(ctx => + { + // Define tasks + var task1 = ctx.AddTask("[green]Reticulating splines[/]"); + var task2 = ctx.AddTask("[green]Folding space[/]"); + + while(!ctx.IsFinished) + { + task1.Increment(1.5); + task2.Increment(0.5); + } + }); +``` + +## Asynchronous progress + +If you prefer to use async/await, you can use `StartAsync` instead of `Start`. + +```csharp +// Asynchronous +await AnsiConsole.Progress() + .StartAsync(async ctx => + { + // Define tasks + var task1 = ctx.AddTask("[green]Reticulating splines[/]"); + var task2 = ctx.AddTask("[green]Folding space[/]"); + + while (!ctx.IsFinished) + { + // Simulate some work + await Task.Delay(250); + + // Increment + task1.Increment(1.5); + task2.Increment(0.5); + } + }); +``` + +# Configure + +```csharp +// Asynchronous +AnsiConsole.Progress() + .AutoRefresh(false) // Turn off auto refresh + .AutoClear(false) // Do not remove the task list when done + .Columns(new ProgressColumn[] + { + new TaskDescriptionColumn(), // Task description + new ProgressBarColumn(), // Progress bar + new PercentageColumn(), // Percentage + new RemainingTimeColumn(), // Remaining time + new SpinnerColumn(), // Spinner + }) + .Start(ctx => + { + // Omitted + }); +``` \ No newline at end of file diff --git a/examples/Info/Program.cs b/examples/Info/Program.cs index 4aa82d4..0c2f624 100644 --- a/examples/Info/Program.cs +++ b/examples/Info/Program.cs @@ -1,4 +1,3 @@ -using System; using Spectre.Console; namespace InfoExample @@ -13,7 +12,7 @@ namespace InfoExample .AddRow("[b]Color system[/]", $"{AnsiConsole.Capabilities.ColorSystem}") .AddRow("[b]Supports ansi?[/]", $"{YesNo(AnsiConsole.Capabilities.SupportsAnsi)}") .AddRow("[b]Legacy console?[/]", $"{YesNo(AnsiConsole.Capabilities.LegacyConsole)}") - .AddRow("[b]Interactive?[/]", $"{YesNo(Environment.UserInteractive)}") + .AddRow("[b]Interactive?[/]", $"{YesNo(AnsiConsole.Capabilities.SupportsInteraction)}") .AddRow("[b]Buffer width[/]", $"{AnsiConsole.Console.Width}") .AddRow("[b]Buffer height[/]", $"{AnsiConsole.Console.Height}"); diff --git a/examples/Progress/DescriptionGenerator.cs b/examples/Progress/DescriptionGenerator.cs new file mode 100644 index 0000000..a4f7861 --- /dev/null +++ b/examples/Progress/DescriptionGenerator.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace ProgressExample +{ + public static class DescriptionGenerator + { + private static readonly string[] _verbs = new[] { "Downloading", "Rerouting", "Retriculating", "Collapsing", "Folding", "Solving", "Colliding", "Measuring" }; + private static readonly string[] _nouns = new[] { "internet", "splines", "space", "capacitators", "quarks", "algorithms", "data structures", "spacetime" }; + + private static readonly Random _random; + private static readonly HashSet _used; + + static DescriptionGenerator() + { + _random = new Random(DateTime.Now.Millisecond); + _used = new HashSet(); + } + + public static bool TryGenerate(out string name) + { + var iterations = 0; + while (iterations < 25) + { + name = Generate(); + if (!_used.Contains(name)) + { + _used.Add(name); + return true; + } + + iterations++; + } + + name = Generate(); + return false; + } + + public static string Generate() + { + return _verbs[_random.Next(0, _verbs.Length)] + + " " + _nouns[_random.Next(0, _nouns.Length)]; + } + } +} diff --git a/examples/Progress/Program.cs b/examples/Progress/Program.cs new file mode 100644 index 0000000..6215b9b --- /dev/null +++ b/examples/Progress/Program.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Spectre.Console; + +namespace ProgressExample +{ + public static class Program + { + public static void Main() + { + AnsiConsole.MarkupLine("[yellow]Initializing warp drive[/]..."); + + // Show progress + AnsiConsole.Progress() + .AutoClear(false) + .Columns(new ProgressColumn[] + { + new TaskDescriptionColumn(), // Task description + new ProgressBarColumn(), // Progress bar + new PercentageColumn(), // Percentage + new RemainingTimeColumn(), // Remaining time + new SpinnerColumn(), // Spinner + }) + .Start(ctx => + { + var random = new Random(DateTime.Now.Millisecond); + var tasks = CreateTasks(ctx, random); + + while (!ctx.IsFinished) + { + // Increment progress + foreach (var (task, increment) in tasks) + { + task.Increment(random.NextDouble() * increment); + } + + // Write some random things to the terminal + if (random.NextDouble() < 0.1) + { + WriteLogMessage(); + } + + // Simulate some delay + Thread.Sleep(100); + } + }); + + // Done + AnsiConsole.MarkupLine("[green]Done![/]"); + } + + private static List<(ProgressTask, int)> CreateTasks(ProgressContext progress, Random random) + { + var tasks = new List<(ProgressTask, int)>(); + while (tasks.Count < 5) + { + if (DescriptionGenerator.TryGenerate(out var name)) + { + tasks.Add((progress.AddTask(name), random.Next(2, 10))); + } + } + + return tasks; + } + + private static void WriteLogMessage() + { + AnsiConsole.MarkupLine( + "[grey]LOG:[/] " + + DescriptionGenerator.Generate() + + "[grey]...[/]"); + } + } +} diff --git a/examples/Progress/Progress.csproj b/examples/Progress/Progress.csproj new file mode 100644 index 0000000..f84c230 --- /dev/null +++ b/examples/Progress/Progress.csproj @@ -0,0 +1,19 @@ + + + + Exe + net5.0 + false + Progress + Demonstrates how to show progress bars. + + + + + + + + + + + diff --git a/examples/Prompt/Program.cs b/examples/Prompt/Program.cs index 4cb42d8..86728e5 100644 --- a/examples/Prompt/Program.cs +++ b/examples/Prompt/Program.cs @@ -6,6 +6,13 @@ namespace Cursor { public static void Main(string[] args) { + // Check if we can accept key strokes + if (!AnsiConsole.Capabilities.SupportsInteraction) + { + AnsiConsole.MarkupLine("[red]Environment does not support interaction.[/]"); + return; + } + // Confirmation if (!AnsiConsole.Confirm("Run prompt example?")) { diff --git a/src/.editorconfig b/src/.editorconfig index 449cd6c..54af1cb 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -89,4 +89,7 @@ dotnet_diagnostic.RCS1227.severity = none dotnet_diagnostic.IDE0004.severity = warning # CA1810: Initialize reference type static fields inline -dotnet_diagnostic.CA1810.severity = none \ No newline at end of file +dotnet_diagnostic.CA1810.severity = none + +# IDE0044: Add readonly modifier +dotnet_diagnostic.IDE0044.severity = warning \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Expectations/ProgressBarTests.Should_Render_Progress_Bar.verified.txt b/src/Spectre.Console.Tests/Expectations/ProgressBarTests.Should_Render_Progress_Bar.verified.txt new file mode 100644 index 0000000..d9aadad --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/ProgressBarTests.Should_Render_Progress_Bar.verified.txt @@ -0,0 +1 @@ +━━━━━━━━━━━━━━━━━━━━ \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Tools/PlainConsole.cs b/src/Spectre.Console.Tests/Tools/PlainConsole.cs index f076db4..12b7c84 100644 --- a/src/Spectre.Console.Tests/Tools/PlainConsole.cs +++ b/src/Spectre.Console.Tests/Tools/PlainConsole.cs @@ -18,6 +18,7 @@ namespace Spectre.Console.Tests public int Height { get; } IAnsiConsoleInput IAnsiConsole.Input => Input; + public RenderPipeline Pipeline { get; } public Decoration Decoration { get; set; } public Color Foreground { get; set; } @@ -31,14 +32,15 @@ namespace Spectre.Console.Tests public PlainConsole( int width = 80, int height = 9000, Encoding encoding = null, bool supportsAnsi = true, ColorSystem colorSystem = ColorSystem.Standard, - bool legacyConsole = false) + bool legacyConsole = false, bool interactive = true) { - Capabilities = new Capabilities(supportsAnsi, colorSystem, legacyConsole); + Capabilities = new Capabilities(supportsAnsi, colorSystem, legacyConsole, interactive); Encoding = encoding ?? Encoding.UTF8; Width = width; Height = height; Writer = new StringWriter(); Input = new TestableConsoleInput(); + Pipeline = new RenderPipeline(); } public void Dispose() @@ -50,14 +52,17 @@ namespace Spectre.Console.Tests { } - public void Write(Segment segment) + public void Write(IEnumerable segments) { - if (segment is null) + if (segments is null) { - throw new ArgumentNullException(nameof(segment)); + return; } - Writer.Write(segment.Text); + foreach (var segment in segments) + { + Writer.Write(segment.Text); + } } public string WriteNormalizedException(Exception ex, ExceptionFormats formats = ExceptionFormats.Default) diff --git a/src/Spectre.Console.Tests/Tools/TestableAnsiConsole.cs b/src/Spectre.Console.Tests/Tools/TestableAnsiConsole.cs index ba986e5..0cd8122 100644 --- a/src/Spectre.Console.Tests/Tools/TestableAnsiConsole.cs +++ b/src/Spectre.Console.Tests/Tools/TestableAnsiConsole.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text; using Spectre.Console.Rendering; @@ -19,16 +20,21 @@ namespace Spectre.Console.Tests public int Height => _console.Height; public IAnsiConsoleCursor Cursor => _console.Cursor; public TestableConsoleInput Input { get; } + public RenderPipeline Pipeline => _console.Pipeline; IAnsiConsoleInput IAnsiConsole.Input => Input; - public TestableAnsiConsole(ColorSystem system, AnsiSupport ansi = AnsiSupport.Yes, int width = 80) + public TestableAnsiConsole( + ColorSystem system, AnsiSupport ansi = AnsiSupport.Yes, + InteractionSupport interaction = InteractionSupport.Yes, + int width = 80) { _writer = new StringWriter(); _console = AnsiConsole.Create(new AnsiConsoleSettings { Ansi = ansi, ColorSystem = (ColorSystemSupport)system, + Interactive = interaction, Out = _writer, LinkIdentityGenerator = new TestLinkIdentityGenerator(), }); @@ -47,9 +53,17 @@ namespace Spectre.Console.Tests _console.Clear(home); } - public void Write(Segment segment) + public void Write(IEnumerable segments) { - _console.Write(segment); + if (segments is null) + { + return; + } + + foreach (var segment in segments) + { + _console.Write(segment); + } } } } diff --git a/src/Spectre.Console.Tests/Unit/ProgressTests.cs b/src/Spectre.Console.Tests/Unit/ProgressTests.cs new file mode 100644 index 0000000..ac330c2 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/ProgressTests.cs @@ -0,0 +1,58 @@ +using Shouldly; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + public sealed class ProgressTests + { + [Fact] + public void Should_Render_Task_Correctly() + { + // Given + var console = new TestableAnsiConsole(ColorSystem.TrueColor, width: 10); + + var progress = new Progress(console) + .Columns(new[] { new ProgressBarColumn() }) + .AutoRefresh(false) + .AutoClear(true); + + // When + progress.Start(ctx => ctx.AddTask("foo")); + + // Then + console.Output + .NormalizeLineEndings() + .ShouldBe( + "[?25l" + // Hide cursor + " \n" + // Top padding + "━━━━━━━━━━\n" + // Task + " " + // Bottom padding + "[?25h"); // Clear + show cursor + } + + [Fact] + public void Should_Not_Auto_Clear_If_Specified() + { + // Given + var console = new TestableAnsiConsole(ColorSystem.TrueColor, width: 10); + + var progress = new Progress(console) + .Columns(new[] { new ProgressBarColumn() }) + .AutoRefresh(false) + .AutoClear(false); + + // When + progress.Start(ctx => ctx.AddTask("foo")); + + // Then + console.Output + .NormalizeLineEndings() + .ShouldBe( + "[?25l" + // Hide cursor + " \n" + // Top padding + "━━━━━━━━━━\n" + // Task + " \n" + // Bottom padding + "[?25h"); // show cursor + } + } +} diff --git a/src/Spectre.Console.Tests/Unit/RenderHookTests.cs b/src/Spectre.Console.Tests/Unit/RenderHookTests.cs new file mode 100644 index 0000000..eeee8ec --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/RenderHookTests.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using Shouldly; +using Spectre.Console.Rendering; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + public sealed class RenderHookTests + { + private sealed class HelloRenderHook : IRenderHook + { + public IEnumerable Process(RenderContext context, IEnumerable renderables) + { + return new IRenderable[] { new Text("Hello\n") }.Concat(renderables); + } + } + + [Fact] + public void Should_Inject_Renderable_Before_Writing_To_Console() + { + // Given + var console = new PlainConsole(); + console.Pipeline.Attach(new HelloRenderHook()); + + // When + console.Render(new Text("World")); + + // Then + console.Lines[0].ShouldBe("Hello"); + console.Lines[1].ShouldBe("World"); + } + } +} diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index a1af2ed..c4ef646 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -58,6 +58,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Canvas", "..\examples\Canva EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.ImageSharp", "Spectre.Console.ImageSharp\Spectre.Console.ImageSharp.csproj", "{0EFE694D-0770-4E71-BF4E-EC2B41362F79}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Progress", "..\examples\Progress\Progress.csproj", "{2B712A52-40F1-4C1C-833E-7C869ACA91F3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -296,6 +298,18 @@ Global {0EFE694D-0770-4E71-BF4E-EC2B41362F79}.Release|x64.Build.0 = Release|Any CPU {0EFE694D-0770-4E71-BF4E-EC2B41362F79}.Release|x86.ActiveCfg = Release|Any CPU {0EFE694D-0770-4E71-BF4E-EC2B41362F79}.Release|x86.Build.0 = Release|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Debug|x64.ActiveCfg = Debug|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Debug|x64.Build.0 = Debug|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Debug|x86.ActiveCfg = Debug|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Debug|x86.Build.0 = Debug|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|Any CPU.Build.0 = Release|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|x64.ActiveCfg = Release|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|x64.Build.0 = Release|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|x86.ActiveCfg = Release|Any CPU + {2B712A52-40F1-4C1C-833E-7C869ACA91F3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -318,6 +332,7 @@ Global {6351C70F-F368-46DB-BAED-9B87CCD69353} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {45BF6302-6553-4E52-BF0F-B10D1AA9A6D1} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {5693761A-754A-40A8-9144-36510D6A4D69} = {F0575243-121F-4DEE-9F6B-246E26DC0844} + {2B712A52-40F1-4C1C-833E-7C869ACA91F3} = {F0575243-121F-4DEE-9F6B-246E26DC0844} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C} diff --git a/src/Spectre.Console/AnsiConsole.Progress.cs b/src/Spectre.Console/AnsiConsole.Progress.cs new file mode 100644 index 0000000..835fcec --- /dev/null +++ b/src/Spectre.Console/AnsiConsole.Progress.cs @@ -0,0 +1,17 @@ +namespace Spectre.Console +{ + /// + /// A console capable of writing ANSI escape sequences. + /// + public static partial class AnsiConsole + { + /// + /// Creates a new instance. + /// + /// A instance. + public static Progress Progress() + { + return Console.Progress(); + } + } +} diff --git a/src/Spectre.Console/AnsiConsoleSettings.cs b/src/Spectre.Console/AnsiConsoleSettings.cs index 4d3dc37..83db057 100644 --- a/src/Spectre.Console/AnsiConsoleSettings.cs +++ b/src/Spectre.Console/AnsiConsoleSettings.cs @@ -18,6 +18,12 @@ namespace Spectre.Console /// public ColorSystemSupport ColorSystem { get; set; } + /// + /// Gets or sets a value indicating whether or + /// not the console is interactive. + /// + public InteractionSupport Interactive { get; set; } + /// /// Gets or sets the link identity generator. /// diff --git a/src/Spectre.Console/Capabilities.cs b/src/Spectre.Console/Capabilities.cs index 284999c..ef4c9a6 100644 --- a/src/Spectre.Console/Capabilities.cs +++ b/src/Spectre.Console/Capabilities.cs @@ -36,17 +36,24 @@ namespace Spectre.Console /// public bool LegacyConsole { get; } + /// + /// Gets or sets a value indicating whether or not the console supports interaction. + /// + public bool SupportsInteraction { get; set; } + /// /// Initializes a new instance of the class. /// /// Whether or not ANSI escape sequences are supported. /// The color system that is supported. /// Whether or not this is a legacy console. - public Capabilities(bool supportsAnsi, ColorSystem colorSystem, bool legacyConsole) + /// Whether or not the console supports interaction. + public Capabilities(bool supportsAnsi, ColorSystem colorSystem, bool legacyConsole, bool supportsInteraction) { SupportsAnsi = supportsAnsi; ColorSystem = colorSystem; LegacyConsole = legacyConsole; + SupportsInteraction = supportsInteraction; } /// diff --git a/src/Spectre.Console/ColorSystemSupport.cs b/src/Spectre.Console/ColorSystemSupport.cs index cdff656..37f4aa5 100644 --- a/src/Spectre.Console/ColorSystemSupport.cs +++ b/src/Spectre.Console/ColorSystemSupport.cs @@ -8,31 +8,31 @@ namespace Spectre.Console /// /// Try to detect the color system. /// - Detect = -1, + Detect = 0, /// /// No colors. /// - NoColors = 0, + NoColors = 1, /// /// Legacy, 3-bit mode. /// - Legacy = 1, + Legacy = 2, /// /// Standard, 4-bit mode. /// - Standard = 2, + Standard = 3, /// /// 8-bit mode. /// - EightBit = 3, + EightBit = 4, /// /// 24-bit mode. /// - TrueColor = 4, + TrueColor = 5, } } diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Markup.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Markup.cs index 0a9ceb1..45ea97e 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Markup.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Markup.cs @@ -52,8 +52,7 @@ namespace Spectre.Console /// An array of objects to write. public static void MarkupLine(this IAnsiConsole console, IFormatProvider provider, string format, params object[] args) { - Markup(console, provider, format, args); - console.WriteLine(); + Markup(console, provider, format + Environment.NewLine, args); } } } diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Progress.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Progress.cs new file mode 100644 index 0000000..69a82dc --- /dev/null +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Progress.cs @@ -0,0 +1,25 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static partial class AnsiConsoleExtensions + { + /// + /// Creates a new instance for the console. + /// + /// The console. + /// A instance. + public static Progress Progress(this IAnsiConsole console) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + return new Progress(console); + } + } +} diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs index 72d92b4..1d2f35a 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs @@ -1,5 +1,5 @@ using System; -using System.Linq; +using System.Collections.Generic; using Spectre.Console.Rendering; namespace Spectre.Console @@ -26,19 +26,26 @@ namespace Spectre.Console throw new ArgumentNullException(nameof(renderable)); } - var options = new RenderContext(console.Encoding, console.Capabilities.LegacyConsole); - var segments = renderable.Render(options, console.Width).ToArray(); - segments = Segment.Merge(segments).ToArray(); + var context = new RenderContext(console.Encoding, console.Capabilities.LegacyConsole); + var renderables = console.Pipeline.Process(context, new[] { renderable }); - foreach (var segment in segments) + Render(console, context, renderables); + } + + private static void Render(IAnsiConsole console, RenderContext options, IEnumerable renderables) + { + if (renderables is null) { - if (string.IsNullOrEmpty(segment.Text)) - { - continue; - } - - console.Write(segment.Text, segment.Style); + return; } + + var result = new List(); + foreach (var renderable in renderables) + { + result.AddRange(renderable.Render(options, console.Width)); + } + + console.Write(Segment.Merge(result)); } } } diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs index aac10f7..acbf72f 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs @@ -18,6 +18,26 @@ namespace Spectre.Console return new Recorder(console); } + /// + /// Writes the specified string value to the console. + /// + /// The console to write to. + /// The segment to write. + public static void Write(this IAnsiConsole console, Segment segment) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + if (segment is null) + { + throw new ArgumentNullException(nameof(segment)); + } + + console.Write(new[] { segment }); + } + /// /// Writes the specified string value to the console. /// @@ -25,7 +45,7 @@ namespace Spectre.Console /// The text to write. public static void Write(this IAnsiConsole console, string text) { - Write(console, text, Style.Plain); + Render(console, new Text(text, Style.Plain)); } /// @@ -36,17 +56,7 @@ namespace Spectre.Console /// The text style. public static void Write(this IAnsiConsole console, string text, Style style) { - if (console is null) - { - throw new ArgumentNullException(nameof(console)); - } - - if (text is null) - { - throw new ArgumentNullException(nameof(text)); - } - - console.Write(new Segment(text, style)); + Render(console, new Text(text, style)); } /// @@ -60,7 +70,7 @@ namespace Spectre.Console throw new ArgumentNullException(nameof(console)); } - console.Write(Environment.NewLine, Style.Plain); + Render(console, new Text(Environment.NewLine, Style.Plain)); } /// @@ -91,8 +101,7 @@ namespace Spectre.Console throw new ArgumentNullException(nameof(text)); } - console.Write(new Segment(text, style)); - console.WriteLine(); + console.Write(text + Environment.NewLine, style); } } } diff --git a/src/Spectre.Console/Extensions/Columns/PercentageColumnExtensions.cs b/src/Spectre.Console/Extensions/Columns/PercentageColumnExtensions.cs new file mode 100644 index 0000000..9d68752 --- /dev/null +++ b/src/Spectre.Console/Extensions/Columns/PercentageColumnExtensions.cs @@ -0,0 +1,54 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class PercentageColumnExtensions + { + /// + /// Sets the style for a non-complete task. + /// + /// The column. + /// The style. + /// The same instance so that multiple calls can be chained. + public static PercentageColumn Style(this PercentageColumn column, Style style) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + if (style is null) + { + throw new ArgumentNullException(nameof(style)); + } + + column.Style = style; + return column; + } + + /// + /// Sets the style for a completed task. + /// + /// The column. + /// The style. + /// The same instance so that multiple calls can be chained. + public static PercentageColumn CompletedStyle(this PercentageColumn column, Style style) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + if (style is null) + { + throw new ArgumentNullException(nameof(style)); + } + + column.CompletedStyle = style; + return column; + } + } +} diff --git a/src/Spectre.Console/Extensions/Columns/ProgressBarColumnExtensions.cs b/src/Spectre.Console/Extensions/Columns/ProgressBarColumnExtensions.cs new file mode 100644 index 0000000..a58eb0f --- /dev/null +++ b/src/Spectre.Console/Extensions/Columns/ProgressBarColumnExtensions.cs @@ -0,0 +1,76 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class ProgressBarColumnExtensions + { + /// + /// Sets the style of completed portions of the progress bar. + /// + /// The column. + /// The style. + /// The same instance so that multiple calls can be chained. + public static ProgressBarColumn CompletedStyle(this ProgressBarColumn column, Style style) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + if (style is null) + { + throw new ArgumentNullException(nameof(style)); + } + + column.CompletedStyle = style; + return column; + } + + /// + /// Sets the style of a finished progress bar. + /// + /// The column. + /// The style. + /// The same instance so that multiple calls can be chained. + public static ProgressBarColumn FinishedStyle(this ProgressBarColumn column, Style style) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + if (style is null) + { + throw new ArgumentNullException(nameof(style)); + } + + column.FinishedStyle = style; + return column; + } + + /// + /// Sets the style of remaining portions of the progress bar. + /// + /// The column. + /// The style. + /// The same instance so that multiple calls can be chained. + public static ProgressBarColumn RemainingStyle(this ProgressBarColumn column, Style style) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + if (style is null) + { + throw new ArgumentNullException(nameof(style)); + } + + column.RemainingStyle = style; + return column; + } + } +} diff --git a/src/Spectre.Console/Extensions/Columns/RemainingTimeColumnExtensions.cs b/src/Spectre.Console/Extensions/Columns/RemainingTimeColumnExtensions.cs new file mode 100644 index 0000000..a33a31b --- /dev/null +++ b/src/Spectre.Console/Extensions/Columns/RemainingTimeColumnExtensions.cs @@ -0,0 +1,32 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class RemainingTimeColumnExtensions + { + /// + /// Sets the style of the remaining time text. + /// + /// The column. + /// The style. + /// The same instance so that multiple calls can be chained. + public static RemainingTimeColumn Style(this RemainingTimeColumn column, Style style) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + if (style is null) + { + throw new ArgumentNullException(nameof(style)); + } + + column.Style = style; + return column; + } + } +} diff --git a/src/Spectre.Console/Extensions/Columns/SpinnerColumnExtensions.cs b/src/Spectre.Console/Extensions/Columns/SpinnerColumnExtensions.cs new file mode 100644 index 0000000..c33c792 --- /dev/null +++ b/src/Spectre.Console/Extensions/Columns/SpinnerColumnExtensions.cs @@ -0,0 +1,32 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class SpinnerColumnExtensions + { + /// + /// Sets the style of the spinner. + /// + /// The column. + /// The style. + /// The same instance so that multiple calls can be chained. + public static SpinnerColumn Style(this SpinnerColumn column, Style style) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + if (style is null) + { + throw new ArgumentNullException(nameof(style)); + } + + column.Style = style; + return column; + } + } +} diff --git a/src/Spectre.Console/Extensions/PanelExtensions.cs b/src/Spectre.Console/Extensions/PanelExtensions.cs index e1901dc..9505fe5 100644 --- a/src/Spectre.Console/Extensions/PanelExtensions.cs +++ b/src/Spectre.Console/Extensions/PanelExtensions.cs @@ -27,7 +27,7 @@ namespace Spectre.Console } alignment ??= panel.Header?.Alignment; - return Header(panel, new PanelHeader(text, alignment)); + return Header(panel, new PanelHeader(text, alignment)); } /// diff --git a/src/Spectre.Console/Extensions/ProgressExtensions.cs b/src/Spectre.Console/Extensions/ProgressExtensions.cs new file mode 100644 index 0000000..cf8d8a9 --- /dev/null +++ b/src/Spectre.Console/Extensions/ProgressExtensions.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class ProgressExtensions + { + /// + /// Sets the columns to be used for an instance. + /// + /// The instance. + /// The columns to use. + /// The same instance so that multiple calls can be chained. + public static Progress Columns(this Progress progress, ProgressColumn[] columns) + { + if (progress is null) + { + throw new ArgumentNullException(nameof(progress)); + } + + if (columns is null) + { + throw new ArgumentNullException(nameof(columns)); + } + + if (!columns.Any()) + { + throw new InvalidOperationException("At least one column must be specified."); + } + + progress.Columns.Clear(); + progress.Columns.AddRange(columns); + + return progress; + } + + /// + /// Sets whether or not auto refresh is enabled. + /// If disabled, you will manually have to refresh the progress. + /// + /// The instance. + /// Whether or not auto refresh is enabled. + /// The same instance so that multiple calls can be chained. + public static Progress AutoRefresh(this Progress progress, bool enabled) + { + if (progress is null) + { + throw new ArgumentNullException(nameof(progress)); + } + + progress.AutoRefresh = enabled; + + return progress; + } + + /// + /// Sets whether or not auto clear is enabled. + /// If enabled, the task tabled will be removed once + /// all tasks have completed. + /// + /// The instance. + /// Whether or not auto clear is enabled. + /// The same instance so that multiple calls can be chained. + public static Progress AutoClear(this Progress progress, bool enabled) + { + if (progress is null) + { + throw new ArgumentNullException(nameof(progress)); + } + + progress.AutoClear = enabled; + + return progress; + } + } +} diff --git a/src/Spectre.Console/Extensions/ProgressTaskExtensions.cs b/src/Spectre.Console/Extensions/ProgressTaskExtensions.cs new file mode 100644 index 0000000..63ba2b0 --- /dev/null +++ b/src/Spectre.Console/Extensions/ProgressTaskExtensions.cs @@ -0,0 +1,44 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class ProgressTaskExtensions + { + /// + /// Sets the task description. + /// + /// The task. + /// The description. + /// The same instance so that multiple calls can be chained. + public static ProgressTask Description(this ProgressTask task, string description) + { + if (task is null) + { + throw new ArgumentNullException(nameof(task)); + } + + task.Description = description; + return task; + } + + /// + /// Sets the max value of the task. + /// + /// The task. + /// The max value. + /// The same instance so that multiple calls can be chained. + public static ProgressTask MaxValue(this ProgressTask task, double value) + { + if (task is null) + { + throw new ArgumentNullException(nameof(task)); + } + + task.MaxValue = value; + return task; + } + } +} diff --git a/src/Spectre.Console/Extensions/StringBuilderExtensions.cs b/src/Spectre.Console/Extensions/StringBuilderExtensions.cs index ff394f6..0a970e0 100644 --- a/src/Spectre.Console/Extensions/StringBuilderExtensions.cs +++ b/src/Spectre.Console/Extensions/StringBuilderExtensions.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Text; -namespace Spectre.Console +namespace Spectre.Console.Internal { internal static class StringBuilderExtensions { diff --git a/src/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console/Extensions/StringExtensions.cs index e17649a..7d5a02f 100644 --- a/src/Spectre.Console/Extensions/StringExtensions.cs +++ b/src/Spectre.Console/Extensions/StringExtensions.cs @@ -62,10 +62,15 @@ namespace Spectre.Console return text; } - internal static string NormalizeLineEndings(this string? text, bool native = false) + internal static string? RemoveNewLines(this string? text) + { + return text?.ReplaceExact("\r\n", string.Empty) + ?.ReplaceExact("\n", string.Empty); + } + + internal static string NormalizeNewLines(this string? text, bool native = false) { text = text?.ReplaceExact("\r\n", "\n"); - text = text?.ReplaceExact("\r", string.Empty); text ??= string.Empty; if (native && !_alreadyNormalized) @@ -78,7 +83,7 @@ namespace Spectre.Console internal static string[] SplitLines(this string text) { - var result = text?.NormalizeLineEndings()?.Split(new[] { '\n' }, StringSplitOptions.None); + var result = text?.NormalizeNewLines()?.Split(new[] { '\n' }, StringSplitOptions.None); return result ?? Array.Empty(); } diff --git a/src/Spectre.Console/IAnsiConsole.cs b/src/Spectre.Console/IAnsiConsole.cs index 58c87e5..12785a0 100644 --- a/src/Spectre.Console/IAnsiConsole.cs +++ b/src/Spectre.Console/IAnsiConsole.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Text; using Spectre.Console.Rendering; @@ -28,6 +29,11 @@ namespace Spectre.Console /// IAnsiConsoleInput Input { get; } + /// + /// Gets the render pipeline. + /// + RenderPipeline Pipeline { get; } + /// /// Gets the buffer width of the console. /// @@ -45,9 +51,9 @@ namespace Spectre.Console void Clear(bool home); /// - /// Writes a string followed by a line terminator to the console. + /// Writes multiple segments to the console. /// - /// The segment to write. - void Write(Segment segment); + /// The segments to write. + void Write(IEnumerable segments); } } diff --git a/src/Spectre.Console/InteractionSupport.cs b/src/Spectre.Console/InteractionSupport.cs new file mode 100644 index 0000000..4fb2747 --- /dev/null +++ b/src/Spectre.Console/InteractionSupport.cs @@ -0,0 +1,24 @@ +namespace Spectre.Console +{ + /// + /// Determines interactivity support. + /// + public enum InteractionSupport + { + /// + /// Interaction support should be + /// detected by the system. + /// + Detect = 0, + + /// + /// Interactivity is supported. + /// + Yes = 1, + + /// + /// Interactivity is not supported. + /// + No = 2, + } +} diff --git a/src/Spectre.Console/Internal/Ansi/AnsiDetector.cs b/src/Spectre.Console/Internal/Ansi/AnsiDetector.cs index d678e71..5f4b132 100644 --- a/src/Spectre.Console/Internal/Ansi/AnsiDetector.cs +++ b/src/Spectre.Console/Internal/Ansi/AnsiDetector.cs @@ -121,6 +121,8 @@ namespace Spectre.Console.Internal // Enabling failed. return false; } + + isLegacy = false; } return true; diff --git a/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs b/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs index 9a101d8..c41494a 100644 --- a/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs +++ b/src/Spectre.Console/Internal/Backends/Ansi/AnsiBackend.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text; using Spectre.Console.Rendering; @@ -11,9 +12,11 @@ namespace Spectre.Console.Internal private readonly AnsiBuilder _ansiBuilder; private readonly AnsiCursor _cursor; private readonly ConsoleInput _input; + private readonly object _lock; public Capabilities Capabilities { get; } public Encoding Encoding { get; } + public RenderPipeline Pipeline { get; } public IAnsiConsoleCursor Cursor => _cursor; public IAnsiConsoleInput Input => _input; @@ -49,35 +52,59 @@ namespace Spectre.Console.Internal Capabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities)); Encoding = _out.IsStandardOut() ? System.Console.OutputEncoding : Encoding.UTF8; + Pipeline = new RenderPipeline(); _ansiBuilder = new AnsiBuilder(Capabilities, linkHasher); _cursor = new AnsiCursor(this); _input = new ConsoleInput(); + _lock = new object(); } public void Clear(bool home) { - Write(Segment.Control("\u001b[2J")); - - if (home) + lock (_lock) { - Cursor.SetPosition(0, 0); + Write(new[] { Segment.Control("\u001b[2J") }); + + if (home) + { + Cursor.SetPosition(0, 0); + } } } - public void Write(Segment segment) + public void Write(IEnumerable segments) { - var parts = segment.Text.NormalizeLineEndings().Split(new[] { '\n' }); - foreach (var (_, _, last, part) in parts.Enumerate()) + lock (_lock) { - if (!string.IsNullOrEmpty(part)) + var builder = new StringBuilder(); + foreach (var segment in segments) { - _out.Write(_ansiBuilder.GetAnsi(part, segment.Style)); + if (segment.IsControlCode) + { + builder.Append(segment.Text); + continue; + } + + var parts = segment.Text.NormalizeNewLines().Split(new[] { '\n' }); + foreach (var (_, _, last, part) in parts.Enumerate()) + { + if (!string.IsNullOrEmpty(part)) + { + builder.Append(_ansiBuilder.GetAnsi(part, segment.Style)); + } + + if (!last) + { + builder.Append(Environment.NewLine); + } + } } - if (!last) + if (builder.Length > 0) { - _out.Write(Environment.NewLine); + _out.Write(builder.ToString()); + _out.Flush(); } } } diff --git a/src/Spectre.Console/Internal/Backends/BackendBuilder.cs b/src/Spectre.Console/Internal/Backends/BackendBuilder.cs index 6069e2a..a2f51e2 100644 --- a/src/Spectre.Console/Internal/Backends/BackendBuilder.cs +++ b/src/Spectre.Console/Internal/Backends/BackendBuilder.cs @@ -50,12 +50,18 @@ namespace Spectre.Console.Internal } } + var supportsInteraction = settings.Interactive == InteractionSupport.Yes; + if (settings.Interactive == InteractionSupport.Detect) + { + supportsInteraction = InteractivityDetector.IsInteractive(); + } + var colorSystem = settings.ColorSystem == ColorSystemSupport.Detect ? ColorSystemDetector.Detect(supportsAnsi) : (ColorSystem)settings.ColorSystem; // Get the capabilities - var capabilities = new Capabilities(supportsAnsi, colorSystem, legacyConsole); + var capabilities = new Capabilities(supportsAnsi, colorSystem, legacyConsole, supportsInteraction); // Create the renderer if (supportsAnsi) diff --git a/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs b/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs index 81aff14..569db39 100644 --- a/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs +++ b/src/Spectre.Console/Internal/Backends/Fallback/FallbackBackend.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Text; using Spectre.Console.Rendering; @@ -14,6 +15,7 @@ namespace Spectre.Console.Internal public Capabilities Capabilities { get; } public Encoding Encoding { get; } + public RenderPipeline Pipeline { get; } public IAnsiConsoleCursor Cursor => _cursor; public IAnsiConsoleInput Input => _input; @@ -43,8 +45,9 @@ namespace Spectre.Console.Internal System.Console.SetOut(@out ?? throw new ArgumentNullException(nameof(@out))); } - Encoding = System.Console.OutputEncoding; Capabilities = capabilities; + Encoding = System.Console.OutputEncoding; + Pipeline = new RenderPipeline(); } public void Clear(bool home) @@ -60,14 +63,22 @@ namespace Spectre.Console.Internal } } - public void Write(Segment segment) + public void Write(IEnumerable segments) { - if (_lastStyle?.Equals(segment.Style) != true) + foreach (var segment in segments) { - SetStyle(segment.Style); - } + if (segment.IsControlCode) + { + continue; + } - System.Console.Write(segment.Text.NormalizeLineEndings(native: true)); + if (_lastStyle?.Equals(segment.Style) != true) + { + SetStyle(segment.Style); + } + + System.Console.Write(segment.Text.NormalizeNewLines(native: true)); + } } private void SetStyle(Style style) diff --git a/src/Spectre.Console/Internal/HtmlEncoder.cs b/src/Spectre.Console/Internal/HtmlEncoder.cs index 074a344..26b7668 100644 --- a/src/Spectre.Console/Internal/HtmlEncoder.cs +++ b/src/Spectre.Console/Internal/HtmlEncoder.cs @@ -15,6 +15,11 @@ namespace Spectre.Console.Internal foreach (var (_, first, _, segment) in segments.Enumerate()) { + if (segment.IsControlCode) + { + continue; + } + if (segment.Text == "\n" && !first) { builder.Append('\n'); diff --git a/src/Spectre.Console/Internal/InteractivityDetector.cs b/src/Spectre.Console/Internal/InteractivityDetector.cs new file mode 100644 index 0000000..c91f296 --- /dev/null +++ b/src/Spectre.Console/Internal/InteractivityDetector.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace Spectre.Console.Internal +{ + internal static class InteractivityDetector + { + private static readonly Dictionary> _environmentVariables; + + static InteractivityDetector() + { + _environmentVariables = new Dictionary> + { + { "APPVEYOR", v => !string.IsNullOrWhiteSpace(v) }, + { "bamboo_buildNumber", v => !string.IsNullOrWhiteSpace(v) }, + { "BITBUCKET_REPO_OWNER", v => !string.IsNullOrWhiteSpace(v) }, + { "BITBUCKET_REPO_SLUG", v => !string.IsNullOrWhiteSpace(v) }, + { "BITBUCKET_COMMIT", v => !string.IsNullOrWhiteSpace(v) }, + { "BITRISE_BUILD_URL", v => !string.IsNullOrWhiteSpace(v) }, + { "ContinuaCI.Version", v => !string.IsNullOrWhiteSpace(v) }, + { "CI_SERVER", v => v.Equals("yes", StringComparison.OrdinalIgnoreCase) }, // GitLab + { "GITHUB_ACTIONS", v => v.Equals("true", StringComparison.OrdinalIgnoreCase) }, + { "GO_SERVER_URL", v => !string.IsNullOrWhiteSpace(v) }, + { "JENKINS_URL", v => !string.IsNullOrWhiteSpace(v) }, + { "BuildRunner", v => v.Equals("MyGet", StringComparison.OrdinalIgnoreCase) }, + { "TEAMCITY_VERSION", v => !string.IsNullOrWhiteSpace(v) }, + { "TF_BUILD", v => !string.IsNullOrWhiteSpace(v) }, // TFS and Azure + { "TRAVIS", v => !string.IsNullOrWhiteSpace(v) }, + }; + } + + public static bool IsInteractive() + { + if (!Environment.UserInteractive) + { + return false; + } + + foreach (var variable in _environmentVariables) + { + var func = variable.Value; + var value = Environment.GetEnvironmentVariable(variable.Key); + if (!string.IsNullOrWhiteSpace(value) && variable.Value(value)) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/Spectre.Console/Internal/ResourceReader.cs b/src/Spectre.Console/Internal/ResourceReader.cs index 908707b..e41efbe 100644 --- a/src/Spectre.Console/Internal/ResourceReader.cs +++ b/src/Spectre.Console/Internal/ResourceReader.cs @@ -24,7 +24,7 @@ namespace Spectre.Console.Internal using (var reader = new StreamReader(stream)) { - return reader.ReadToEnd().NormalizeLineEndings(); + return reader.ReadToEnd().NormalizeNewLines(); } } } diff --git a/src/Spectre.Console/Internal/TextEncoder.cs b/src/Spectre.Console/Internal/TextEncoder.cs index 2bc3adf..7261c90 100644 --- a/src/Spectre.Console/Internal/TextEncoder.cs +++ b/src/Spectre.Console/Internal/TextEncoder.cs @@ -12,6 +12,11 @@ namespace Spectre.Console.Internal foreach (var segment in Segment.Merge(segments)) { + if (segment.IsControlCode) + { + continue; + } + builder.Append(segment.Text); } diff --git a/src/Spectre.Console/Progress/Columns/PercentageColumn.cs b/src/Spectre.Console/Progress/Columns/PercentageColumn.cs new file mode 100644 index 0000000..382751c --- /dev/null +++ b/src/Spectre.Console/Progress/Columns/PercentageColumn.cs @@ -0,0 +1,32 @@ +using System; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A column showing task progress in percentage. + /// + public sealed class PercentageColumn : ProgressColumn + { + /// + protected internal override int? ColumnWidth => 4; + + /// + /// Gets or sets the style for a non-complete task. + /// + public Style Style { get; set; } = Style.Plain; + + /// + /// Gets or sets the style for a completed task. + /// + public Style CompletedStyle { get; set; } = new Style(foreground: Color.Green); + + /// + public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime) + { + var percentage = (int)task.Percentage; + var style = percentage == 100 ? CompletedStyle : Style ?? Style.Plain; + return new Text($"{percentage}%", style).RightAligned(); + } + } +} diff --git a/src/Spectre.Console/Progress/Columns/ProgressBarColumn.cs b/src/Spectre.Console/Progress/Columns/ProgressBarColumn.cs new file mode 100644 index 0000000..1164325 --- /dev/null +++ b/src/Spectre.Console/Progress/Columns/ProgressBarColumn.cs @@ -0,0 +1,45 @@ +using System; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A column showing task progress as a progress bar. + /// + public sealed class ProgressBarColumn : ProgressColumn + { + /// + /// Gets or sets the width of the column. + /// + public int? Width { get; set; } = 40; + + /// + /// Gets or sets the style of completed portions of the progress bar. + /// + public Style CompletedStyle { get; set; } = new Style(foreground: Color.Yellow); + + /// + /// Gets or sets the style of a finished progress bar. + /// + public Style FinishedStyle { get; set; } = new Style(foreground: Color.Green); + + /// + /// Gets or sets the style of remaining portions of the progress bar. + /// + public Style RemainingStyle { get; set; } = new Style(foreground: Color.Grey); + + /// + public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime) + { + return new ProgressBar + { + MaxValue = task.MaxValue, + Value = task.Value, + Width = Width, + CompletedStyle = CompletedStyle, + FinishedStyle = FinishedStyle, + RemainingStyle = RemainingStyle, + }; + } + } +} diff --git a/src/Spectre.Console/Progress/Columns/RemainingTimeColumn.cs b/src/Spectre.Console/Progress/Columns/RemainingTimeColumn.cs new file mode 100644 index 0000000..7c0a970 --- /dev/null +++ b/src/Spectre.Console/Progress/Columns/RemainingTimeColumn.cs @@ -0,0 +1,28 @@ +using System; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A column showing the remaining time of a task. + /// + public sealed class RemainingTimeColumn : ProgressColumn + { + /// + /// 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 remaining = task.RemainingTime; + if (remaining == null) + { + return new Markup("-:--:--"); + } + + return new Text($"{remaining.Value:h\\:mm\\:ss}", Style ?? Style.Plain); + } + } +} diff --git a/src/Spectre.Console/Progress/Columns/SpinnerColumn.cs b/src/Spectre.Console/Progress/Columns/SpinnerColumn.cs new file mode 100644 index 0000000..b32d711 --- /dev/null +++ b/src/Spectre.Console/Progress/Columns/SpinnerColumn.cs @@ -0,0 +1,44 @@ +using System; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A column showing a spinner. + /// + public sealed class SpinnerColumn : ProgressColumn + { + private const string ACCUMULATED = "SPINNER_ACCUMULATED"; + private const string INDEX = "SPINNER_INDEX"; + + private readonly string _ansiSequence = "⣷⣯⣟⡿⢿⣻⣽⣾"; + private readonly string _asciiSequence = "-\\|/-\\|/"; + + /// + /// Gets or sets the style of the spinner. + /// + public Style Style { get; set; } = new Style(foreground: Color.Yellow); + + /// + public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime) + { + if (!task.IsStarted || task.IsFinished) + { + return new Markup(" "); + } + + var accumulated = task.State.Update(ACCUMULATED, acc => acc + deltaTime.TotalMilliseconds); + if (accumulated >= 100) + { + task.State.Update(ACCUMULATED, _ => 0); + task.State.Update(INDEX, index => index + 1); + } + + var useAscii = context.LegacyConsole || !context.Unicode; + var sequence = useAscii ? _asciiSequence : _ansiSequence; + + var index = task.State.Get(INDEX); + return new Markup(sequence[index % sequence.Length].ToString(), Style ?? Style.Plain); + } + } +} diff --git a/src/Spectre.Console/Progress/Columns/TaskDescriptionColumn.cs b/src/Spectre.Console/Progress/Columns/TaskDescriptionColumn.cs new file mode 100644 index 0000000..eac611f --- /dev/null +++ b/src/Spectre.Console/Progress/Columns/TaskDescriptionColumn.cs @@ -0,0 +1,18 @@ +using System; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A column showing the task description. + /// + public sealed class TaskDescriptionColumn : ProgressColumn + { + /// + public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime) + { + var text = task.Description?.RemoveNewLines()?.Trim(); + return new Markup(text ?? string.Empty).RightAligned(); + } + } +} diff --git a/src/Spectre.Console/Progress/Progress.cs b/src/Spectre.Console/Progress/Progress.cs new file mode 100644 index 0000000..8fde020 --- /dev/null +++ b/src/Spectre.Console/Progress/Progress.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Represents a task list. + /// + public sealed class Progress + { + private readonly IAnsiConsole _console; + + /// + /// Gets or sets a value indicating whether or not task list should auto refresh. + /// Defaults to true. + /// + public bool AutoRefresh { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not the task list should + /// be cleared once it completes. + /// Defaults to false. + /// + public bool AutoClear { get; set; } + + internal List Columns { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The console to render to. + public Progress(IAnsiConsole console) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + + // Initialize with default columns + Columns = new List + { + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new PercentageColumn(), + }; + } + + /// + /// Starts the progress task list. + /// + /// The action to execute. + public void Start(Action action) + { + var task = StartAsync(ctx => + { + action(ctx); + return Task.CompletedTask; + }); + + task.GetAwaiter().GetResult(); + } + + /// + /// Starts the progress task list. + /// + /// The action to execute. + /// A representing the asynchronous operation. + public async Task StartAsync(Func action) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + var renderer = CreateRenderer(); + renderer.Started(); + + try + { + using (new RenderHookScope(_console, renderer)) + { + var context = new ProgressContext(_console, renderer); + + if (AutoRefresh) + { + using (var thread = new ProgressRefreshThread(context, renderer.RefreshRate)) + { + await action(context).ConfigureAwait(false); + } + } + else + { + await action(context).ConfigureAwait(false); + } + + context.Refresh(); + } + } + finally + { + renderer.Completed(AutoClear); + } + } + + private ProgressRenderer CreateRenderer() + { + var caps = _console.Capabilities; + var interactive = caps.SupportsInteraction && caps.SupportsAnsi; + + if (interactive) + { + var columns = new List(Columns); + return new InteractiveProgressRenderer(_console, columns); + } + else + { + return new NonInteractiveProgressRenderer(); + } + } + } +} diff --git a/src/Spectre.Console/Progress/ProgressColumn.cs b/src/Spectre.Console/Progress/ProgressColumn.cs new file mode 100644 index 0000000..e72585a --- /dev/null +++ b/src/Spectre.Console/Progress/ProgressColumn.cs @@ -0,0 +1,25 @@ +using System; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Represents a progress column. + /// + public abstract class ProgressColumn + { + /// + /// Gets the requested column width for the column. + /// + protected internal virtual int? ColumnWidth { get; } + + /// + /// Gets a renderable representing the column. + /// + /// The render context. + /// The task. + /// The elapsed time since last call. + /// A renderable representing the column. + public abstract IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime); + } +} diff --git a/src/Spectre.Console/Progress/ProgressContext.cs b/src/Spectre.Console/Progress/ProgressContext.cs new file mode 100644 index 0000000..4b57c2c --- /dev/null +++ b/src/Spectre.Console/Progress/ProgressContext.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Internal; + +namespace Spectre.Console +{ + /// + /// Represents a context that can be used to interact with a . + /// + public sealed class ProgressContext + { + private readonly List _tasks; + private readonly object _taskLock; + private readonly IAnsiConsole _console; + private readonly ProgressRenderer _renderer; + private int _taskId; + + /// + /// Gets a value indicating whether or not all tasks have completed. + /// + public bool IsFinished => _tasks.All(task => task.IsFinished); + + internal ProgressContext(IAnsiConsole console, ProgressRenderer renderer) + { + _tasks = new List(); + _taskLock = new object(); + _console = console ?? throw new ArgumentNullException(nameof(console)); + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + } + + /// + /// Adds a task. + /// + /// The task description. + /// The task settings. + /// The task's ID. + public ProgressTask AddTask(string description, ProgressTaskSettings? settings = null) + { + lock (_taskLock) + { + settings ??= new ProgressTaskSettings(); + var task = new ProgressTask(_taskId++, description, settings.MaxValue, settings.AutoStart); + + _tasks.Add(task); + return task; + } + } + + /// + /// Refreshes the current progress. + /// + public void Refresh() + { + _renderer.Update(this); + _console.Render(new ControlSequence(string.Empty)); + } + + internal void EnumerateTasks(Action action) + { + lock (_taskLock) + { + foreach (var task in _tasks) + { + action(task); + } + } + } + } +} diff --git a/src/Spectre.Console/Progress/ProgressRefreshThread.cs b/src/Spectre.Console/Progress/ProgressRefreshThread.cs new file mode 100644 index 0000000..35cd09e --- /dev/null +++ b/src/Spectre.Console/Progress/ProgressRefreshThread.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading; + +namespace Spectre.Console.Internal +{ + internal sealed class ProgressRefreshThread : IDisposable + { + private readonly ProgressContext _context; + private readonly TimeSpan _refreshRate; + private readonly ManualResetEvent _running; + private readonly ManualResetEvent _stopped; + private readonly Thread? _thread; + + public ProgressRefreshThread(ProgressContext context, TimeSpan refreshRate) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _refreshRate = refreshRate; + _running = new ManualResetEvent(false); + _stopped = new ManualResetEvent(false); + + _thread = new Thread(Run); + _thread.IsBackground = true; + _thread.Start(); + } + + public void Dispose() + { + if (_thread == null || !_running.WaitOne(0)) + { + return; + } + + _stopped.Set(); + _thread.Join(); + + _stopped.Dispose(); + _running.Dispose(); + } + + private void Run() + { + _running.Set(); + + try + { + while (!_stopped.WaitOne(_refreshRate)) + { + _context.Refresh(); + } + } + finally + { + _stopped.Reset(); + _running.Reset(); + } + } + } +} diff --git a/src/Spectre.Console/Progress/ProgressRenderer.cs b/src/Spectre.Console/Progress/ProgressRenderer.cs new file mode 100644 index 0000000..14fc8d4 --- /dev/null +++ b/src/Spectre.Console/Progress/ProgressRenderer.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using Spectre.Console.Rendering; + +namespace Spectre.Console.Internal +{ + internal abstract class ProgressRenderer : IRenderHook + { + public abstract TimeSpan RefreshRate { get; } + + public virtual void Started() + { + } + + public virtual void Completed(bool clear) + { + } + + public abstract void Update(ProgressContext context); + public abstract IEnumerable Process(RenderContext context, IEnumerable renderables); + } +} diff --git a/src/Spectre.Console/Progress/ProgressSample.cs b/src/Spectre.Console/Progress/ProgressSample.cs new file mode 100644 index 0000000..b8fc57f --- /dev/null +++ b/src/Spectre.Console/Progress/ProgressSample.cs @@ -0,0 +1,16 @@ +using System; + +namespace Spectre.Console.Internal +{ + internal readonly struct ProgressSample + { + public double Value { get; } + public DateTime Timestamp { get; } + + public ProgressSample(DateTime timestamp, double value) + { + Timestamp = timestamp; + Value = value; + } + } +} diff --git a/src/Spectre.Console/Progress/ProgressTask.cs b/src/Spectre.Console/Progress/ProgressTask.cs new file mode 100644 index 0000000..58c3c83 --- /dev/null +++ b/src/Spectre.Console/Progress/ProgressTask.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Internal; + +namespace Spectre.Console +{ + /// + /// Represents a progress task. + /// + public sealed class ProgressTask + { + private readonly List _samples; + private readonly object _lock; + + private double _maxValue; + private string _description; + + /// + /// Gets the task ID. + /// + public int Id { get; } + + /// + /// Gets or sets the task description. + /// + public string Description + { + get => _description; + set => Update(description: value); + } + + /// + /// Gets or sets the max value of the task. + /// + public double MaxValue + { + get => _maxValue; + set => Update(maxValue: value); + } + + /// + /// Gets the value of the task. + /// + public double Value { get; private set; } + + /// + /// Gets the start time of the task. + /// + public DateTime? StartTime { get; private set; } + + /// + /// Gets the stop time of the task. + /// + public DateTime? StopTime { get; private set; } + + /// + /// Gets the task state. + /// + public ProgressTaskState State { get; } + + /// + /// Gets a value indicating whether or not the task has started. + /// + public bool IsStarted => StartTime != null; + + /// + /// Gets a value indicating whether or not the task has finished. + /// + public bool IsFinished => Value >= MaxValue; + + /// + /// Gets the percentage done of the task. + /// + public double Percentage => GetPercentage(); + + /// + /// Gets the speed measured in steps/second. + /// + public double? Speed => GetSpeed(); + + /// + /// Gets the elapsed time. + /// + public TimeSpan? ElapsedTime => GetElapsedTime(); + + /// + /// Gets the remaining time. + /// + public TimeSpan? RemainingTime => GetRemainingTime(); + + internal ProgressTask(int id, string description, double maxValue, bool autoStart) + { + _samples = new List(); + _lock = new object(); + _maxValue = maxValue; + + _description = description?.RemoveNewLines()?.Trim() ?? throw new ArgumentNullException(nameof(description)); + if (string.IsNullOrWhiteSpace(_description)) + { + throw new ArgumentException("Task name cannot be empty", nameof(description)); + } + + Id = id; + State = new ProgressTaskState(); + Value = 0; + StartTime = autoStart ? DateTime.Now : null; + } + + /// + /// Starts the task. + /// + public void StartTask() + { + lock (_lock) + { + StartTime = DateTime.Now; + StopTime = null; + } + } + + /// + /// Stops the task. + /// + public void StopTask() + { + lock (_lock) + { + var now = DateTime.Now; + if (StartTime == null) + { + StartTime = now; + } + + StopTime = now; + } + } + + /// + /// Increments the task's value. + /// + /// The value to increment with. + public void Increment(double value) + { + Update(increment: value); + } + + private void Update( + string? description = null, + double? maxValue = null, + double? increment = null) + { + lock (_lock) + { + var startValue = Value; + + if (description != null) + { + description = description?.RemoveNewLines()?.Trim(); + if (string.IsNullOrWhiteSpace(description)) + { + throw new InvalidOperationException("Task name cannot be empty."); + } + + _description = description; + } + + if (maxValue != null) + { + _maxValue += maxValue.Value; + } + + if (increment != null) + { + Value += increment.Value; + } + + var timestamp = DateTime.Now; + var threshold = timestamp - TimeSpan.FromSeconds(30); + + // Remove samples that's too old + while (_samples.Count > 0 && _samples[0].Timestamp < threshold) + { + _samples.RemoveAt(0); + } + + // Keep maximum of 1000 samples + while (_samples.Count > 1000) + { + _samples.RemoveAt(0); + } + + _samples.Add(new ProgressSample(timestamp, Value - startValue)); + } + } + + private double GetPercentage() + { + var percentage = (Value / MaxValue) * 100; + percentage = Math.Min(100, Math.Max(0, percentage)); + return percentage; + } + + private double? GetSpeed() + { + lock (_lock) + { + if (StartTime == null) + { + return null; + } + + if (_samples.Count == 0) + { + return null; + } + + var totalTime = _samples.Last().Timestamp - _samples[0].Timestamp; + if (totalTime == TimeSpan.Zero) + { + return null; + } + + var totalCompleted = _samples.Sum(x => x.Value); + return totalCompleted / totalTime.TotalSeconds; + } + } + + private TimeSpan? GetElapsedTime() + { + lock (_lock) + { + if (StartTime == null) + { + return null; + } + + if (StopTime != null) + { + return StopTime - StartTime; + } + + return DateTime.Now - StartTime; + } + } + + private TimeSpan? GetRemainingTime() + { + lock (_lock) + { + if (IsFinished) + { + return TimeSpan.Zero; + } + + var speed = GetSpeed(); + if (speed == null) + { + return null; + } + + var estimate = (MaxValue - Value) / speed.Value; + return TimeSpan.FromSeconds(estimate); + } + } + } +} diff --git a/src/Spectre.Console/Progress/ProgressTaskSettings.cs b/src/Spectre.Console/Progress/ProgressTaskSettings.cs new file mode 100644 index 0000000..3ceb0fb --- /dev/null +++ b/src/Spectre.Console/Progress/ProgressTaskSettings.cs @@ -0,0 +1,20 @@ +namespace Spectre.Console +{ + /// + /// Represents settings for a progress task. + /// + public sealed class ProgressTaskSettings + { + /// + /// Gets or sets the task's max value. + /// Defaults to 100. + /// + public double MaxValue { get; set; } = 100; + + /// + /// Gets or sets a value indicating whether or not the task + /// will be auto started. Defaults to true. + /// + public bool AutoStart { get; set; } = true; + } +} diff --git a/src/Spectre.Console/Progress/ProgressTaskState.cs b/src/Spectre.Console/Progress/ProgressTaskState.cs new file mode 100644 index 0000000..9e8a2dc --- /dev/null +++ b/src/Spectre.Console/Progress/ProgressTaskState.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; + +namespace Spectre.Console +{ + /// + /// Represents progress task state. + /// + public sealed class ProgressTaskState + { + private readonly Dictionary _state; + private readonly object _lock; + + /// + /// Initializes a new instance of the class. + /// + public ProgressTaskState() + { + _state = new Dictionary(); + _lock = new object(); + } + + /// + /// Gets the state value for the specified key. + /// + /// The state value type. + /// The state key. + /// The value for the specified key. + public T Get(string key) + where T : struct + { + lock (_lock) + { + if (!_state.TryGetValue(key, out var value)) + { + return default; + } + + if (!(value is T)) + { + throw new InvalidOperationException("State value is of the wrong type."); + } + + return (T)value; + } + } + + /// + /// Updates a task state value. + /// + /// The state value type. + /// The key. + /// The transformation function. + /// The updated value. + public T Update(string key, Func func) + where T : struct + { + lock (_lock) + { + if (func is null) + { + throw new ArgumentNullException(nameof(func)); + } + + var old = default(T); + if (_state.TryGetValue(key, out var value)) + { + if (!(value is T)) + { + throw new InvalidOperationException("State value is of the wrong type."); + } + + old = (T)value; + } + + _state[key] = func(old); + return (T)_state[key]; + } + } + } +} diff --git a/src/Spectre.Console/Progress/Renderers/InteractiveProgressRenderer.cs b/src/Spectre.Console/Progress/Renderers/InteractiveProgressRenderer.cs new file mode 100644 index 0000000..7860239 --- /dev/null +++ b/src/Spectre.Console/Progress/Renderers/InteractiveProgressRenderer.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Spectre.Console.Rendering; + +namespace Spectre.Console.Internal +{ + internal sealed class InteractiveProgressRenderer : ProgressRenderer + { + private readonly IAnsiConsole _console; + private readonly List _columns; + private readonly LiveRenderable _live; + private readonly object _lock; + private readonly Stopwatch _stopwatch; + private TimeSpan _lastUpdate; + + public override TimeSpan RefreshRate => TimeSpan.FromMilliseconds(100); + + public InteractiveProgressRenderer(IAnsiConsole console, List columns) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _columns = columns ?? throw new ArgumentNullException(nameof(columns)); + _live = new LiveRenderable(); + _lock = new object(); + _stopwatch = new Stopwatch(); + _lastUpdate = TimeSpan.Zero; + } + + public override void Started() + { + _console.Cursor.Hide(); + } + + public override void Completed(bool clear) + { + lock (_lock) + { + if (clear) + { + _console.Render(_live.RestoreCursor()); + } + else + { + _console.WriteLine(); + } + + _console.Cursor.Show(); + } + } + + public override void Update(ProgressContext context) + { + lock (_lock) + { + if (!_stopwatch.IsRunning) + { + _stopwatch.Start(); + } + + var delta = _stopwatch.Elapsed - _lastUpdate; + _lastUpdate = _stopwatch.Elapsed; + + var grid = new Grid(); + for (var columnIndex = 0; columnIndex < _columns.Count; columnIndex++) + { + var column = new GridColumn().PadRight(1); + if (_columns[columnIndex].ColumnWidth != null) + { + column.Width = _columns[columnIndex].ColumnWidth; + } + + // Last column? + if (columnIndex == _columns.Count - 1) + { + column.PadRight(0); + } + + grid.AddColumn(column); + } + + // Add rows + var renderContext = new RenderContext(_console.Encoding, _console.Capabilities.LegacyConsole); + context.EnumerateTasks(task => + { + var columns = _columns.Select(column => column.Render(renderContext, task, delta)); + grid.AddRow(columns.ToArray()); + }); + + _live.SetRenderable(new Padder(grid, new Padding(0, 1))); + } + } + + public override IEnumerable Process(RenderContext context, IEnumerable renderables) + { + lock (_lock) + { + yield return _live.PositionCursor(); + + foreach (var renderable in renderables) + { + yield return renderable; + } + + yield return _live; + } + } + } +} diff --git a/src/Spectre.Console/Progress/Renderers/NonInteractiveProgressRenderer.cs b/src/Spectre.Console/Progress/Renderers/NonInteractiveProgressRenderer.cs new file mode 100644 index 0000000..a15f09c --- /dev/null +++ b/src/Spectre.Console/Progress/Renderers/NonInteractiveProgressRenderer.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using Spectre.Console.Rendering; + +namespace Spectre.Console.Internal +{ + internal sealed class NonInteractiveProgressRenderer : ProgressRenderer + { + private const double FirstMilestone = 25; + private static readonly double?[] _milestones = new double?[] { FirstMilestone, 50, 75, 95, 96, 97, 98, 99, 100 }; + + private readonly Dictionary _taskMilestones; + private readonly object _lock; + private IRenderable? _renderable; + private DateTime _lastUpdate; + + public override TimeSpan RefreshRate => TimeSpan.FromSeconds(1); + + public NonInteractiveProgressRenderer() + { + _taskMilestones = new Dictionary(); + _lock = new object(); + } + + public override void Update(ProgressContext context) + { + lock (_lock) + { + var hasStartedTasks = false; + var updates = new List<(string, double)>(); + + context.EnumerateTasks(task => + { + if (!task.IsStarted || task.IsFinished) + { + return; + } + + hasStartedTasks = true; + + if (TryAdvance(task.Id, task.Percentage)) + { + updates.Add((task.Description, task.Percentage)); + } + }); + + // Got started tasks but no updates for 30 seconds? + if (hasStartedTasks && updates.Count == 0 && (DateTime.Now - _lastUpdate) > TimeSpan.FromSeconds(30)) + { + context.EnumerateTasks(task => updates.Add((task.Description, task.Percentage))); + } + + if (updates.Count > 0) + { + _lastUpdate = DateTime.Now; + } + + _renderable = BuildTaskGrid(updates); + } + } + + public override IEnumerable Process(RenderContext context, IEnumerable renderables) + { + lock (_lock) + { + var result = new List(); + result.AddRange(renderables); + + if (_renderable != null) + { + result.Add(_renderable); + } + + _renderable = null; + + return result; + } + } + + private bool TryAdvance(int task, double percentage) + { + if (!_taskMilestones.TryGetValue(task, out var milestone)) + { + _taskMilestones.Add(task, FirstMilestone); + return true; + } + + if (percentage > milestone) + { + var nextMilestone = GetNextMilestone(percentage); + if (nextMilestone != null && _taskMilestones[task] != nextMilestone) + { + _taskMilestones[task] = nextMilestone.Value; + return true; + } + } + + return false; + } + + private static double? GetNextMilestone(double percentage) + { + return Array.Find(_milestones, p => p > percentage); + } + + private static IRenderable? BuildTaskGrid(List<(string Name, double Percentage)> updates) + { + if (updates.Count > 0) + { + var renderables = new List(); + foreach (var (name, percentage) in updates) + { + renderables.Add(new Markup($"[blue]{name}[/]: {(int)percentage}%")); + } + + return new Rows(renderables); + } + + return null; + } + } +} diff --git a/src/Spectre.Console/Recorder.cs b/src/Spectre.Console/Recorder.cs index 11271a1..f1ffadb 100644 --- a/src/Spectre.Console/Recorder.cs +++ b/src/Spectre.Console/Recorder.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text; using Spectre.Console.Rendering; @@ -8,7 +10,8 @@ namespace Spectre.Console /// /// A console recorder used to record output from a console. /// - public sealed class Recorder : IAnsiConsole, IDisposable + [SuppressMessage("Design", "CA1063:Implement IDisposable Correctly")] + public class Recorder : IAnsiConsole, IDisposable { private readonly IAnsiConsole _console; private readonly List _recorded; @@ -31,6 +34,14 @@ namespace Spectre.Console /// public int Height => _console.Height; + /// + public RenderPipeline Pipeline => _console.Pipeline; + + /// + /// Gets a list containing all recorded segments. + /// + protected List Recorded => _recorded; + /// /// Initializes a new instance of the class. /// @@ -42,6 +53,7 @@ namespace Spectre.Console } /// + [SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize")] public void Dispose() { // Only used for scoping. @@ -54,20 +66,25 @@ namespace Spectre.Console } /// - public void Write(Segment segment) + public void Write(IEnumerable segments) { - if (segment is null) + if (segments is null) { - throw new ArgumentNullException(nameof(segment)); + throw new ArgumentNullException(nameof(segments)); } - // Don't record control codes. - if (!segment.IsControlCode) - { - _recorded.Add(segment); - } + Record(segments); - _console.Write(segment); + _console.Write(segments); + } + + /// + /// Records the specified segments. + /// + /// The segments to be recorded. + protected virtual void Record(IEnumerable segments) + { + Recorded.AddRange(segments.Where(s => !s.IsControlCode)); } /// diff --git a/src/Spectre.Console/Rendering/IRenderHook.cs b/src/Spectre.Console/Rendering/IRenderHook.cs new file mode 100644 index 0000000..64ed57a --- /dev/null +++ b/src/Spectre.Console/Rendering/IRenderHook.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Spectre.Console.Rendering +{ + /// + /// Represents a render hook. + /// + public interface IRenderHook + { + /// + /// Processes the specified renderables. + /// + /// The render context. + /// The renderables to process. + /// The processed renderables. + IEnumerable Process(RenderContext context, IEnumerable renderables); + } +} diff --git a/src/Spectre.Console/Rendering/LiveRenderable.cs b/src/Spectre.Console/Rendering/LiveRenderable.cs new file mode 100644 index 0000000..fad49f8 --- /dev/null +++ b/src/Spectre.Console/Rendering/LiveRenderable.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console.Rendering +{ + internal sealed class LiveRenderable : Renderable + { + private readonly object _lock = new object(); + private IRenderable? _renderable; + private int? _height; + + public void SetRenderable(IRenderable renderable) + { + lock (_lock) + { + _renderable = renderable; + } + } + + public IRenderable PositionCursor() + { + lock (_lock) + { + if (_height == null) + { + return new ControlSequence(string.Empty); + } + + return new ControlSequence("\r" + "\u001b[1A".Repeat(_height.Value - 1)); + } + } + + public IRenderable RestoreCursor() + { + lock (_lock) + { + if (_height == null) + { + return new ControlSequence(string.Empty); + } + + return new ControlSequence("\r\u001b[2K" + "\u001b[1A\u001b[2K".Repeat(_height.Value - 1)); + } + } + + protected override IEnumerable Render(RenderContext context, int maxWidth) + { + lock (_lock) + { + if (_renderable != null) + { + var segments = _renderable.Render(context, maxWidth); + var lines = Segment.SplitLines(context, segments); + + _height = lines.Count; + + var result = new List(); + foreach (var (_, _, last, line) in lines.Enumerate()) + { + foreach (var item in line) + { + result.Add(item); + } + + if (!last) + { + result.Add(Segment.LineBreak); + } + } + + return result; + } + + _height = 0; + return Enumerable.Empty(); + } + } + } +} diff --git a/src/Spectre.Console/Rendering/RenderContext.cs b/src/Spectre.Console/Rendering/RenderContext.cs index 644b443..c50ca6c 100644 --- a/src/Spectre.Console/Rendering/RenderContext.cs +++ b/src/Spectre.Console/Rendering/RenderContext.cs @@ -49,7 +49,7 @@ namespace Spectre.Console.Rendering Encoding = encoding ?? throw new System.ArgumentNullException(nameof(encoding)); LegacyConsole = legacyConsole; Justification = justification; - Unicode = Encoding == Encoding.UTF8 || Encoding == Encoding.Unicode; + Unicode = Encoding.EncodingName.ContainsExact("Unicode"); SingleLine = singleLine; } diff --git a/src/Spectre.Console/Rendering/RenderHookScope.cs b/src/Spectre.Console/Rendering/RenderHookScope.cs new file mode 100644 index 0000000..c646bfb --- /dev/null +++ b/src/Spectre.Console/Rendering/RenderHookScope.cs @@ -0,0 +1,31 @@ +using System; + +namespace Spectre.Console.Rendering +{ + /// + /// Represents a render hook scope. + /// + public sealed class RenderHookScope : IDisposable + { + private readonly IAnsiConsole _console; + private readonly IRenderHook _hook; + + /// + /// Initializes a new instance of the class. + /// + /// The console to attach the render hook to. + /// The render hook. + public RenderHookScope(IAnsiConsole console, IRenderHook hook) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _hook = hook ?? throw new ArgumentNullException(nameof(hook)); + _console.Pipeline.Attach(_hook); + } + + /// + public void Dispose() + { + _console.Pipeline.Detach(_hook); + } + } +} diff --git a/src/Spectre.Console/Rendering/RenderPipeline.cs b/src/Spectre.Console/Rendering/RenderPipeline.cs new file mode 100644 index 0000000..c20ce1a --- /dev/null +++ b/src/Spectre.Console/Rendering/RenderPipeline.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; + +namespace Spectre.Console.Rendering +{ + /// + /// Represents the render pipeline. + /// + public sealed class RenderPipeline + { + private readonly List _hooks; + private readonly object _lock; + + /// + /// Initializes a new instance of the class. + /// + public RenderPipeline() + { + _hooks = new List(); + _lock = new object(); + } + + /// + /// Attaches a new render hook onto the pipeline. + /// + /// The render hook to attach. + public void Attach(IRenderHook hook) + { + lock (_lock) + { + _hooks.Add(hook); + } + } + + /// + /// Detaches a render hook from the pipeline. + /// + /// The render hook to detach. + public void Detach(IRenderHook hook) + { + lock (_lock) + { + _hooks.Remove(hook); + } + } + + /// + /// Processes the specified renderables. + /// + /// The render context. + /// The renderables to process. + /// The processed renderables. + public IEnumerable Process(RenderContext context, IEnumerable renderables) + { + lock (_lock) + { + var current = renderables; + for (var index = _hooks.Count - 1; index >= 0; index--) + { + current = _hooks[index].Process(context, current); + } + + return current; + } + } + } +} diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 0c3073e..46fcacd 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -72,7 +72,7 @@ namespace Spectre.Console.Rendering private Segment(string text, Style style, bool lineBreak, bool control) { - Text = text?.NormalizeLineEndings() ?? throw new ArgumentNullException(nameof(text)); + Text = text?.NormalizeNewLines() ?? throw new ArgumentNullException(nameof(text)); Style = style ?? throw new ArgumentNullException(nameof(style)); IsLineBreak = lineBreak; IsWhiteSpace = string.IsNullOrWhiteSpace(text); @@ -102,6 +102,11 @@ namespace Spectre.Console.Rendering throw new ArgumentNullException(nameof(context)); } + if (IsControlCode) + { + return 0; + } + return Text.CellLength(context); } @@ -477,16 +482,22 @@ namespace Spectre.Console.Rendering continue; } + // Both control codes? + if (segment.IsControlCode && previous.IsControlCode) + { + previous = Control(previous.Text + segment.Text); + continue; + } + // Same style? - if (previous.Style.Equals(segment.Style) && !previous.IsLineBreak) + if (previous.Style.Equals(segment.Style) && !previous.IsLineBreak && !previous.IsControlCode) { previous = new Segment(previous.Text + segment.Text, previous.Style); + continue; } - else - { - result.Add(previous); - previous = segment; - } + + result.Add(previous); + previous = segment; } if (previous != null) diff --git a/src/Spectre.Console/Widgets/ControlSequence.cs b/src/Spectre.Console/Widgets/ControlSequence.cs new file mode 100644 index 0000000..a577f15 --- /dev/null +++ b/src/Spectre.Console/Widgets/ControlSequence.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + internal sealed class ControlSequence : Renderable + { + private readonly Segment _segment; + + public ControlSequence(string control) + { + _segment = Segment.Control(control); + } + + protected override Measurement Measure(RenderContext context, int maxWidth) + { + return new Measurement(0, 0); + } + + protected override IEnumerable Render(RenderContext context, int maxWidth) + { + yield return _segment; + } + } +} diff --git a/src/Spectre.Console/Widgets/ProgressBar.cs b/src/Spectre.Console/Widgets/ProgressBar.cs new file mode 100644 index 0000000..3c4e742 --- /dev/null +++ b/src/Spectre.Console/Widgets/ProgressBar.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + internal sealed class ProgressBar : Renderable + { + public double Value { get; set; } + public double MaxValue { get; set; } = 100; + + public int? Width { get; set; } + + public Style CompletedStyle { get; set; } = new Style(foreground: Color.Yellow); + public Style FinishedStyle { get; set; } = new Style(foreground: Color.Green); + public Style RemainingStyle { get; set; } = new Style(foreground: Color.Grey); + + protected override Measurement Measure(RenderContext context, int maxWidth) + { + var width = Math.Min(Width ?? maxWidth, maxWidth); + return new Measurement(4, width); + } + + protected override IEnumerable Render(RenderContext context, int maxWidth) + { + var width = Math.Min(Width ?? maxWidth, maxWidth); + var completed = Math.Min(MaxValue, Math.Max(0, Value)); + + var token = !context.Unicode || context.LegacyConsole ? '-' : '━'; + var style = completed >= MaxValue ? FinishedStyle : CompletedStyle; + + var bars = Math.Max(0, (int)(width * (completed / MaxValue))); + yield return new Segment(new string(token, bars), style); + + if (bars < width) + { + yield return new Segment(new string(token, width - bars), RemainingStyle); + } + } + } +} diff --git a/src/Spectre.Console/Widgets/Rule.cs b/src/Spectre.Console/Widgets/Rule.cs index c3b9c2f..1078e2e 100644 --- a/src/Spectre.Console/Widgets/Rule.cs +++ b/src/Spectre.Console/Widgets/Rule.cs @@ -95,7 +95,7 @@ namespace Spectre.Console private IEnumerable GetTitleSegments(RenderContext context, string title, int width) { - title = title.NormalizeLineEndings().ReplaceExact("\n", " ").Trim(); + title = title.NormalizeNewLines().ReplaceExact("\n", " ").Trim(); var markup = new Markup(title, Style); return ((IRenderable)markup).Render(context.WithSingleLine(), width); } diff --git a/src/Spectre.Console/Widgets/Table.cs b/src/Spectre.Console/Widgets/Table.cs index f9c297b..e98f61e 100644 --- a/src/Spectre.Console/Widgets/Table.cs +++ b/src/Spectre.Console/Widgets/Table.cs @@ -16,8 +16,8 @@ namespace Spectre.Console private readonly List _columns; private readonly List _rows; - private static Style _defaultHeadingStyle = new Style(Color.Silver); - private static Style _defaultCaptionStyle = new Style(Color.Grey); + private static readonly Style _defaultHeadingStyle = new Style(Color.Silver); + private static readonly Style _defaultCaptionStyle = new Style(Color.Grey); /// /// Gets the table columns. @@ -447,12 +447,10 @@ namespace Spectre.Console private (int Min, int Max) MeasureColumn(TableColumn column, RenderContext options, int maxWidth) { - var padding = column.Padding?.GetWidth() ?? 0; - // Predetermined width? if (column.Width != null) { - return (column.Width.Value + padding, column.Width.Value + padding); + return (column.Width.Value, column.Width.Value); } var columnIndex = _columns.IndexOf(column); @@ -474,6 +472,8 @@ namespace Spectre.Console maxWidths.Add(rowMeasure.Max); } + var padding = column.Padding?.GetWidth() ?? 0; + return (minWidths.Count > 0 ? minWidths.Max() : padding, maxWidths.Count > 0 ? maxWidths.Max() : maxWidth); }