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("│ Foo │ Bar │ Qux │");
+ result[3].ShouldBe("├─────────────────┼───────┼─────┤");
+ result[4].ShouldBe("│ Corgi │ Waldo │ Zap │");
+ 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);
}