From 8b9a6e50e6b93ccb09e1c7a860df79e986ea05b5 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 8 Jul 2020 16:18:40 -0500 Subject: [PATCH] AnsiControlCode formatting * Add support for AnsiControlCode formatting through IFormattable. * Introduce ConsoleFormatInfo for specifying if Ansi codes should be written or not. --- .../AnsiControlCodeTests.cs | 24 ++++ .../ConsoleFormatInfoTests.cs | 119 +++++++++++++++++ .../AnsiControlCode.cs | 11 +- .../ConsoleFormatInfo.cs | 121 ++++++++++++++++++ .../Interop.Windows.cs | 32 +++++ .../System.CommandLine.Rendering.csproj | 2 +- .../VirtualTerminalMode.cs | 27 +--- 7 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 src/System.CommandLine.Rendering.Tests/ConsoleFormatInfoTests.cs create mode 100644 src/System.CommandLine.Rendering/ConsoleFormatInfo.cs create mode 100644 src/System.CommandLine.Rendering/Interop.Windows.cs diff --git a/src/System.CommandLine.Rendering.Tests/AnsiControlCodeTests.cs b/src/System.CommandLine.Rendering.Tests/AnsiControlCodeTests.cs index 3f5f47d1f2..f9c572319a 100644 --- a/src/System.CommandLine.Rendering.Tests/AnsiControlCodeTests.cs +++ b/src/System.CommandLine.Rendering.Tests/AnsiControlCodeTests.cs @@ -42,5 +42,29 @@ public void Control_codes_with_nonequivalent_content_are_not_equal() .Should() .BeFalse(); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Control_codes_respect_ConsoleFormatInfo(bool supportsAnsiCodes) + { + IFormattable code = new AnsiControlCode($"{Ansi.Esc}[s"); + + IFormatProvider provider = new ConsoleFormatInfo() { SupportsAnsiCodes = supportsAnsiCodes }; + string output = code.ToString(null, provider); + + if (supportsAnsiCodes) + { + output + .Should() + .Contain(Ansi.Esc); + } + else + { + output + .Should() + .BeEmpty(); + } + } } } diff --git a/src/System.CommandLine.Rendering.Tests/ConsoleFormatInfoTests.cs b/src/System.CommandLine.Rendering.Tests/ConsoleFormatInfoTests.cs new file mode 100644 index 0000000000..8f581d5809 --- /dev/null +++ b/src/System.CommandLine.Rendering.Tests/ConsoleFormatInfoTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using FluentAssertions; +using System.CommandLine.Tests.Utility; +using System.Globalization; +using Xunit; + +namespace System.CommandLine.Rendering.Tests +{ + public class ConsoleFormatInfoTests + { + [Fact] + public void Can_create_modify_and_readonly_format_info() + { + var info = new ConsoleFormatInfo(); + info.IsReadOnly + .Should() + .BeFalse(); + + info.SupportsAnsiCodes = true; + info.SupportsAnsiCodes + .Should() + .BeTrue(); + + var readonlyInfo = ConsoleFormatInfo.ReadOnly(info); + readonlyInfo.IsReadOnly + .Should() + .BeTrue(); + + Assert.Throws(() => readonlyInfo.SupportsAnsiCodes = false); + } + + [Fact] + public void ReadOnly_throws_argnull() + { + Assert.Throws(() => ConsoleFormatInfo.ReadOnly(null)); + } + + [Fact] + public void Set_current_throws_argnull() + { + var info = new ConsoleFormatInfo(); + Assert.Throws(() => ConsoleFormatInfo.CurrentInfo = null); + } + + [Fact] + public void GetInstance_null_returns_current() + { + var info = ConsoleFormatInfo.GetInstance(null); + info.Should() + .BeSameAs(ConsoleFormatInfo.CurrentInfo); + } + + [Fact] + public void GetInstance_returns_same() + { + var info = new ConsoleFormatInfo(); + + var instance = ConsoleFormatInfo.GetInstance(info); + instance.Should() + .BeSameAs(info); + instance.Should() + .NotBeSameAs(ConsoleFormatInfo.CurrentInfo); + } + + [Fact] + public void GetInstance_calls_GetFormat_on_provider() + { + var info = new ConsoleFormatInfo(); + var provider = new MockFormatProvider() { TestInfo = info }; + + var instance = ConsoleFormatInfo.GetInstance(provider); + instance.Should() + .BeSameAs(info); + instance.Should() + .NotBeSameAs(ConsoleFormatInfo.CurrentInfo); + + provider.GetFormatCallCount + .Should() + .Be(1); + } + + private class MockFormatProvider : IFormatProvider + { + public int GetFormatCallCount { get; set; } + public ConsoleFormatInfo TestInfo { get; set; } + public object GetFormat(Type formatType) + { + GetFormatCallCount++; + + if (formatType == typeof(ConsoleFormatInfo)) + { + return TestInfo; + } + + throw new NotSupportedException(); + } + } + + [Fact] + public void GetFormat_returns_instance() + { + var info = new ConsoleFormatInfo(); + info.GetFormat(typeof(ConsoleFormatInfo)) + .Should() + .BeSameAs(info); + } + + [Fact] + public void GetFormat_returns_null() + { + var info = new ConsoleFormatInfo(); + info.GetFormat(typeof(NumberFormatInfo)) + .Should() + .BeNull(); + } + } +} diff --git a/src/System.CommandLine.Rendering/AnsiControlCode.cs b/src/System.CommandLine.Rendering/AnsiControlCode.cs index 169b55b818..7fc062f58a 100644 --- a/src/System.CommandLine.Rendering/AnsiControlCode.cs +++ b/src/System.CommandLine.Rendering/AnsiControlCode.cs @@ -6,7 +6,7 @@ namespace System.CommandLine.Rendering { [DebuggerStepThrough] - public class AnsiControlCode + public class AnsiControlCode : IFormattable { public AnsiControlCode(string escapeSequence) { @@ -22,6 +22,15 @@ public AnsiControlCode(string escapeSequence) public override string ToString() => ""; + public string ToString(string format, IFormatProvider provider) + { + ConsoleFormatInfo info = ConsoleFormatInfo.GetInstance(provider); + + return info.SupportsAnsiCodes ? + EscapeSequence : + string.Empty; + } + protected bool Equals(AnsiControlCode other) => string.Equals(EscapeSequence, other.EscapeSequence); public override bool Equals(object obj) diff --git a/src/System.CommandLine.Rendering/ConsoleFormatInfo.cs b/src/System.CommandLine.Rendering/ConsoleFormatInfo.cs new file mode 100644 index 0000000000..142dc04367 --- /dev/null +++ b/src/System.CommandLine.Rendering/ConsoleFormatInfo.cs @@ -0,0 +1,121 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.InteropServices; + +namespace System.CommandLine.Rendering +{ + public sealed class ConsoleFormatInfo : IFormatProvider + { + private static ConsoleFormatInfo s_currentInfo; + private bool _isReadOnly; + private bool _supportsAnsiCodes; + + public ConsoleFormatInfo() + { + } + + public static ConsoleFormatInfo CurrentInfo + { + get + { + return s_currentInfo ??= + InitializeCurrentInfo(); + } + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + s_currentInfo = ReadOnly(value); + } + } + + public bool SupportsAnsiCodes + { + get => _supportsAnsiCodes; + set + { + VerifyWritable(); + _supportsAnsiCodes = value; + } + } + + public bool IsReadOnly => _isReadOnly; + + public static ConsoleFormatInfo GetInstance(IFormatProvider formatProvider) + { + return formatProvider == null ? + CurrentInfo : // Fast path for a null provider + GetProviderNonNull(formatProvider); + + static ConsoleFormatInfo GetProviderNonNull(IFormatProvider provider) + { + return + provider as ConsoleFormatInfo ?? // Fast path for an CFI + provider.GetFormat(typeof(ConsoleFormatInfo)) as ConsoleFormatInfo ?? + CurrentInfo; + } + } + + public object GetFormat(Type formatType) => + formatType == typeof(ConsoleFormatInfo) ? this : null; + + public static ConsoleFormatInfo ReadOnly(ConsoleFormatInfo cfi) + { + if (cfi == null) + { + throw new ArgumentNullException(nameof(cfi)); + } + + if (cfi.IsReadOnly) + { + return cfi; + } + + ConsoleFormatInfo info = (ConsoleFormatInfo)cfi.MemberwiseClone(); + info._isReadOnly = true; + return info; + } + + private static ConsoleFormatInfo InitializeCurrentInfo() + { + bool supportsAnsi = + !Console.IsOutputRedirected && + DoesOperatingSystemSupportAnsi(); + + return new ConsoleFormatInfo() + { + _isReadOnly = true, + _supportsAnsiCodes = supportsAnsi + }; + } + + private static bool DoesOperatingSystemSupportAnsi() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return true; + } + + // for Windows, check the console mode + var stdOutHandle = Interop.GetStdHandle(Interop.STD_OUTPUT_HANDLE); + if (!Interop.GetConsoleMode(stdOutHandle, out uint consoleMode)) + { + return false; + } + + return (consoleMode & Interop.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == Interop.ENABLE_VIRTUAL_TERMINAL_PROCESSING; + } + + private void VerifyWritable() + { + if (_isReadOnly) + { + throw new InvalidOperationException("Instance is read-only."); + } + } + } +} diff --git a/src/System.CommandLine.Rendering/Interop.Windows.cs b/src/System.CommandLine.Rendering/Interop.Windows.cs new file mode 100644 index 0000000000..407d6ab40a --- /dev/null +++ b/src/System.CommandLine.Rendering/Interop.Windows.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.InteropServices; + +namespace System.CommandLine.Rendering +{ + internal static class Interop + { + public const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004; + + public const uint ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; + + public const uint DISABLE_NEWLINE_AUTO_RETURN = 0x0008; + + public const int STD_OUTPUT_HANDLE = -11; + + public const int STD_INPUT_HANDLE = -10; + + [DllImport("kernel32.dll")] + public static extern bool GetConsoleMode(IntPtr handle, out uint mode); + + [DllImport("kernel32.dll")] + public static extern uint GetLastError(); + + [DllImport("kernel32.dll")] + public static extern bool SetConsoleMode(IntPtr handle, uint mode); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetStdHandle(int handle); + } +} diff --git a/src/System.CommandLine.Rendering/System.CommandLine.Rendering.csproj b/src/System.CommandLine.Rendering/System.CommandLine.Rendering.csproj index d552286ed4..54b32a6265 100644 --- a/src/System.CommandLine.Rendering/System.CommandLine.Rendering.csproj +++ b/src/System.CommandLine.Rendering/System.CommandLine.Rendering.csproj @@ -3,7 +3,7 @@ true netstandard2.0 - 7.3 + 8 This package provides support for structured command line output rendering. Write code once that renders correctly in multiple output modes, including System.Console, virtual terminal (using ANSI escape sequences), and plain text. diff --git a/src/System.CommandLine.Rendering/VirtualTerminalMode.cs b/src/System.CommandLine.Rendering/VirtualTerminalMode.cs index b81ce153d5..7f38b204d9 100644 --- a/src/System.CommandLine.Rendering/VirtualTerminalMode.cs +++ b/src/System.CommandLine.Rendering/VirtualTerminalMode.cs @@ -2,37 +2,12 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Runtime.InteropServices; +using static System.CommandLine.Rendering.Interop; namespace System.CommandLine.Rendering { public sealed class VirtualTerminalMode : IDisposable { - private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004; - - private const uint ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; - - private const uint DISABLE_NEWLINE_AUTO_RETURN = 0x0008; - - private const int STD_OUTPUT_HANDLE = -11; - - private const int STD_INPUT_HANDLE = -10; - - [DllImport("kernel32.dll")] - private static extern bool GetConsoleMode( - IntPtr handle, - out uint mode); - - [DllImport("kernel32.dll")] - private static extern uint GetLastError(); - - [DllImport("kernel32.dll")] - private static extern bool SetConsoleMode( - IntPtr handle, - uint mode); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr GetStdHandle(int handle); - private readonly IntPtr _stdOutHandle; private readonly IntPtr _stdInHandle; private readonly uint _originalOutputMode;