Add support for recording console output

This commit adds support for recording console output
as well as exporting it to either text or HTML. A user can
also provide their own encoder if they wish.
This commit is contained in:
Patrik Svensson 2020-09-21 00:44:47 +02:00 committed by Patrik Svensson
parent b197f278ed
commit cd0d182f12
16 changed files with 481 additions and 24 deletions

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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("<pre style=\"font-size:90%;font-family:consolas,'Courier New',monospace\">");
result[1].ShouldBe("<span>┌─────────────────┬───────┬─────┐</span>");
result[2].ShouldBe("<span>│ </span><span style=\"color: #FF0000;background-color: #000000\">Foo</span><span> │ </span><span style=\"color: #008000;font-weight: bold;font-style: italic\">Bar</span><span> │ </span><span style=\"color: #0000FF\">Qux</span><span> │</span>");
result[3].ShouldBe("<span>├─────────────────┼───────┼─────┤</span>");
result[4].ShouldBe("<span>│ </span><span style=\"text-decoration: underline\">Corgi</span><span> │ </span><span style=\"font-weight: bold;font-style: italic;text-decoration: line-through\">Waldo</span><span> │ </span><span style=\"color: #7F7F7F\">Zap</span><span> │</span>");
result[5].ShouldBe("<span>│ </span><span style=\"color: #FF0000\">╭─────────────╮</span><span> │ │ │</span>");
result[6].ShouldBe("<span>│ </span><span style=\"color: #FF0000\">│</span><span> </span><span style=\"color: #0000FF\">Hello World</span><span> </span><span style=\"color: #FF0000\">│</span><span> │ │ │</span>");
result[7].ShouldBe("<span>│ </span><span style=\"color: #FF0000\">╰─────────────╯</span><span> │ │ │</span>");
result[8].ShouldBe("<span>└─────────────────┴───────┴─────┘</span>");
result[9].ShouldBe("</pre>");
}
}
}

View File

@ -0,0 +1,66 @@
using System;
namespace Spectre.Console
{
/// <summary>
/// A console capable of writing ANSI escape sequences.
/// </summary>
public static partial class AnsiConsole
{
/// <summary>
/// Starts recording the console output.
/// </summary>
public static void Record()
{
_recorder = new Recorder(_console.Value);
}
/// <summary>
/// Exports all recorded console output as text.
/// </summary>
/// <returns>The recorded output as text.</returns>
public static string ExportText()
{
if (_recorder == null)
{
throw new InvalidOperationException("Cannot export text since a recording hasn't been started.");
}
return _recorder.ExportText();
}
/// <summary>
/// Exports all recorded console output as HTML.
/// </summary>
/// <returns>The recorded output as HTML.</returns>
public static string ExportHtml()
{
if (_recorder == null)
{
throw new InvalidOperationException("Cannot export HTML since a recording hasn't been started.");
}
return _recorder.ExportHtml();
}
/// <summary>
/// Exports all recorded console output using a custom encoder.
/// </summary>
/// <param name="encoder">The encoder to use.</param>
/// <returns>The recorded output.</returns>
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);
}
}
}

View File

@ -21,10 +21,12 @@ namespace Spectre.Console
return console;
});
private static Recorder? _recorder;
/// <summary>
/// Gets the underlying <see cref="IAnsiConsole"/>.
/// </summary>
public static IAnsiConsole Console => _console.Value;
public static IAnsiConsole Console => _recorder ?? _console.Value;
/// <summary>
/// Gets the console's capabilities.

View File

@ -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;
}
/// <summary>
/// Blends two colors.
/// </summary>
/// <param name="other">The other color.</param>
/// <param name="factor">The blend factor.</param>
/// <returns>The resulting color.</returns>
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)));
}
/// <summary>
/// Gets the hexadecimal representation of the color.
/// </summary>
/// <returns>The hexadecimal representation of the color.</returns>
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));
}
/// <inheritdoc/>
public override int GetHashCode()
{

View File

@ -1,4 +1,5 @@
using System;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
@ -7,6 +8,32 @@ namespace Spectre.Console
/// </summary>
public static partial class AnsiConsoleExtensions
{
/// <summary>
/// Creates a recorder for the specified console.
/// </summary>
/// <param name="console">The console to record.</param>
/// <returns>A recorder for the specified console.</returns>
public static Recorder CreateRecorder(this IAnsiConsole console)
{
return new Recorder(console);
}
/// <summary>
/// Writes the specified string value to the console.
/// </summary>
/// <param name="console">The console to write to.</param>
/// <param name="text">The text to write.</param>
/// <param name="style">The text style.</param>
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));
}
/// <summary>
/// Writes an empty line to the console.
/// </summary>
@ -34,7 +61,7 @@ namespace Spectre.Console
throw new ArgumentNullException(nameof(console));
}
console.Write(text, style);
console.Write(new Segment(text, style));
console.WriteLine();
}
}

