From fd4b96944e321ad6b03459768f04f2ede82d122f Mon Sep 17 00:00:00 2001 From: Patrik Svensson <patrik@patriksvensson.se> Date: Tue, 30 Nov 2021 13:15:17 +0100 Subject: [PATCH] Add support for alternate screen buffers Closes #250 --- .github/workflows/ci.yaml | 2 +- .../AlternateScreen/AlternateScreen.csproj | 15 ++++ examples/Console/AlternateScreen/Program.cs | 26 ++++++ examples/Examples.sln | 16 +++- .../Internal/NoopExclusivityMode.cs | 2 +- .../TestConsoleInput.cs | 6 ++ src/Spectre.Console/AnsiConsole.Screen.cs | 19 +++++ src/Spectre.Console/AnsiConsoleFactory.cs | 1 + src/Spectre.Console/Capabilities.cs | 6 ++ .../AnsiConsoleExtensions.Exclusive.cs | 2 +- .../AnsiConsoleExtensions.Screen.cs | 48 +++++++++++ src/Spectre.Console/IAnsiConsoleInput.cs | 7 ++ src/Spectre.Console/IExclusivityMode.cs | 2 +- .../Internal/DefaultExclusivityMode.cs | 2 +- src/Spectre.Console/Internal/DefaultInput.cs | 20 +++-- .../AlternateScreen/Show.Output.verified.txt | 3 + .../Unit/AlternateScreenTests.cs | 79 +++++++++++++++++++ 17 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 examples/Console/AlternateScreen/AlternateScreen.csproj create mode 100644 examples/Console/AlternateScreen/Program.cs create mode 100644 src/Spectre.Console/AnsiConsole.Screen.cs create mode 100644 src/Spectre.Console/Extensions/AnsiConsoleExtensions.Screen.cs create mode 100644 test/Spectre.Console.Tests/Expectations/AlternateScreen/Show.Output.verified.txt create mode 100644 test/Spectre.Console.Tests/Unit/AlternateScreenTests.cs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7481fec..e236adf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -93,7 +93,7 @@ jobs: shell: bash run: | dotnet tool restore - dotnet example --all --skip live --skip livetable --skip prompt + dotnet example --all --skip live --skip livetable --skip prompt --skip screens - name: Build shell: bash diff --git a/examples/Console/AlternateScreen/AlternateScreen.csproj b/examples/Console/AlternateScreen/AlternateScreen.csproj new file mode 100644 index 0000000..24be5e7 --- /dev/null +++ b/examples/Console/AlternateScreen/AlternateScreen.csproj @@ -0,0 +1,15 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net6.0</TargetFramework> + <ExampleTitle>Screens</ExampleTitle> + <ExampleDescription>Demonstrates how to use alternate screens.</ExampleDescription> + <ExampleGroup>Widgets</ExampleGroup> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\Shared\Shared.csproj" /> + </ItemGroup> + +</Project> diff --git a/examples/Console/AlternateScreen/Program.cs b/examples/Console/AlternateScreen/Program.cs new file mode 100644 index 0000000..4415ab0 --- /dev/null +++ b/examples/Console/AlternateScreen/Program.cs @@ -0,0 +1,26 @@ +// Check if we can use alternate screen buffers +using Spectre.Console; + +if (!AnsiConsole.Profile.Capabilities.AlternateBuffer) +{ + AnsiConsole.MarkupLine( + "[red]Alternate screen buffers are not supported " + + "by your terminal[/] [yellow]:([/]"); + + return; +} + +// Write to the terminal +AnsiConsole.Write(new Rule("[yellow]Normal universe[/]")); +AnsiConsole.Write(new Panel("Hello World!")); +AnsiConsole.MarkupLine("[grey]Press a key to continue[/]"); +AnsiConsole.Console.Input.ReadKey(true); + +AnsiConsole.AlternateScreen(() => +{ + // Now we're in another terminal screen buffer + AnsiConsole.Write(new Rule("[red]Mirror universe[/]")); + AnsiConsole.Write(new Panel("[red]Welcome to the upside down![/]")); + AnsiConsole.MarkupLine("[grey]Press a key to return[/]"); + AnsiConsole.Console.Input.ReadKey(true); +}); \ No newline at end of file diff --git a/examples/Examples.sln b/examples/Examples.sln index 079c546..dc89d2f 100644 --- a/examples/Examples.sln +++ b/examples/Examples.sln @@ -65,7 +65,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Trees", "Console\Trees\Tree EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiveTable", "Console\LiveTable\LiveTable.csproj", "{E5FAAFB4-1D0F-4E29-A94F-A647D64AE64E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Minimal", "Console\Minimal\Minimal.csproj", "{1780A30A-397A-4CC3-B2A0-A385D9081FA2}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Minimal", "Console\Minimal\Minimal.csproj", "{1780A30A-397A-4CC3-B2A0-A385D9081FA2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AlternateScreen", "Console\AlternateScreen\AlternateScreen.csproj", "{8A3B636E-5828-438B-A8F4-83811D2704CD}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -437,6 +439,18 @@ Global {1780A30A-397A-4CC3-B2A0-A385D9081FA2}.Release|x64.Build.0 = Release|Any CPU {1780A30A-397A-4CC3-B2A0-A385D9081FA2}.Release|x86.ActiveCfg = Release|Any CPU {1780A30A-397A-4CC3-B2A0-A385D9081FA2}.Release|x86.Build.0 = Release|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Debug|x64.Build.0 = Debug|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Debug|x86.Build.0 = Debug|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Release|Any CPU.Build.0 = Release|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Release|x64.ActiveCfg = Release|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Release|x64.Build.0 = Release|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Release|x86.ActiveCfg = Release|Any CPU + {8A3B636E-5828-438B-A8F4-83811D2704CD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Spectre.Console.Testing/Internal/NoopExclusivityMode.cs b/src/Spectre.Console.Testing/Internal/NoopExclusivityMode.cs index bcc9be6..dd5aab5 100644 --- a/src/Spectre.Console.Testing/Internal/NoopExclusivityMode.cs +++ b/src/Spectre.Console.Testing/Internal/NoopExclusivityMode.cs @@ -10,7 +10,7 @@ namespace Spectre.Console.Testing return func(); } - public async Task<T> Run<T>(Func<Task<T>> func) + public async Task<T> RunAsync<T>(Func<Task<T>> func) { return await func().ConfigureAwait(false); } diff --git a/src/Spectre.Console.Testing/TestConsoleInput.cs b/src/Spectre.Console.Testing/TestConsoleInput.cs index 837dff9..1342e29 100644 --- a/src/Spectre.Console.Testing/TestConsoleInput.cs +++ b/src/Spectre.Console.Testing/TestConsoleInput.cs @@ -66,6 +66,12 @@ namespace Spectre.Console.Testing _input.Enqueue(new ConsoleKeyInfo((char)input, input, false, false, false)); } + /// <inheritdoc/> + public bool IsKeyAvailable() + { + return _input.Count > 0; + } + /// <inheritdoc/> public ConsoleKeyInfo? ReadKey(bool intercept) { diff --git a/src/Spectre.Console/AnsiConsole.Screen.cs b/src/Spectre.Console/AnsiConsole.Screen.cs new file mode 100644 index 0000000..69e46ba --- /dev/null +++ b/src/Spectre.Console/AnsiConsole.Screen.cs @@ -0,0 +1,19 @@ +using System; + +namespace Spectre.Console +{ + /// <summary> + /// A console capable of writing ANSI escape sequences. + /// </summary> + public static partial class AnsiConsole + { + /// <summary> + /// Switches to an alternate screen buffer if the terminal supports it. + /// </summary> + /// <param name="action">The action to execute within the alternate screen buffer.</param> + public static void AlternateScreen(Action action) + { + Console.AlternateScreen(action); + } + } +} diff --git a/src/Spectre.Console/AnsiConsoleFactory.cs b/src/Spectre.Console/AnsiConsoleFactory.cs index 3d8e0e5..178ea9d 100644 --- a/src/Spectre.Console/AnsiConsoleFactory.cs +++ b/src/Spectre.Console/AnsiConsoleFactory.cs @@ -55,6 +55,7 @@ namespace Spectre.Console profile.Capabilities.Legacy = legacyConsole; profile.Capabilities.Interactive = interactive; profile.Capabilities.Unicode = encoding.EncodingName.ContainsExact("Unicode"); + profile.Capabilities.AlternateBuffer = supportsAnsi && !legacyConsole; // Enrich the profile ProfileEnricher.Enrich( diff --git a/src/Spectre.Console/Capabilities.cs b/src/Spectre.Console/Capabilities.cs index 3419640..3e2a168 100644 --- a/src/Spectre.Console/Capabilities.cs +++ b/src/Spectre.Console/Capabilities.cs @@ -55,6 +55,12 @@ namespace Spectre.Console /// </summary> public bool Unicode { get; set; } + /// <summary> + /// Gets or sets a value indicating whether + /// or not the console supports alternate buffers. + /// </summary> + public bool AlternateBuffer { get; set; } + /// <summary> /// Initializes a new instance of the /// <see cref="Capabilities"/> class. diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exclusive.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exclusive.cs index 10ba8e6..0bdc535 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exclusive.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exclusive.cs @@ -29,7 +29,7 @@ namespace Spectre.Console /// <returns>The result of the function.</returns> public static Task<T> RunExclusive<T>(this IAnsiConsole console, Func<Task<T>> func) { - return console.ExclusivityMode.Run(func); + return console.ExclusivityMode.RunAsync(func); } } } diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Screen.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Screen.cs new file mode 100644 index 0000000..1491bfb --- /dev/null +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Screen.cs @@ -0,0 +1,48 @@ +using System; + +namespace Spectre.Console +{ + /// <summary> + /// Contains extension methods for <see cref="IAnsiConsole"/>. + /// </summary> + public static partial class AnsiConsoleExtensions + { + /// <summary> + /// Switches to an alternate screen buffer if the terminal supports it. + /// </summary> + /// <param name="console">The console.</param> + /// <param name="action">The action to execute within the alternate screen buffer.</param> + public static void AlternateScreen(this IAnsiConsole console, Action action) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + if (!console.Profile.Capabilities.Ansi) + { + throw new NotSupportedException("Alternate buffers are not supported since your terminal does not support ANSI."); + } + + if (!console.Profile.Capabilities.AlternateBuffer) + { + throw new NotSupportedException("Alternate buffers are not supported by your terminal."); + } + + console.ExclusivityMode.Run<object?>(() => + { + // Switch to alternate screen + console.Write(new ControlCode("\u001b[?1049h\u001b[H")); + + // Execute custom action + action(); + + // Switch back to primary screen + console.Write(new ControlCode("\u001b[?1049l")); + + // Dummy result + return null; + }); + } + } +} diff --git a/src/Spectre.Console/IAnsiConsoleInput.cs b/src/Spectre.Console/IAnsiConsoleInput.cs index a23c823..5504354 100644 --- a/src/Spectre.Console/IAnsiConsoleInput.cs +++ b/src/Spectre.Console/IAnsiConsoleInput.cs @@ -9,6 +9,13 @@ namespace Spectre.Console /// </summary> public interface IAnsiConsoleInput { + /// <summary> + /// Gets a value indicating whether or not + /// there is a key available. + /// </summary> + /// <returns><c>true</c> if there's a key available, otherwise <c>false</c>.</returns> + bool IsKeyAvailable(); + /// <summary> /// Reads a key from the console. /// </summary> diff --git a/src/Spectre.Console/IExclusivityMode.cs b/src/Spectre.Console/IExclusivityMode.cs index fae0c41..8fa1b29 100644 --- a/src/Spectre.Console/IExclusivityMode.cs +++ b/src/Spectre.Console/IExclusivityMode.cs @@ -22,6 +22,6 @@ namespace Spectre.Console /// <typeparam name="T">The result type.</typeparam> /// <param name="func">The func to run in exclusive mode.</param> /// <returns>The result of the function.</returns> - Task<T> Run<T>(Func<Task<T>> func); + Task<T> RunAsync<T>(Func<Task<T>> func); } } diff --git a/src/Spectre.Console/Internal/DefaultExclusivityMode.cs b/src/Spectre.Console/Internal/DefaultExclusivityMode.cs index 1c675e9..02bd830 100644 --- a/src/Spectre.Console/Internal/DefaultExclusivityMode.cs +++ b/src/Spectre.Console/Internal/DefaultExclusivityMode.cs @@ -31,7 +31,7 @@ namespace Spectre.Console.Internal } } - public async Task<T> Run<T>(Func<Task<T>> func) + public async Task<T> RunAsync<T>(Func<Task<T>> func) { // Try acquiring the exclusivity semaphore if (!await _semaphore.WaitAsync(0).ConfigureAwait(false)) diff --git a/src/Spectre.Console/Internal/DefaultInput.cs b/src/Spectre.Console/Internal/DefaultInput.cs index 846403c..2b65a25 100644 --- a/src/Spectre.Console/Internal/DefaultInput.cs +++ b/src/Spectre.Console/Internal/DefaultInput.cs @@ -13,6 +13,16 @@ namespace Spectre.Console _profile = profile ?? throw new ArgumentNullException(nameof(profile)); } + public bool IsKeyAvailable() + { + if (!_profile.Capabilities.Interactive) + { + throw new InvalidOperationException("Failed to read input in non-interactive mode."); + } + + return System.Console.KeyAvailable; + } + public ConsoleKeyInfo? ReadKey(bool intercept) { if (!_profile.Capabilities.Interactive) @@ -20,16 +30,16 @@ namespace Spectre.Console throw new InvalidOperationException("Failed to read input in non-interactive mode."); } - if (!System.Console.KeyAvailable) - { - return null; - } - return System.Console.ReadKey(intercept); } public async Task<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken) { + if (!_profile.Capabilities.Interactive) + { + throw new InvalidOperationException("Failed to read input in non-interactive mode."); + } + while (true) { if (cancellationToken.IsCancellationRequested) diff --git a/test/Spectre.Console.Tests/Expectations/AlternateScreen/Show.Output.verified.txt b/test/Spectre.Console.Tests/Expectations/AlternateScreen/Show.Output.verified.txt new file mode 100644 index 0000000..205b2d0 --- /dev/null +++ b/test/Spectre.Console.Tests/Expectations/AlternateScreen/Show.Output.verified.txt @@ -0,0 +1,3 @@ +Foo +[?1049h[HBar +[?1049l \ No newline at end of file diff --git a/test/Spectre.Console.Tests/Unit/AlternateScreenTests.cs b/test/Spectre.Console.Tests/Unit/AlternateScreenTests.cs new file mode 100644 index 0000000..038f9ae --- /dev/null +++ b/test/Spectre.Console.Tests/Unit/AlternateScreenTests.cs @@ -0,0 +1,79 @@ +using System.Threading.Tasks; +using Shouldly; +using Spectre.Console.Testing; +using Spectre.Verify.Extensions; +using VerifyXunit; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + [UsesVerify] + [ExpectationPath("AlternateScreen")] + public sealed class AlternateScreenTests + { + [Fact] + public void Should_Throw_If_Alternative_Buffer_Is_Not_Supported_By_Terminal() + { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.AlternateBuffer = false; + + // When + var result = Record.Exception(() => + { + console.WriteLine("Foo"); + console.AlternateScreen(() => + { + console.WriteLine("Bar"); + }); + }); + + // Then + result.ShouldNotBeNull(); + result.Message.ShouldBe("Alternate buffers are not supported by your terminal."); + } + + [Fact] + public void Should_Throw_If_Ansi_Is_Not_Supported_By_Terminal() + { + // Given + var console = new TestConsole(); + console.Profile.Capabilities.Ansi = false; + console.Profile.Capabilities.AlternateBuffer = true; + + // When + var result = Record.Exception(() => + { + console.WriteLine("Foo"); + console.AlternateScreen(() => + { + console.WriteLine("Bar"); + }); + }); + + // Then + result.ShouldNotBeNull(); + result.Message.ShouldBe("Alternate buffers are not supported since your terminal does not support ANSI."); + } + + [Fact] + [Expectation("Show")] + public async Task Should_Write_To_Alternate_Screen() + { + // Given + var console = new TestConsole(); + console.EmitAnsiSequences = true; + console.Profile.Capabilities.AlternateBuffer = true; + + // When + console.WriteLine("Foo"); + console.AlternateScreen(() => + { + console.WriteLine("Bar"); + }); + + // Then + await Verifier.Verify(console.Output); + } + } +}