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 @@ + + + + Exe + net6.0 + Screens + Demonstrates how to use alternate screens. + Widgets + + + + + + + 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 Run(Func> func) + public async Task RunAsync(Func> 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)); } + /// + public bool IsKeyAvailable() + { + return _input.Count > 0; + } + /// 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 +{ + /// + /// A console capable of writing ANSI escape sequences. + /// + public static partial class AnsiConsole + { + /// + /// Switches to an alternate screen buffer if the terminal supports it. + /// + /// The action to execute within the alternate screen buffer. + 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 /// public bool Unicode { get; set; } + /// + /// Gets or sets a value indicating whether + /// or not the console supports alternate buffers. + /// + public bool AlternateBuffer { get; set; } + /// /// Initializes a new instance of the /// 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 /// The result of the function. public static Task RunExclusive(this IAnsiConsole console, Func> 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 +{ + /// + /// Contains extension methods for . + /// + public static partial class AnsiConsoleExtensions + { + /// + /// Switches to an alternate screen buffer if the terminal supports it. + /// + /// The console. + /// The action to execute within the alternate screen buffer. + 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(() => + { + // 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 /// public interface IAnsiConsoleInput { + /// + /// Gets a value indicating whether or not + /// there is a key available. + /// + /// true if there's a key available, otherwise false. + bool IsKeyAvailable(); + /// /// Reads a key from the console. /// 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 /// The result type. /// The func to run in exclusive mode. /// The result of the function. - Task Run(Func> func); + Task RunAsync(Func> 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 Run(Func> func) + public async Task RunAsync(Func> 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 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 +[?1049hBar +[?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); + } + } +}