View File

@ -0,0 +1,44 @@
using System;
using Spectre.Console.Internal;
namespace Spectre.Console
{
/// <summary>
/// Contains extension methods for <see cref="Recorder"/>.
/// </summary>
public static class RecorderExtensions
{
private static readonly TextEncoder _textEncoder = new TextEncoder();
private static readonly HtmlEncoder _htmlEncoder = new HtmlEncoder();
/// <summary>
/// Exports the recorded content as text.
/// </summary>
/// <param name="recorder">The recorder.</param>
/// <returns>The recorded content as text.</returns>
public static string ExportText(this Recorder recorder)
{
if (recorder is null)
{
throw new ArgumentNullException(nameof(recorder));
}
return recorder.Export(_textEncoder);
}
/// <summary>
/// Exports the recorded content as HTML.
/// </summary>
/// <param name="recorder">The recorder.</param>
/// <returns>The recorded content as HTML.</returns>
public static string ExportHtml(this Recorder recorder)
{
if (recorder is null)
{
throw new ArgumentNullException(nameof(recorder));
}
return recorder.Export(_htmlEncoder);
}
}
}

View File

@ -1,4 +1,5 @@
using System.Text;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
@ -30,8 +31,7 @@ namespace Spectre.Console
/// <summary>
/// Writes a string followed by a line terminator to the console.
/// </summary>
/// <param name="text">The string to write.</param>
/// <param name="style">The style to use.</param>
void Write(string text, Style style);
/// <param name="segment">The segment to write.</param>
void Write(Segment segment);
}
}

View File

@ -0,0 +1,19 @@
using System.Collections.Generic;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// Represents a console encoder that can encode
/// recorded segments into a string.
/// </summary>
public interface IAnsiConsoleEncoder
{
/// <summary>
/// Encodes the specified segments.
/// </summary>
/// <param name="segments">The segments to encode.</param>
/// <returns>The encoded string.</returns>
string Encode(IEnumerable<Segment> segments);
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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<Segment> segments)
{
var builder = new StringBuilder();
builder.Append("<pre style=\"font-size:90%;font-family:consolas,'Courier New',monospace\">\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("<span");
if (!segment.Style.Equals(Style.Plain))
{
builder.Append(" style=\"");
builder.Append(BuildCss(segment.Style));
builder.Append('"');
}
builder.Append('>');
builder.Append(line);
builder.Append("</span>");
if (parts.Length > 1 && !last)
{
builder.Append('\n');
}
}
}
builder.Append("</pre>");
return builder.ToString().TrimEnd('\n');
}
private static string BuildCss(Style style)
{
var css = new List<string>();
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);
}
}
}

View File

@ -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<Segment> segments)
{
var builder = new StringBuilder();
foreach (var segment in Segment.Merge(segments))
{
builder.Append(segment.Text);
}
return builder.ToString().TrimEnd('\n');
}
}
}

View File

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.Text;
using Spectre.Console.Rendering;
namespace Spectre.Console
{
/// <summary>
/// A console recorder used to record output from a console.
/// </summary>
public sealed class Recorder : IAnsiConsole, IDisposable
{
private readonly IAnsiConsole _console;
private readonly List<Segment> _recorded;
/// <inheritdoc/>
public Capabilities Capabilities => _console.Capabilities;
/// <inheritdoc/>
public Encoding Encoding => _console.Encoding;
/// <inheritdoc/>
public int Width => _console.Width;
/// <inheritdoc/>
public int Height => _console.Height;
/// <summary>
/// Initializes a new instance of the <see cref="Recorder"/> class.
/// </summary>
/// <param name="console">The console to record output for.</param>
public Recorder(IAnsiConsole console)
{
_console = console ?? throw new ArgumentNullException(nameof(console));
_recorded = new List<Segment>();
}
/// <inheritdoc/>
public void Dispose()
{
// Only used for scoping.
}
/// <inheritdoc/>
public void Write(Segment segment)
{
_recorded.Add(segment);
_console.Write(segment);
}
/// <summary>
/// Exports the recorded data.
/// </summary>
/// <param name="encoder">The encoder.</param>
/// <returns>The recorded data represented as a string.</returns>
public string Export(IAnsiConsoleEncoder encoder)
{
if (encoder is null)
{
throw new ArgumentNullException(nameof(encoder));
}
return encoder.Encode(_recorded);
}
}
}

View File

@ -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);
}