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
+[?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);
+        }
+    }
+}