diff --git a/src/Spectre.Console.Tests/Tools/AnsiConsoleFixture.cs b/src/Spectre.Console.Tests/Tools/AnsiConsoleFixture.cs index 1bacf34..7101d6d 100644 --- a/src/Spectre.Console.Tests/Tools/AnsiConsoleFixture.cs +++ b/src/Spectre.Console.Tests/Tools/AnsiConsoleFixture.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text; +using Spectre.Console.Rendering; using Spectre.Console.Tests.Tools; namespace Spectre.Console.Tests @@ -36,9 +37,9 @@ namespace Spectre.Console.Tests _writer?.Dispose(); } - public void Write(string text, Style style) + public void Write(Segment segment) { - _console.Write(text, style); + _console.Write(segment); } } } diff --git a/src/Spectre.Console.Tests/Tools/PlainConsole.cs b/src/Spectre.Console.Tests/Tools/PlainConsole.cs index af2f169..e793b28 100644 --- a/src/Spectre.Console.Tests/Tools/PlainConsole.cs +++ b/src/Spectre.Console.Tests/Tools/PlainConsole.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Text; +using Spectre.Console.Rendering; namespace Spectre.Console.Tests { @@ -40,9 +41,14 @@ namespace Spectre.Console.Tests Writer.Dispose(); } - public void Write(string text, Style style) + public void Write(Segment segment) { - Writer.Write(text); + if (segment is null) + { + throw new ArgumentNullException(nameof(segment)); + } + + Writer.Write(segment.Text); } } } diff --git a/src/Spectre.Console.Tests/Unit/RecorderTests.cs b/src/Spectre.Console.Tests/Unit/RecorderTests.cs new file mode 100644 index 0000000..7a129ea --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/RecorderTests.cs @@ -0,0 +1,66 @@ +using Shouldly; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + public sealed class RecorderTests + { + [Fact] + public void Should_Export_Text_As_Expected() + { + // Given + var console = new PlainConsole(); + var recorder = new Recorder(console); + + recorder.Render(new Table() + .AddColumns("Foo", "Bar", "Qux") + .AddRow("Corgi", "Waldo", "Zap") + .AddRow(new Panel("Hello World").RoundedBorder())); + + // When + var result = recorder.ExportText().Split(new[] { '\n' }); + + // Then + result.Length.ShouldBe(8); + result[0].ShouldBe("┌─────────────────┬───────┬─────┐"); + result[1].ShouldBe("│ Foo │ Bar │ Qux │"); + result[2].ShouldBe("├─────────────────┼───────┼─────┤"); + result[3].ShouldBe("│ Corgi │ Waldo │ Zap │"); + result[4].ShouldBe("│ ╭─────────────╮ │ │ │"); + result[5].ShouldBe("│ │ Hello World │ │ │ │"); + result[6].ShouldBe("│ ╰─────────────╯ │ │ │"); + result[7].ShouldBe("└─────────────────┴───────┴─────┘"); + } + + [Fact] + public void Should_Export_Html_As_Expected() + { + // Given + var console = new PlainConsole(); + var recorder = new Recorder(console); + + recorder.Render(new Table() + .AddColumns("[red on black]Foo[/]", "[green bold]Bar[/]", "[blue italic]Qux[/]") + .AddRow("[invert underline]Corgi[/]", "[bold strikethrough]Waldo[/]", "[dim]Zap[/]") + .AddRow(new Panel("[blue]Hello World[/]") + .SetBorderColor(Color.Red).RoundedBorder())); + + // When + var html = recorder.ExportHtml(); + var result = html.Split(new[] { '\n' }); + + // Then + result.Length.ShouldBe(10); + result[0].ShouldBe("
");
+            result[1].ShouldBe("┌─────────────────┬───────┬─────┐");
+            result[2].ShouldBe("FooBarQux");
+            result[3].ShouldBe("├─────────────────┼───────┼─────┤");
+            result[4].ShouldBe("CorgiWaldoZap");
+            result[5].ShouldBe("╭─────────────╮ │       │     │");
+            result[6].ShouldBe(" Hello World  │       │     │");
+            result[7].ShouldBe("╰─────────────╯ │       │     │");
+            result[8].ShouldBe("└─────────────────┴───────┴─────┘");
+            result[9].ShouldBe("
"); + } + } +} diff --git a/src/Spectre.Console/AnsiConsole.Recording.cs b/src/Spectre.Console/AnsiConsole.Recording.cs new file mode 100644 index 0000000..b3c5a09 --- /dev/null +++ b/src/Spectre.Console/AnsiConsole.Recording.cs @@ -0,0 +1,66 @@ +using System; + +namespace Spectre.Console +{ + /// + /// A console capable of writing ANSI escape sequences. + /// + public static partial class AnsiConsole + { + /// + /// Starts recording the console output. + /// + public static void Record() + { + _recorder = new Recorder(_console.Value); + } + + /// + /// Exports all recorded console output as text. + /// + /// The recorded output as text. + public static string ExportText() + { + if (_recorder == null) + { + throw new InvalidOperationException("Cannot export text since a recording hasn't been started."); + } + + return _recorder.ExportText(); + } + + /// + /// Exports all recorded console output as HTML. + /// + /// The recorded output as HTML. + public static string ExportHtml() + { + if (_recorder == null) + { + throw new InvalidOperationException("Cannot export HTML since a recording hasn't been started."); + } + + return _recorder.ExportHtml(); + } + + /// + /// Exports all recorded console output using a custom encoder. + /// + /// The encoder to use. + /// The recorded output. + public static string ExportCustom(IAnsiConsoleEncoder encoder) + { + if (_recorder == null) + { + throw new InvalidOperationException("Cannot export HTML since a recording hasn't been started."); + } + + if (encoder is null) + { + throw new ArgumentNullException(nameof(encoder)); + } + + return _recorder.Export(encoder); + } + } +} diff --git a/src/Spectre.Console/AnsiConsole.cs b/src/Spectre.Console/AnsiConsole.cs index 3bed250..40ae22d 100644 --- a/src/Spectre.Console/AnsiConsole.cs +++ b/src/Spectre.Console/AnsiConsole.cs @@ -21,10 +21,12 @@ namespace Spectre.Console return console; }); + private static Recorder? _recorder; + /// /// Gets the underlying . /// - public static IAnsiConsole Console => _console.Value; + public static IAnsiConsole Console => _recorder ?? _console.Value; /// /// Gets the console's capabilities. diff --git a/src/Spectre.Console/Color.cs b/src/Spectre.Console/Color.cs index 77330de..12a990a 100644 --- a/src/Spectre.Console/Color.cs +++ b/src/Spectre.Console/Color.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.Globalization; +using System.Security.Cryptography; using Spectre.Console.Internal; namespace Spectre.Console @@ -60,6 +61,35 @@ namespace Spectre.Console Number = null; } + /// + /// Blends two colors. + /// + /// The other color. + /// The blend factor. + /// The resulting color. + public Color Blend(Color other, float factor) + { + // https://github.com/willmcgugan/rich/blob/f092b1d04252e6f6812021c0f415dd1d7be6a16a/rich/color.py#L494 + return new Color( + (byte)(R + ((other.R - R) * factor)), + (byte)(G + ((other.G - G) * factor)), + (byte)(B + ((other.B - B) * factor))); + } + + /// + /// Gets the hexadecimal representation of the color. + /// + /// The hexadecimal representation of the color. + public string ToHex() + { + return string.Format( + CultureInfo.InvariantCulture, + "{0}{1}{2}", + R.ToString("X2", CultureInfo.InvariantCulture), + G.ToString("X2", CultureInfo.InvariantCulture), + B.ToString("X2", CultureInfo.InvariantCulture)); + } + /// public override int GetHashCode() { diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs index b8f659a..9a2abc4 100644 --- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs +++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.cs @@ -1,4 +1,5 @@ using System; +using Spectre.Console.Rendering; namespace Spectre.Console { @@ -7,6 +8,32 @@ namespace Spectre.Console /// public static partial class AnsiConsoleExtensions { + /// + /// Creates a recorder for the specified console. + /// + /// The console to record. + /// A recorder for the specified console. + public static Recorder CreateRecorder(this IAnsiConsole console) + { + return new Recorder(console); + } + + /// + /// Writes the specified string value to the console. + /// + /// The console to write to. + /// The text to write. + /// The text style. + public static void Write(this IAnsiConsole console, string text, Style style) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + console.Write(new Segment(text, style)); + } + /// /// Writes an empty line to the console. /// @@ -34,7 +61,7 @@ namespace Spectre.Console throw new ArgumentNullException(nameof(console)); } - console.Write(text, style); + console.Write(new Segment(text, style)); console.WriteLine(); } } diff --git a/src/Spectre.Console/Extensions/RecorderExtensions.cs b/src/Spectre.Console/Extensions/RecorderExtensions.cs new file mode 100644 index 0000000..4b432a5 --- /dev/null +++ b/src/Spectre.Console/Extensions/RecorderExtensions.cs @@ -0,0 +1,44 @@ +using System; +using Spectre.Console.Internal; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class RecorderExtensions + { + private static readonly TextEncoder _textEncoder = new TextEncoder(); + private static readonly HtmlEncoder _htmlEncoder = new HtmlEncoder(); + + /// + /// Exports the recorded content as text. + /// + /// The recorder. + /// The recorded content as text. + public static string ExportText(this Recorder recorder) + { + if (recorder is null) + { + throw new ArgumentNullException(nameof(recorder)); + } + + return recorder.Export(_textEncoder); + } + + /// + /// Exports the recorded content as HTML. + /// + /// The recorder. + /// The recorded content as HTML. + public static string ExportHtml(this Recorder recorder) + { + if (recorder is null) + { + throw new ArgumentNullException(nameof(recorder)); + } + + return recorder.Export(_htmlEncoder); + } + } +} diff --git a/src/Spectre.Console/IAnsiConsole.cs b/src/Spectre.Console/IAnsiConsole.cs index d12d885..a4334b9 100644 --- a/src/Spectre.Console/IAnsiConsole.cs +++ b/src/Spectre.Console/IAnsiConsole.cs @@ -1,4 +1,5 @@ using System.Text; +using Spectre.Console.Rendering; namespace Spectre.Console { @@ -30,8 +31,7 @@ namespace Spectre.Console /// /// Writes a string followed by a line terminator to the console. /// - /// The string to write. - /// The style to use. - void Write(string text, Style style); + /// The segment to write. + void Write(Segment segment); } } diff --git a/src/Spectre.Console/IAnsiConsoleEncoder.cs b/src/Spectre.Console/IAnsiConsoleEncoder.cs new file mode 100644 index 0000000..9d2d75e --- /dev/null +++ b/src/Spectre.Console/IAnsiConsoleEncoder.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Represents a console encoder that can encode + /// recorded segments into a string. + /// + public interface IAnsiConsoleEncoder + { + /// + /// Encodes the specified segments. + /// + /// The segments to encode. + /// The encoded string. + string Encode(IEnumerable segments); + } +} diff --git a/src/Spectre.Console/Internal/AnsiConsoleRenderer.cs b/src/Spectre.Console/Internal/AnsiConsoleRenderer.cs index 1772a14..51810fe 100644 --- a/src/Spectre.Console/Internal/AnsiConsoleRenderer.cs +++ b/src/Spectre.Console/Internal/AnsiConsoleRenderer.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text; +using Spectre.Console.Rendering; namespace Spectre.Console.Internal { @@ -48,21 +49,14 @@ namespace Spectre.Console.Internal _ansiBuilder = new AnsiBuilder(Capabilities, linkHasher); } - public void Write(string text, Style style) + public void Write(Segment segment) { - if (string.IsNullOrEmpty(text)) - { - return; - } - - style ??= Style.Plain; - - var parts = text.NormalizeLineEndings().Split(new[] { '\n' }); + var parts = segment.Text.NormalizeLineEndings().Split(new[] { '\n' }); foreach (var (_, _, last, part) in parts.Enumerate()) { if (!string.IsNullOrEmpty(part)) { - _out.Write(_ansiBuilder.GetAnsi(part, style)); + _out.Write(_ansiBuilder.GetAnsi(part, segment.Style)); } if (!last) diff --git a/src/Spectre.Console/Internal/FallbackConsoleRenderer.cs b/src/Spectre.Console/Internal/FallbackConsoleRenderer.cs index 799766f..150e961 100644 --- a/src/Spectre.Console/Internal/FallbackConsoleRenderer.cs +++ b/src/Spectre.Console/Internal/FallbackConsoleRenderer.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text; +using Spectre.Console.Rendering; namespace Spectre.Console.Internal { @@ -61,14 +62,14 @@ namespace Spectre.Console.Internal Capabilities = capabilities; } - public void Write(string text, Style style) + public void Write(Segment segment) { - if (_lastStyle?.Equals(style) != true) + if (_lastStyle?.Equals(segment.Style) != true) { - SetStyle(style); + SetStyle(segment.Style); } - _out.Write(text.NormalizeLineEndings(native: true)); + _out.Write(segment.Text.NormalizeLineEndings(native: true)); } private void SetStyle(Style style) diff --git a/src/Spectre.Console/Internal/HtmlEncoder.cs b/src/Spectre.Console/Internal/HtmlEncoder.cs new file mode 100644 index 0000000..074a344 --- /dev/null +++ b/src/Spectre.Console/Internal/HtmlEncoder.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Spectre.Console.Rendering; + +namespace Spectre.Console.Internal +{ + internal sealed class HtmlEncoder : IAnsiConsoleEncoder + { + public string Encode(IEnumerable segments) + { + var builder = new StringBuilder(); + + builder.Append("
\n");
+
+            foreach (var (_, first, _, segment) in segments.Enumerate())
+            {
+                if (segment.Text == "\n" && !first)
+                {
+                    builder.Append('\n');
+                    continue;
+                }
+
+                var parts = segment.Text.Split(new[] { '\n' }, StringSplitOptions.None);
+                foreach (var (_, _, last, line) in parts.Enumerate())
+                {
+                    if (string.IsNullOrEmpty(line))
+                    {
+                        continue;
+                    }
+
+                    builder.Append("');
+                    builder.Append(line);
+                    builder.Append("");
+
+                    if (parts.Length > 1 && !last)
+                    {
+                        builder.Append('\n');
+                    }
+                }
+            }
+
+            builder.Append("
"); + + return builder.ToString().TrimEnd('\n'); + } + + private static string BuildCss(Style style) + { + var css = new List(); + + var foreground = style.Foreground; + var background = style.Background; + + if ((style.Decoration & Decoration.Invert) != 0) + { + var temp = foreground; + foreground = background; + background = temp; + } + + if ((style.Decoration & Decoration.Dim) != 0) + { + var blender = background; + if (blender.Equals(Color.Default)) + { + blender = Color.White; + } + + foreground = foreground.Blend(blender, 0.5f); + } + + if (!foreground.Equals(Color.Default)) + { + css.Add($"color: #{foreground.ToHex()}"); + } + + if (!background.Equals(Color.Default)) + { + css.Add($"background-color: #{background.ToHex()}"); + } + + if ((style.Decoration & Decoration.Bold) != 0) + { + css.Add("font-weight: bold"); + } + + if ((style.Decoration & Decoration.Bold) != 0) + { + css.Add("font-style: italic"); + } + + if ((style.Decoration & Decoration.Underline) != 0) + { + css.Add("text-decoration: underline"); + } + + if ((style.Decoration & Decoration.Strikethrough) != 0) + { + css.Add("text-decoration: line-through"); + } + + return string.Join(";", css); + } + } +} diff --git a/src/Spectre.Console/Internal/TextEncoder.cs b/src/Spectre.Console/Internal/TextEncoder.cs new file mode 100644 index 0000000..2bc3adf --- /dev/null +++ b/src/Spectre.Console/Internal/TextEncoder.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text; +using Spectre.Console.Rendering; + +namespace Spectre.Console.Internal +{ + internal sealed class TextEncoder : IAnsiConsoleEncoder + { + public string Encode(IEnumerable segments) + { + var builder = new StringBuilder(); + + foreach (var segment in Segment.Merge(segments)) + { + builder.Append(segment.Text); + } + + return builder.ToString().TrimEnd('\n'); + } + } +} diff --git a/src/Spectre.Console/Recorder.cs b/src/Spectre.Console/Recorder.cs new file mode 100644 index 0000000..140319b --- /dev/null +++ b/src/Spectre.Console/Recorder.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A console recorder used to record output from a console. + /// + public sealed class Recorder : IAnsiConsole, IDisposable + { + private readonly IAnsiConsole _console; + private readonly List _recorded; + + /// + public Capabilities Capabilities => _console.Capabilities; + + /// + public Encoding Encoding => _console.Encoding; + + /// + public int Width => _console.Width; + + /// + public int Height => _console.Height; + + /// + /// Initializes a new instance of the class. + /// + /// The console to record output for. + public Recorder(IAnsiConsole console) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _recorded = new List(); + } + + /// + public void Dispose() + { + // Only used for scoping. + } + + /// + public void Write(Segment segment) + { + _recorded.Add(segment); + _console.Write(segment); + } + + /// + /// Exports the recorded data. + /// + /// The encoder. + /// The recorded data represented as a string. + public string Export(IAnsiConsoleEncoder encoder) + { + if (encoder is null) + { + throw new ArgumentNullException(nameof(encoder)); + } + + return encoder.Encode(_recorded); + } + } +} diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 3020ce5..0dfed61 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -72,7 +72,7 @@ namespace Spectre.Console.Rendering } Text = text.NormalizeLineEndings(); - Style = style; + Style = style ?? throw new ArgumentNullException(nameof(style)); IsLineBreak = lineBreak; IsWhiteSpace = string.IsNullOrWhiteSpace(text); }