diff --git a/src/Spectre.Console.Rx.Json/IJsonParser.cs b/src/Spectre.Console.Rx.Json/IJsonParser.cs
new file mode 100644
index 0000000..3da5431
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/IJsonParser.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+///
+/// Represents a JSON parser.
+///
+public interface IJsonParser
+{
+ ///
+ /// Parses the provided JSON into an abstract syntax tree.
+ ///
+ /// The JSON to parse.
+ /// An instance.
+ JsonSyntax Parse(string json);
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonBuilder.cs b/src/Spectre.Console.Rx.Json/JsonBuilder.cs
new file mode 100644
index 0000000..6aaf332
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonBuilder.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+internal sealed class JsonBuilder : JsonSyntaxVisitor
+{
+ public static JsonBuilder Shared { get; } = new JsonBuilder();
+
+ public override void VisitObject(JsonObject syntax, JsonBuilderContext context)
+ {
+ context.Paragraph.Append("{", context.Styling.BracesStyle);
+ context.Paragraph.Append("\n");
+ context.Indentation++;
+
+ foreach (var (_, _, last, property) in syntax.Members.Enumerate())
+ {
+ context.InsertIndentation();
+ property.Accept(this, context);
+
+ if (!last)
+ {
+ context.Paragraph.Append(",", context.Styling.CommaStyle);
+ }
+
+ context.Paragraph.Append("\n");
+ }
+
+ context.Indentation--;
+ context.InsertIndentation();
+ context.Paragraph.Append("}", context.Styling.BracesStyle);
+ }
+
+ public override void VisitArray(JsonArray syntax, JsonBuilderContext context)
+ {
+ context.Paragraph.Append("[", context.Styling.BracketsStyle);
+ context.Paragraph.Append("\n");
+ context.Indentation++;
+
+ foreach (var (_, _, last, item) in syntax.Items.Enumerate())
+ {
+ context.InsertIndentation();
+ item.Accept(this, context);
+
+ if (!last)
+ {
+ context.Paragraph.Append(",", context.Styling.CommaStyle);
+ }
+
+ context.Paragraph.Append("\n");
+ }
+
+ context.Indentation--;
+ context.InsertIndentation();
+ context.Paragraph.Append("]", context.Styling.BracketsStyle);
+ }
+
+ public override void VisitMember(JsonMember syntax, JsonBuilderContext context)
+ {
+ context.Paragraph.Append(syntax.Name, context.Styling.MemberStyle);
+ context.Paragraph.Append(":", context.Styling.ColonStyle);
+ context.Paragraph.Append(" ");
+
+ syntax.Value.Accept(this, context);
+ }
+
+ public override void VisitNumber(JsonNumber syntax, JsonBuilderContext context) =>
+ context.Paragraph.Append(syntax.Lexeme, context.Styling.NumberStyle);
+
+ public override void VisitString(JsonString syntax, JsonBuilderContext context) =>
+ context.Paragraph.Append(syntax.Lexeme, context.Styling.StringStyle);
+
+ public override void VisitBoolean(JsonBoolean syntax, JsonBuilderContext context) =>
+ context.Paragraph.Append(syntax.Lexeme, context.Styling.BooleanStyle);
+
+ public override void VisitNull(JsonNull syntax, JsonBuilderContext context) =>
+ context.Paragraph.Append(syntax.Lexeme, context.Styling.NullStyle);
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonBuilderContext.cs b/src/Spectre.Console.Rx.Json/JsonBuilderContext.cs
new file mode 100644
index 0000000..f491470
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonBuilderContext.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+internal sealed class JsonBuilderContext(JsonTextStyles styling)
+{
+ public Paragraph Paragraph { get; } = new Paragraph();
+
+ public int Indentation { get; set; }
+
+ public JsonTextStyles Styling { get; } = styling;
+
+ public void InsertIndentation() => Paragraph.Append(new string(' ', Indentation * 3));
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonParser.cs b/src/Spectre.Console.Rx.Json/JsonParser.cs
new file mode 100644
index 0000000..6a104b5
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonParser.cs
@@ -0,0 +1,141 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+internal sealed class JsonParser : IJsonParser
+{
+ public static JsonParser Shared { get; } = new JsonParser();
+
+ public JsonSyntax Parse(string json)
+ {
+ try
+ {
+ var tokens = JsonTokenizer.Tokenize(json);
+ var reader = new JsonTokenReader(tokens);
+ return ParseElement(reader);
+ }
+ catch
+ {
+ throw new InvalidOperationException("Invalid JSON");
+ }
+ }
+
+ private static JsonSyntax ParseElement(JsonTokenReader reader) => ParseValue(reader);
+
+ private static List ParseElements(JsonTokenReader reader)
+ {
+ var members = new List();
+
+ while (!reader.Eof)
+ {
+ members.Add(ParseElement(reader));
+
+ if (reader.Peek()?.Type != JsonTokenType.Comma)
+ {
+ break;
+ }
+
+ reader.Consume(JsonTokenType.Comma);
+ }
+
+ return members;
+ }
+
+ private static JsonSyntax ParseValue(JsonTokenReader reader)
+ {
+ var current = reader.Peek() ?? throw new InvalidOperationException("Could not parse value (EOF)");
+ if (current.Type == JsonTokenType.LeftBrace)
+ {
+ return ParseObject(reader);
+ }
+
+ if (current.Type == JsonTokenType.LeftBracket)
+ {
+ return ParseArray(reader);
+ }
+
+ if (current.Type == JsonTokenType.Number)
+ {
+ reader.Consume(JsonTokenType.Number);
+ return new JsonNumber(current.Lexeme);
+ }
+
+ if (current.Type == JsonTokenType.String)
+ {
+ reader.Consume(JsonTokenType.String);
+ return new JsonString(current.Lexeme);
+ }
+
+ if (current.Type == JsonTokenType.Boolean)
+ {
+ reader.Consume(JsonTokenType.Boolean);
+ return new JsonBoolean(current.Lexeme);
+ }
+
+ if (current.Type == JsonTokenType.Null)
+ {
+ reader.Consume(JsonTokenType.Null);
+ return new JsonNull(current.Lexeme);
+ }
+
+ throw new InvalidOperationException($"Unknown value token: {current.Type}");
+ }
+
+ private static JsonSyntax ParseObject(JsonTokenReader reader)
+ {
+ reader.Consume(JsonTokenType.LeftBrace);
+
+ var result = new JsonObject();
+
+ if (reader.Peek()?.Type != JsonTokenType.RightBrace)
+ {
+ result.Members.AddRange(ParseMembers(reader));
+ }
+
+ reader.Consume(JsonTokenType.RightBrace);
+ return result;
+ }
+
+ private static JsonSyntax ParseArray(JsonTokenReader reader)
+ {
+ reader.Consume(JsonTokenType.LeftBracket);
+
+ var result = new JsonArray();
+
+ if (reader.Peek()?.Type != JsonTokenType.RightBracket)
+ {
+ result.Items.AddRange(ParseElements(reader));
+ }
+
+ reader.Consume(JsonTokenType.RightBracket);
+ return result;
+ }
+
+ private static List ParseMembers(JsonTokenReader reader)
+ {
+ var members = new List();
+
+ while (!reader.Eof)
+ {
+ members.Add(ParseMember(reader));
+
+ if (reader.Peek()?.Type != JsonTokenType.Comma)
+ {
+ break;
+ }
+
+ reader.Consume(JsonTokenType.Comma);
+ }
+
+ return members;
+ }
+
+ private static JsonMember ParseMember(JsonTokenReader reader)
+ {
+ var name = reader.Consume(JsonTokenType.String);
+ reader.Consume(JsonTokenType.Colon);
+ var value = ParseElement(reader);
+ return new JsonMember(name.Lexeme, value);
+ }
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonText.cs b/src/Spectre.Console.Rx.Json/JsonText.cs
new file mode 100644
index 0000000..3a9b4b7
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonText.cs
@@ -0,0 +1,99 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+///
+/// A renderable piece of JSON text.
+///
+///
+/// Initializes a new instance of the class.
+///
+/// The JSON to render.
+public sealed class JsonText(string json) : JustInTimeRenderable
+{
+ private readonly string _json = json ?? throw new ArgumentNullException(nameof(json));
+ private JsonSyntax? _syntax;
+ private IJsonParser? _parser;
+
+ ///
+ /// Gets or sets the style used for braces.
+ ///
+ public Style? BracesStyle { get; set; }
+
+ ///
+ /// Gets or sets the style used for brackets.
+ ///
+ public Style? BracketsStyle { get; set; }
+
+ ///
+ /// Gets or sets the style used for member names.
+ ///
+ public Style? MemberStyle { get; set; }
+
+ ///
+ /// Gets or sets the style used for colons.
+ ///
+ public Style? ColonStyle { get; set; }
+
+ ///
+ /// Gets or sets the style used for commas.
+ ///
+ public Style? CommaStyle { get; set; }
+
+ ///
+ /// Gets or sets the style used for string literals.
+ ///
+ public Style? StringStyle { get; set; }
+
+ ///
+ /// Gets or sets the style used for number literals.
+ ///
+ public Style? NumberStyle { get; set; }
+
+ ///
+ /// Gets or sets the style used for boolean literals.
+ ///
+ public Style? BooleanStyle { get; set; }
+
+ ///
+ /// Gets or sets the style used for null literals.
+ ///
+ public Style? NullStyle { get; set; }
+
+ ///
+ /// Gets or sets the JSON parser.
+ ///
+ public IJsonParser? Parser
+ {
+ get => _parser;
+
+ set
+ {
+ _syntax = null;
+ _parser = value;
+ }
+ }
+
+ ///
+ protected override IRenderable Build()
+ {
+ _syntax ??= (Parser ?? JsonParser.Shared).Parse(_json);
+
+ var context = new JsonBuilderContext(new JsonTextStyles
+ {
+ BracesStyle = BracesStyle ?? Color.Grey,
+ BracketsStyle = BracketsStyle ?? Color.Grey,
+ MemberStyle = MemberStyle ?? Color.Blue,
+ ColonStyle = ColonStyle ?? Color.Yellow,
+ CommaStyle = CommaStyle ?? Color.Grey,
+ StringStyle = StringStyle ?? Color.Red,
+ NumberStyle = NumberStyle ?? Color.Green,
+ BooleanStyle = BooleanStyle ?? Color.Green,
+ NullStyle = NullStyle ?? Color.Grey,
+ });
+
+ _syntax.Accept(JsonBuilder.Shared, context);
+ return context.Paragraph;
+ }
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonTextExtensions.cs b/src/Spectre.Console.Rx.Json/JsonTextExtensions.cs
new file mode 100644
index 0000000..4458663
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonTextExtensions.cs
@@ -0,0 +1,316 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+///
+/// Contains extension methods for .
+///
+public static class JsonTextExtensions
+{
+ ///
+ /// Sets the style used for braces.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText BracesStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.BracesStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the style used for brackets.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText BracketStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.BracketsStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the style used for member names.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText MemberStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.MemberStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the style used for colons.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText ColonStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.ColonStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the style used for commas.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText CommaStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.CommaStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the style used for string literals.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText StringStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.StringStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the style used for number literals.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText NumberStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.NumberStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the style used for boolean literals.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText BooleanStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.BooleanStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the style used for null literals.
+ ///
+ /// The JSON text instance.
+ /// The style to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText NullStyle(this JsonText text, Style? style)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.NullStyle = style;
+ return text;
+ }
+
+ ///
+ /// Sets the color used for braces.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText BracesColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.BracesStyle = new Style(color);
+ return text;
+ }
+
+ ///
+ /// Sets the color used for brackets.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText BracketColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.BracketsStyle = new Style(color);
+ return text;
+ }
+
+ ///
+ /// Sets the color used for member names.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText MemberColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.MemberStyle = new Style(color);
+ return text;
+ }
+
+ ///
+ /// Sets the color used for colons.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText ColonColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.ColonStyle = new Style(color);
+ return text;
+ }
+
+ ///
+ /// Sets the color used for commas.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText CommaColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.CommaStyle = new Style(color);
+ return text;
+ }
+
+ ///
+ /// Sets the color used for string literals.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText StringColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.StringStyle = new Style(color);
+ return text;
+ }
+
+ ///
+ /// Sets the color used for number literals.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText NumberColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.NumberStyle = new Style(color);
+ return text;
+ }
+
+ ///
+ /// Sets the color used for boolean literals.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText BooleanColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.BooleanStyle = new Style(color);
+ return text;
+ }
+
+ ///
+ /// Sets the color used for null literals.
+ ///
+ /// The JSON text instance.
+ /// The color to set.
+ /// The same instance so that multiple calls can be chained.
+ public static JsonText NullColor(this JsonText text, Color color)
+ {
+ if (text == null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ text.NullStyle = new Style(color);
+ return text;
+ }
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonTextStyles.cs b/src/Spectre.Console.Rx.Json/JsonTextStyles.cs
new file mode 100644
index 0000000..df50d7f
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonTextStyles.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+internal sealed class JsonTextStyles
+{
+ public Style BracesStyle { get; set; } = null!;
+
+ public Style BracketsStyle { get; set; } = null!;
+
+ public Style MemberStyle { get; set; } = null!;
+
+ public Style ColonStyle { get; set; } = null!;
+
+ public Style CommaStyle { get; set; } = null!;
+
+ public Style StringStyle { get; set; } = null!;
+
+ public Style NumberStyle { get; set; } = null!;
+
+ public Style BooleanStyle { get; set; } = null!;
+
+ public Style NullStyle { get; set; } = null!;
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonToken.cs b/src/Spectre.Console.Rx.Json/JsonToken.cs
new file mode 100644
index 0000000..0a6318c
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonToken.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+internal sealed class JsonToken(JsonTokenType type, string lexeme)
+{
+ public JsonTokenType Type { get; } = type;
+
+ public string Lexeme { get; } = lexeme ?? throw new ArgumentNullException(nameof(lexeme));
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonTokenReader.cs b/src/Spectre.Console.Rx.Json/JsonTokenReader.cs
new file mode 100644
index 0000000..63f72df
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonTokenReader.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+internal sealed class JsonTokenReader(List tokens)
+{
+ private readonly int _length = tokens.Count;
+
+ public int Position { get; private set; }
+
+ public bool Eof => Position >= _length;
+
+ public JsonToken Consume(JsonTokenType type)
+ {
+ var read = Read() ?? throw new InvalidOperationException("Could not read token");
+ if (read.Type != type)
+ {
+ throw new InvalidOperationException($"Expected '{type}' token, but found '{read.Type}'");
+ }
+
+ return read;
+ }
+
+ public JsonToken? Peek()
+ {
+ if (Eof)
+ {
+ return null;
+ }
+
+ return tokens[Position];
+ }
+
+ public JsonToken? Read()
+ {
+ if (Eof)
+ {
+ return null;
+ }
+
+ Position++;
+ return tokens[Position - 1];
+ }
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonTokenType.cs b/src/Spectre.Console.Rx.Json/JsonTokenType.cs
new file mode 100644
index 0000000..40d7792
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonTokenType.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json;
+
+internal enum JsonTokenType
+{
+ LeftBrace,
+ RightBrace,
+ LeftBracket,
+ RightBracket,
+ Colon,
+ Comma,
+ String,
+ Number,
+ Boolean,
+ Null,
+}
diff --git a/src/Spectre.Console.Rx.Json/JsonTokenizer.cs b/src/Spectre.Console.Rx.Json/JsonTokenizer.cs
new file mode 100644
index 0000000..a416a61
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/JsonTokenizer.cs
@@ -0,0 +1,208 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text;
+
+namespace Spectre.Console.Rx.Json;
+
+internal static class JsonTokenizer
+{
+ private static readonly Dictionary _typeLookup;
+ private static readonly Dictionary _keywords;
+ private static readonly HashSet _allowedEscapedChars;
+
+ static JsonTokenizer()
+ {
+ _typeLookup = new Dictionary
+ {
+ { '{', JsonTokenType.LeftBrace },
+ { '}', JsonTokenType.RightBrace },
+ { '[', JsonTokenType.LeftBracket },
+ { ']', JsonTokenType.RightBracket },
+ { ':', JsonTokenType.Colon },
+ { ',', JsonTokenType.Comma },
+ };
+
+ _keywords = new Dictionary
+ {
+ { "true", JsonTokenType.Boolean },
+ { "false", JsonTokenType.Boolean },
+ { "null", JsonTokenType.Null },
+ };
+
+ _allowedEscapedChars = new HashSet
+ {
+ '\"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u',
+ };
+ }
+
+ public static List Tokenize(string text)
+ {
+ var result = new List();
+ var buffer = new StringBuffer(text);
+
+ while (!buffer.Eof)
+ {
+ var current = buffer.Peek();
+
+ if (_typeLookup.TryGetValue(current, out var tokenType))
+ {
+ buffer.Read(); // Consume
+ result.Add(new JsonToken(tokenType, current.ToString()));
+ continue;
+ }
+ else if (current == '\"')
+ {
+ result.Add(ReadString(buffer));
+ }
+ else if (current == '-' || current.IsDigit())
+ {
+ result.Add(ReadNumber(buffer));
+ }
+ else if (current is ' ' or '\n' or '\r' or '\t')
+ {
+ buffer.Read(); // Consume
+ }
+ else if (char.IsLetter(current))
+ {
+ var accumulator = new StringBuilder();
+ while (!buffer.Eof)
+ {
+ current = buffer.Peek();
+ if (!char.IsLetter(current))
+ {
+ break;
+ }
+
+ buffer.Read(); // Consume
+ accumulator.Append(current);
+ }
+
+ if (!_keywords.TryGetValue(accumulator.ToString(), out var keyword))
+ {
+ throw new InvalidOperationException($"Encountered invalid keyword '{keyword}'");
+ }
+
+ result.Add(new JsonToken(keyword, accumulator.ToString()));
+ }
+ else
+ {
+ throw new InvalidOperationException("Invalid token");
+ }
+ }
+
+ return result;
+ }
+
+ private static JsonToken ReadString(StringBuffer buffer)
+ {
+ var accumulator = new StringBuilder();
+ accumulator.Append(buffer.Expect('\"'));
+
+ while (!buffer.Eof)
+ {
+ var current = buffer.Peek();
+ if (current == '\"')
+ {
+ break;
+ }
+ else if (current == '\\')
+ {
+ buffer.Expect('\\');
+
+ if (buffer.Eof)
+ {
+ break;
+ }
+
+ current = buffer.Read();
+ if (!_allowedEscapedChars.Contains(current))
+ {
+ throw new InvalidOperationException("Invalid escape encountered");
+ }
+
+ accumulator.Append('\\').Append(current);
+ }
+ else
+ {
+ accumulator.Append(current);
+ buffer.Read();
+ }
+ }
+
+ if (buffer.Eof)
+ {
+ throw new InvalidOperationException("Unterminated string literal");
+ }
+
+ accumulator.Append(buffer.Expect('\"'));
+ return new JsonToken(JsonTokenType.String, accumulator.ToString());
+ }
+
+ private static JsonToken ReadNumber(StringBuffer buffer)
+ {
+ var accumulator = new StringBuilder();
+
+ // Minus?
+ if (buffer.Peek() == '-')
+ {
+ buffer.Read();
+ accumulator.Append('-');
+ }
+
+ // Digits
+ var current = buffer.Peek();
+ if (current.IsDigit(min: 1))
+ {
+ ReadDigits(buffer, accumulator, min: 1);
+ }
+ else if (current == '0')
+ {
+ accumulator.Append(buffer.Expect('0'));
+ }
+ else
+ {
+ throw new InvalidOperationException("Invalid number");
+ }
+
+ // Fractions
+ current = buffer.Peek();
+ if (current == '.')
+ {
+ accumulator.Append(buffer.Expect('.'));
+ ReadDigits(buffer, accumulator);
+ }
+
+ // Exponent
+ current = buffer.Peek();
+ if (current is 'e' or 'E')
+ {
+ accumulator.Append(buffer.Read());
+
+ current = buffer.Peek();
+ if (current is '+' or '-')
+ {
+ accumulator.Append(buffer.Read());
+ }
+
+ ReadDigits(buffer, accumulator);
+ }
+
+ return new JsonToken(JsonTokenType.Number, accumulator.ToString());
+ }
+
+ private static void ReadDigits(StringBuffer buffer, StringBuilder accumulator, int min = 0)
+ {
+ while (!buffer.Eof)
+ {
+ var current = buffer.Peek();
+ if (!current.IsDigit(min))
+ {
+ break;
+ }
+
+ buffer.Read(); // Consume
+ accumulator.Append(current);
+ }
+ }
+}
diff --git a/src/Spectre.Console.Rx.Json/Properties/Usings.cs b/src/Spectre.Console.Rx.Json/Properties/Usings.cs
new file mode 100644
index 0000000..fb4ee75
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Properties/Usings.cs
@@ -0,0 +1,7 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+global using System.Text;
+global using Spectre.Console.Rx.Internal;
+global using Spectre.Console.Rx.Json.Syntax;
+global using Spectre.Console.Rx.Rendering;
diff --git a/src/Spectre.Console.Rx.Json/Spectre.Console.Rx.Json.csproj b/src/Spectre.Console.Rx.Json/Spectre.Console.Rx.Json.csproj
new file mode 100644
index 0000000..5670b6c
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Spectre.Console.Rx.Json.csproj
@@ -0,0 +1,19 @@
+
+
+
+ netstandard2.0;net6.0;net7.0;net8.0
+ true
+ A library that extends Spectre.Console with JSON superpowers.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonArray.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonArray.cs
new file mode 100644
index 0000000..6b07c12
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonArray.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+///
+/// Represents an array in the JSON abstract syntax tree.
+///
+public sealed class JsonArray : JsonSyntax
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public JsonArray() => Items = new();
+
+ ///
+ /// Gets the array items.
+ ///
+ public List Items { get; }
+
+ internal override void Accept(JsonSyntaxVisitor visitor, T context) =>
+ visitor.VisitArray(this, context);
+}
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonBoolean.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonBoolean.cs
new file mode 100644
index 0000000..a92d5fc
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonBoolean.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+///
+/// Represents a boolean literal in the JSON abstract syntax tree.
+///
+///
+/// Initializes a new instance of the class.
+///
+/// The lexeme.
+public sealed class JsonBoolean(string lexeme) : JsonSyntax
+{
+ ///
+ /// Gets the lexeme.
+ ///
+ public string Lexeme { get; } = lexeme;
+
+ internal override void Accept(JsonSyntaxVisitor visitor, T context) =>
+ visitor.VisitBoolean(this, context);
+}
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonMember.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonMember.cs
new file mode 100644
index 0000000..d314a90
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonMember.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+///
+/// Represents a member in the JSON abstract syntax tree.
+///
+///
+/// Initializes a new instance of the class.
+///
+/// The name.
+/// The value.
+public sealed class JsonMember(string name, JsonSyntax value) : JsonSyntax
+{
+ ///
+ /// Gets the member name.
+ ///
+ public string Name { get; } = name;
+
+ ///
+ /// Gets the member value.
+ ///
+ public JsonSyntax Value { get; } = value;
+
+ internal override void Accept(JsonSyntaxVisitor visitor, T context) =>
+ visitor.VisitMember(this, context);
+}
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonNull.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonNull.cs
new file mode 100644
index 0000000..1887915
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonNull.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+///
+/// Represents a null literal in the JSON abstract syntax tree.
+///
+///
+/// Initializes a new instance of the class.
+///
+/// The lexeme.
+public sealed class JsonNull(string lexeme) : JsonSyntax
+{
+ ///
+ /// Gets the lexeme.
+ ///
+ public string Lexeme { get; } = lexeme;
+
+ internal override void Accept(JsonSyntaxVisitor visitor, T context) =>
+ visitor.VisitNull(this, context);
+}
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonNumber.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonNumber.cs
new file mode 100644
index 0000000..6206cc9
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonNumber.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+internal sealed class JsonNumber(string lexeme) : JsonSyntax
+{
+ public string Lexeme { get; } = lexeme;
+
+ internal override void Accept(JsonSyntaxVisitor visitor, T context) =>
+ visitor.VisitNumber(this, context);
+}
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonObject.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonObject.cs
new file mode 100644
index 0000000..41676db
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonObject.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+///
+/// Represents an object in the JSON abstract syntax tree.
+///
+public sealed class JsonObject : JsonSyntax
+{
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public JsonObject() => Members = new();
+
+ ///
+ /// Gets the object's members.
+ ///
+ public List Members { get; }
+
+ internal override void Accept(JsonSyntaxVisitor visitor, T context) =>
+ visitor.VisitObject(this, context);
+}
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonString.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonString.cs
new file mode 100644
index 0000000..2a4bbbf
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonString.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+///
+/// Represents a string literal in the JSON abstract syntax tree.
+///
+///
+/// Initializes a new instance of the class.
+///
+/// The lexeme.
+public sealed class JsonString(string lexeme) : JsonSyntax
+{
+ ///
+ /// Gets the lexeme.
+ ///
+ public string Lexeme { get; } = lexeme;
+
+ internal override void Accept(JsonSyntaxVisitor visitor, T context) =>
+ visitor.VisitString(this, context);
+}
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonSyntax.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonSyntax.cs
new file mode 100644
index 0000000..aee737c
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonSyntax.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+///
+/// Represents a syntax node in the JSON abstract syntax tree.
+///
+public abstract class JsonSyntax
+{
+ internal abstract void Accept(JsonSyntaxVisitor visitor, T context);
+}
diff --git a/src/Spectre.Console.Rx.Json/Syntax/JsonSyntaxVisitor.cs b/src/Spectre.Console.Rx.Json/Syntax/JsonSyntaxVisitor.cs
new file mode 100644
index 0000000..ced7ba9
--- /dev/null
+++ b/src/Spectre.Console.Rx.Json/Syntax/JsonSyntaxVisitor.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Json.Syntax;
+
+internal abstract class JsonSyntaxVisitor
+{
+ public abstract void VisitObject(JsonObject syntax, T context);
+
+ public abstract void VisitArray(JsonArray syntax, T context);
+
+ public abstract void VisitMember(JsonMember syntax, T context);
+
+ public abstract void VisitNumber(JsonNumber syntax, T context);
+
+ public abstract void VisitString(JsonString syntax, T context);
+
+ public abstract void VisitBoolean(JsonBoolean syntax, T context);
+
+ public abstract void VisitNull(JsonNull syntax, T context);
+}
diff --git a/src/Spectre.Console.Rx.Testing/.editorconfig b/src/Spectre.Console.Rx.Testing/.editorconfig
new file mode 100644
index 0000000..2adf83c
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/.editorconfig
@@ -0,0 +1,3 @@
+root = false
+
+[*.cs]
diff --git a/src/Spectre.Console.Rx.Testing/Extensions/ShouldlyExtensions.cs b/src/Spectre.Console.Rx.Testing/Extensions/ShouldlyExtensions.cs
new file mode 100644
index 0000000..fee8c4c
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/Extensions/ShouldlyExtensions.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx;
+
+internal static class ShouldlyExtensions
+{
+ [DebuggerStepThrough]
+ public static T And(this T item, Action action)
+ {
+ if (action == null)
+ {
+ throw new ArgumentNullException(nameof(action));
+ }
+
+ action(item);
+ return item;
+ }
+}
diff --git a/src/Spectre.Console.Rx.Testing/Extensions/StringExtensions.cs b/src/Spectre.Console.Rx.Testing/Extensions/StringExtensions.cs
new file mode 100644
index 0000000..f05f78a
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/Extensions/StringExtensions.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Testing;
+
+///
+/// Contains extensions for .
+///
+public static class StringExtensions
+{
+ ///
+ /// Returns a new string with all lines trimmed of trailing whitespace.
+ ///
+ /// The string to trim.
+ /// A new string with all lines trimmed of trailing whitespace.
+ public static string TrimLines(this string value)
+ {
+ if (value is null)
+ {
+ return string.Empty;
+ }
+
+ var result = new List();
+ foreach (var line in value.NormalizeLineEndings().Split(new[] { '\n' }))
+ {
+ result.Add(line.TrimEnd());
+ }
+
+ return string.Join("\n", result);
+ }
+
+ ///
+ /// Returns a new string with normalized line endings.
+ ///
+ /// The string to normalize line endings for.
+ /// A new string with normalized line endings.
+ public static string NormalizeLineEndings(this string value)
+ {
+ if (value != null)
+ {
+ value = value.Replace("\r\n", "\n");
+ return value.Replace("\r", string.Empty);
+ }
+
+ return string.Empty;
+ }
+}
diff --git a/src/Spectre.Console.Rx.Testing/Extensions/StyleExtensions.cs b/src/Spectre.Console.Rx.Testing/Extensions/StyleExtensions.cs
new file mode 100644
index 0000000..771ceef
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/Extensions/StyleExtensions.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Testing;
+
+///
+/// Contains extensions for .
+///
+public static class StyleExtensions
+{
+ ///
+ /// Sets the foreground or background color of the specified style.
+ ///
+ /// The style.
+ /// The color.
+ /// Whether or not to set the foreground color.
+ /// The same instance so that multiple calls can be chained.
+ public static Style SetColor(this Style style, Color color, bool foreground)
+ {
+ if (foreground)
+ {
+ return style.Foreground(color);
+ }
+
+ return style.Background(color);
+ }
+}
diff --git a/src/Spectre.Console.Rx.Testing/Internal/NoopCursor.cs b/src/Spectre.Console.Rx.Testing/Internal/NoopCursor.cs
new file mode 100644
index 0000000..5d16daa
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/Internal/NoopCursor.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Testing;
+
+internal sealed class NoopCursor : IAnsiConsoleCursor
+{
+ public void Move(CursorDirection direction, int steps)
+ {
+ }
+
+ public void SetPosition(int column, int line)
+ {
+ }
+
+ public void Show(bool show)
+ {
+ }
+}
diff --git a/src/Spectre.Console.Rx.Testing/Internal/NoopExclusivityMode.cs b/src/Spectre.Console.Rx.Testing/Internal/NoopExclusivityMode.cs
new file mode 100644
index 0000000..784aebc
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/Internal/NoopExclusivityMode.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Testing;
+
+internal sealed class NoopExclusivityMode : IExclusivityMode
+{
+ private bool _disposedValue;
+
+ public T Run(Func func) => func();
+
+ public async Task RunAsync(Func> func) => await func().ConfigureAwait(false);
+
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (!_disposedValue)
+ {
+ if (disposing)
+ {
+ // TODO: dispose managed state (managed objects)
+ }
+
+ // TODO: free unmanaged resources (unmanaged objects) and override finalizer
+ // TODO: set large fields to null
+ _disposedValue = true;
+ }
+ }
+}
diff --git a/src/Spectre.Console.Rx.Testing/Properties/Usings.cs b/src/Spectre.Console.Rx.Testing/Properties/Usings.cs
new file mode 100644
index 0000000..fd28302
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/Properties/Usings.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+global using System;
+global using System.Collections.Generic;
+global using System.Diagnostics;
+global using System.IO;
+global using System.Linq;
+global using System.Threading;
+global using System.Threading.Tasks;
+global using Spectre.Console.Rx.Rendering;
diff --git a/src/Spectre.Console.Rx.Testing/Spectre.Console.Rx.Testing.csproj b/src/Spectre.Console.Rx.Testing/Spectre.Console.Rx.Testing.csproj
new file mode 100644
index 0000000..673de64
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/Spectre.Console.Rx.Testing.csproj
@@ -0,0 +1,12 @@
+
+
+
+ net8.0;net7.0;net6.0;netstandard2.0
+ false
+ Contains testing utilities for Spectre.Console.
+
+
+
+
+
+
diff --git a/src/Spectre.Console.Rx.Testing/TestCapabilities.cs b/src/Spectre.Console.Rx.Testing/TestCapabilities.cs
new file mode 100644
index 0000000..17c8855
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/TestCapabilities.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Testing;
+
+///
+/// Represents fake capabilities useful in tests.
+///
+public sealed class TestCapabilities : IReadOnlyCapabilities
+{
+ ///
+ public ColorSystem ColorSystem { get; set; } = ColorSystem.TrueColor;
+
+ ///
+ public bool Ansi { get; set; }
+
+ ///
+ public bool Links { get; set; }
+
+ ///
+ public bool Legacy { get; set; }
+
+ ///
+ public bool IsTerminal { get; set; }
+
+ ///
+ public bool Interactive { get; set; }
+
+ ///
+ public bool Unicode { get; set; }
+
+ ///
+ /// Creates a with the same capabilities as this instace.
+ ///
+ /// The console.
+ /// A with the same capabilities as this instace.
+ public RenderOptions CreateRenderContext(IAnsiConsole console)
+ {
+ if (console is null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ return RenderOptions.Create(console, this);
+ }
+}
diff --git a/src/Spectre.Console.Rx.Testing/TestConsole.cs b/src/Spectre.Console.Rx.Testing/TestConsole.cs
new file mode 100644
index 0000000..fe0f327
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/TestConsole.cs
@@ -0,0 +1,114 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Testing;
+
+///
+/// A testable console.
+///
+public sealed class TestConsole : IAnsiConsole
+{
+ private readonly IAnsiConsole _console;
+ private readonly StringWriter _writer;
+ private IAnsiConsoleCursor? _cursor;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TestConsole()
+ {
+ _writer = new StringWriter();
+ _cursor = new NoopCursor();
+
+ Input = new TestConsoleInput();
+ EmitAnsiSequences = false;
+
+ _console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Ansi = AnsiSupport.Yes,
+ ColorSystem = (ColorSystemSupport)ColorSystem.TrueColor,
+ Out = new AnsiConsoleOutput(_writer),
+ Interactive = InteractionSupport.No,
+ ExclusivityMode = new NoopExclusivityMode(),
+ Enrichment = new ProfileEnrichment
+ {
+ UseDefaultEnrichers = false,
+ },
+ });
+
+ _console.Profile.Width = 80;
+ _console.Profile.Height = 24;
+ _console.Profile.Capabilities.Ansi = true;
+ _console.Profile.Capabilities.Unicode = true;
+ }
+
+ ///
+ public Profile Profile => _console.Profile;
+
+ ///
+ public IExclusivityMode ExclusivityMode => _console.ExclusivityMode;
+
+ ///
+ /// Gets the console input.
+ ///
+ public TestConsoleInput Input { get; }
+
+ ///
+ public RenderPipeline Pipeline => _console.Pipeline;
+
+ ///
+ public IAnsiConsoleCursor Cursor => _cursor ?? _console.Cursor;
+
+ ///
+ IAnsiConsoleInput IAnsiConsole.Input => Input;
+
+ ///
+ /// Gets the console output.
+ ///
+ public string Output => _writer.ToString();
+
+ ///
+ /// Gets the console output lines.
+ ///
+ public IReadOnlyList Lines =>
+ Output.NormalizeLineEndings().TrimEnd('\n').Split(['\n']);
+
+ ///
+ /// Gets or sets a value indicating whether or not VT/ANSI sequences
+ /// should be emitted to the console.
+ ///
+ public bool EmitAnsiSequences { get; set; }
+
+ ///
+ public void Dispose()
+ {
+ _writer.Dispose();
+ _console.Dispose();
+ }
+
+ ///
+ public void Clear(bool home) => _console.Clear(home);
+
+ ///
+ public void Write(IRenderable renderable)
+ {
+ if (EmitAnsiSequences)
+ {
+ _console.Write(renderable);
+ }
+ else
+ {
+ foreach (var segment in renderable.GetSegments(this))
+ {
+ if (segment.IsControlCode)
+ {
+ continue;
+ }
+
+ Profile.Out.Writer.Write(segment.Text);
+ }
+ }
+ }
+
+ internal void SetCursor(IAnsiConsoleCursor? cursor) => _cursor = cursor;
+}
diff --git a/src/Spectre.Console.Rx.Testing/TestConsoleExtensions.cs b/src/Spectre.Console.Rx.Testing/TestConsoleExtensions.cs
new file mode 100644
index 0000000..151a471
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/TestConsoleExtensions.cs
@@ -0,0 +1,129 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Testing;
+
+///
+/// Contains extensions for .
+///
+public static class TestConsoleExtensions
+{
+ ///
+ /// Sets the console's color system.
+ ///
+ /// The console.
+ /// The color system to use.
+ /// The same instance so that multiple calls can be chained.
+ public static TestConsole Colors(this TestConsole console, ColorSystem colors)
+ {
+ if (console is null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ console.Profile.Capabilities.ColorSystem = colors;
+ return console;
+ }
+
+ ///
+ /// Sets whether or not ANSI is supported.
+ ///
+ /// The console.
+ /// Whether or not VT/ANSI control codes are supported.
+ /// The same instance so that multiple calls can be chained.
+ public static TestConsole SupportsAnsi(this TestConsole console, bool enable)
+ {
+ if (console is null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ console.Profile.Capabilities.Ansi = enable;
+ return console;
+ }
+
+ ///
+ /// Makes the console interactive.
+ ///
+ /// The console.
+ /// The same instance so that multiple calls can be chained.
+ public static TestConsole Interactive(this TestConsole console)
+ {
+ if (console is null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ console.Profile.Capabilities.Interactive = true;
+ return console;
+ }
+
+ ///
+ /// Sets the console width.
+ ///
+ /// The console.
+ /// The console width.
+ /// The same instance so that multiple calls can be chained.
+ public static TestConsole Width(this TestConsole console, int width)
+ {
+ if (console is null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ console.Profile.Width = width;
+ return console;
+ }
+
+ ///
+ /// Sets the console height.
+ ///
+ /// The console.
+ /// The console height.
+ /// The same instance so that multiple calls can be chained.
+ public static TestConsole Height(this TestConsole console, int width)
+ {
+ if (console is null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ console.Profile.Height = width;
+ return console;
+ }
+
+ ///
+ /// Sets the console size.
+ ///
+ /// The console.
+ /// The console size.
+ /// The same instance so that multiple calls can be chained.
+ public static TestConsole Size(this TestConsole console, Size size)
+ {
+ if (console is null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ console.Profile.Width = size.Width;
+ console.Profile.Height = size.Height;
+ return console;
+ }
+
+ ///
+ /// Turns on emitting of VT/ANSI sequences.
+ ///
+ /// The console.
+ /// The same instance so that multiple calls can be chained.
+ public static TestConsole EmitAnsiSequences(this TestConsole console)
+ {
+ if (console is null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ console.SetCursor(null);
+ console.EmitAnsiSequences = true;
+ return console;
+ }
+}
diff --git a/src/Spectre.Console.Rx.Testing/TestConsoleInput.cs b/src/Spectre.Console.Rx.Testing/TestConsoleInput.cs
new file mode 100644
index 0000000..2c24792
--- /dev/null
+++ b/src/Spectre.Console.Rx.Testing/TestConsoleInput.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Spectre.Console.Rx.Testing;
+
+///
+/// Represents a testable console input mechanism.
+///
+public sealed class TestConsoleInput : IAnsiConsoleInput
+{
+ private readonly Queue _input;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TestConsoleInput()
+ {
+ _input = new Queue();
+ }
+
+ ///
+ /// Pushes the specified text to the input queue.
+ ///
+ /// The input string.
+ public void PushText(string input)
+ {
+ if (input is null)
+ {
+ throw new ArgumentNullException(nameof(input));
+ }
+
+ foreach (var character in input)
+ {
+ PushCharacter(character);
+ }
+ }
+
+ ///
+ /// Pushes the specified text followed by 'Enter' to the input queue.
+ ///
+ /// The input.
+ public void PushTextWithEnter(string input)
+ {
+ PushText(input);
+ PushKey(ConsoleKey.Enter);
+ }
+
+ ///
+ /// Pushes the specified character to the input queue.
+ ///
+ /// The input.
+ public void PushCharacter(char input)
+ {
+ var control = char.IsUpper(input);
+ _input.Enqueue(new ConsoleKeyInfo(input, (ConsoleKey)input, false, false, control));
+ }
+
+ ///
+ /// Pushes the specified key to the input queue.
+ ///
+ /// The input.
+ public void PushKey(ConsoleKey input)
+ {
+ _input.Enqueue(new ConsoleKeyInfo((char)input, input, false, false, false));
+ }
+
+ ///
+ /// Pushes the specified key to the input queue.
+ ///
+ /// The input.
+ public void PushKey(ConsoleKeyInfo consoleKeyInfo)
+ {
+ _input.Enqueue(consoleKeyInfo);
+ }
+
+ ///
+ public bool IsKeyAvailable()
+ {
+ return _input.Count > 0;
+ }
+
+ ///
+ public ConsoleKeyInfo? ReadKey(bool intercept)
+ {
+ if (_input.Count == 0)
+ {
+ throw new InvalidOperationException("No input available.");
+ }
+
+ return _input.Dequeue();
+ }
+
+ ///
+ public Task ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(ReadKey(intercept));
+ }
+}
diff --git a/src/Spectre.Console.Rx.sln b/src/Spectre.Console.Rx.sln
index c76f9c2..bc19d47 100644
--- a/src/Spectre.Console.Rx.sln
+++ b/src/Spectre.Console.Rx.sln
@@ -19,7 +19,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProgressThreadExample", "Pr
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "..\build\_build.csproj", "{15A30A6D-9A94-4A7C-A74B-D7A9CB36C216}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CombinedExample", "CombinedExample\CombinedExample.csproj", "{00E6E161-7DD9-49BB-AEDC-FFFD0447368E}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CombinedExample", "CombinedExample\CombinedExample.csproj", "{00E6E161-7DD9-49BB-AEDC-FFFD0447368E}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UsingSpectreConsole", "UsingSpectreConsole\UsingSpectreConsole.csproj", "{57204D65-A88B-4065-B690-F9CC6334EDEE}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Using.Spectre.Console.Rx.Tests", "Tests\Using.Spectre.Console.Rx.Tests.csproj", "{4DF93391-F7EA-4E36-8992-113387F4614A}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.Rx.Json", "Spectre.Console.Rx.Json\Spectre.Console.Rx.Json.csproj", "{D912E5E0-6701-4933-8D22-BD9681BCA71E}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.Rx.Testing", "Spectre.Console.Rx.Testing\Spectre.Console.Rx.Testing.csproj", "{2887DDD5-2622-487E-9378-C2539DC31C66}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -57,6 +65,22 @@ Global
{00E6E161-7DD9-49BB-AEDC-FFFD0447368E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00E6E161-7DD9-49BB-AEDC-FFFD0447368E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00E6E161-7DD9-49BB-AEDC-FFFD0447368E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {57204D65-A88B-4065-B690-F9CC6334EDEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {57204D65-A88B-4065-B690-F9CC6334EDEE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {57204D65-A88B-4065-B690-F9CC6334EDEE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {57204D65-A88B-4065-B690-F9CC6334EDEE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4DF93391-F7EA-4E36-8992-113387F4614A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4DF93391-F7EA-4E36-8992-113387F4614A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4DF93391-F7EA-4E36-8992-113387F4614A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4DF93391-F7EA-4E36-8992-113387F4614A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D912E5E0-6701-4933-8D22-BD9681BCA71E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D912E5E0-6701-4933-8D22-BD9681BCA71E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D912E5E0-6701-4933-8D22-BD9681BCA71E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D912E5E0-6701-4933-8D22-BD9681BCA71E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2887DDD5-2622-487E-9378-C2539DC31C66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2887DDD5-2622-487E-9378-C2539DC31C66}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2887DDD5-2622-487E-9378-C2539DC31C66}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2887DDD5-2622-487E-9378-C2539DC31C66}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -68,6 +92,8 @@ Global
{51ACCE14-F44D-455B-A5E9-AEC71F7C6F61} = {E17E245C-D501-46B4-A804-A68241982088}
{CB209C31-2CD2-4188-A088-18E68DF23788} = {E17E245C-D501-46B4-A804-A68241982088}
{00E6E161-7DD9-49BB-AEDC-FFFD0447368E} = {E17E245C-D501-46B4-A804-A68241982088}
+ {57204D65-A88B-4065-B690-F9CC6334EDEE} = {E17E245C-D501-46B4-A804-A68241982088}
+ {4DF93391-F7EA-4E36-8992-113387F4614A} = {E17E245C-D501-46B4-A804-A68241982088}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {2FADF055-54C6-4DE5-98D0-D6F352AB1EFA}
diff --git a/src/Spectre.Console.Rx/Spectre.Console.Rx.csproj b/src/Spectre.Console.Rx/Spectre.Console.Rx.csproj
index ebfd4d5..53bfe7b 100644
--- a/src/Spectre.Console.Rx/Spectre.Console.Rx.csproj
+++ b/src/Spectre.Console.Rx/Spectre.Console.Rx.csproj
@@ -11,6 +11,11 @@
False
+
+
+
+
+
@@ -19,7 +24,7 @@
-
+
diff --git a/src/Spectre.Console.Rx/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs b/src/Spectre.Console.Rx/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs
index 1792b07..a3858c0 100644
--- a/src/Spectre.Console.Rx/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs
+++ b/src/Spectre.Console.Rx/Spectre.Console/Internal/Extensions/EnumerableExtensions.cs
@@ -89,16 +89,21 @@ public static void ForEach(this IEnumerable source, Action action)
throw new ArgumentNullException(nameof(source));
}
- var first = true;
- var last = !source.MoveNext();
- T current;
+ return DoEnumeration();
- for (var index = 0; !last; index++)
+ IEnumerable<(int Index, bool First, bool Last, T Item)> DoEnumeration()
{
- current = source.Current;
- last = !source.MoveNext();
- yield return (index, first, last, current);
- first = false;
+ var first = true;
+ var last = !source.MoveNext();
+ T current;
+
+ for (var index = 0; !last; index++)
+ {
+ current = source.Current;
+ last = !source.MoveNext();
+ yield return (index, first, last, current);
+ first = false;
+ }
}
}
@@ -112,4 +117,4 @@ public static void ForEach(this IEnumerable source, Action action)
public static IEnumerable<(TFirst First, TSecond Second, TThird Third)> ZipThree(
this IEnumerable first, IEnumerable second, IEnumerable third) => first.Zip(second, (a, b) => (a, b))
.Zip(third, (a, b) => (a.a, a.b, b));
-}
\ No newline at end of file
+}
diff --git a/src/Spectre.Console.Rx/Spectre.Console/Widgets/Figlet/FigletFont.cs b/src/Spectre.Console.Rx/Spectre.Console/Widgets/Figlet/FigletFont.cs
index ee10db6..f5a9c95 100644
--- a/src/Spectre.Console.Rx/Spectre.Console/Widgets/Figlet/FigletFont.cs
+++ b/src/Spectre.Console.Rx/Spectre.Console/Widgets/Figlet/FigletFont.cs
@@ -8,7 +8,7 @@ namespace Spectre.Console.Rx;
///
public sealed class FigletFont
{
- private const string StandardFont = "Spectre.Console/Widgets/Figlet/Fonts/Standard.flf";
+ private const string StandardFont = "Spectre.Console.Rx/Spectre.Console/Widgets/Figlet/Fonts/Standard.flf";
private static readonly Lazy _standard;
private readonly Dictionary _characters;
diff --git a/src/Tests/BasicUsagesTest.WhenACalendarIsCreated_ThenPrettyPrintCalendar.verified.txt b/src/Tests/BasicUsagesTest.WhenACalendarIsCreated_ThenPrettyPrintCalendar.verified.txt
new file mode 100644
index 0000000..b9792a7
--- /dev/null
+++ b/src/Tests/BasicUsagesTest.WhenACalendarIsCreated_ThenPrettyPrintCalendar.verified.txt
@@ -0,0 +1,11 @@
+ 2023 November
+┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐
+│ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ 1 │ 2 │ 3 │ 4 │
+│ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │
+│ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │
+│ 19* │ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │
+│ 26 │ 27 │ 28 │ 29 │ 30 │ │ │
+│ │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
diff --git a/src/Tests/BasicUsagesTest.WhenJsonDataIsPassed_ThenRenderBeautifiedStudentsDataJson.verified.txt b/src/Tests/BasicUsagesTest.WhenJsonDataIsPassed_ThenRenderBeautifiedStudentsDataJson.verified.txt
new file mode 100644
index 0000000..f7210c6
--- /dev/null
+++ b/src/Tests/BasicUsagesTest.WhenJsonDataIsPassed_ThenRenderBeautifiedStudentsDataJson.verified.txt
@@ -0,0 +1,39 @@
+┌───────────Students────────────┐
+│ [ │
+│ { │
+│ "Id": 1, │
+│ "FirstName": "Julie", │
+│ "LastName": "Matthew", │
+│ "Age": 19, │
+│ "Hostel": "Lincoln" │
+│ }, │
+│ { │
+│ "Id": 2, │
+│ "FirstName": "Michael", │
+│ "LastName": "Taylor", │
+│ "Age": 23, │
+│ "Hostel": "George" │
+│ }, │
+│ { │
+│ "Id": 3, │
+│ "FirstName": "Joe", │
+│ "LastName": "Hardy", │
+│ "Age": 27, │
+│ "Hostel": "Laurent" │
+│ }, │
+│ { │
+│ "Id": 4, │
+│ "FirstName": "Sabrina", │
+│ "LastName": "Azulon", │
+│ "Age": 18, │
+│ "Hostel": "George" │
+│ }, │
+│ { │
+│ "Id": 5, │
+│ "FirstName": "Hunter", │
+│ "LastName": "Cyril", │
+│ "Age": 19, │
+│ "Hostel": "Kennedy" │
+│ } │
+│ ] │
+└───────────────────────────────┘
diff --git a/src/Tests/BasicUsagesTest.cs b/src/Tests/BasicUsagesTest.cs
new file mode 100644
index 0000000..94ce04f
--- /dev/null
+++ b/src/Tests/BasicUsagesTest.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Tests;
+
+///
+/// BasicUsagesTest.
+///
+[UsesVerify]
+public class BasicUsagesTest
+{
+ private static readonly List _students = StudentsGenerator.GenerateStudents();
+
+ ///
+ /// Whens the format character is escaped then render text with square brackets.
+ ///
+ [Fact]
+ public void WhenFormatCharacterIsEscaped_ThenRenderTextWithSquareBrackets()
+ {
+ var console = new TestConsole();
+ console.Markup($"[[{_students[3].FirstName}]][blue][[{_students[3].Hostel}]][/]");
+ console.Output.ShouldBe("[Sabrina][George]");
+ }
+
+ ///
+ /// Whens the json data is passed then render beautified students data json.
+ ///
+ /// A Task.
+ [Fact]
+ public Task WhenJsonDataIsPassed_ThenRenderBeautifiedStudentsDataJson()
+ {
+ var console = new TestConsole().Size(new Size(100, 25));
+ console.Write(SpectreConsoleBasicUsages.BeautifyStudentsDataJson());
+
+ return Verify(console.Output);
+ }
+
+ ///
+ /// Whens a calendar is created then pretty print calendar.
+ ///
+ /// A Task.
+ [Fact]
+ public Task WhenACalendarIsCreated_ThenPrettyPrintCalendar()
+ {
+ var console = new TestConsole();
+ console.Write(SpectreConsoleBasicUsages.PrettyPrintCalendar());
+
+ return Verify(console.Output);
+ }
+}
diff --git a/src/Tests/GlobalUsings.cs b/src/Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c47b1d0
--- /dev/null
+++ b/src/Tests/GlobalUsings.cs
@@ -0,0 +1,7 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+global using Shouldly;
+global using Spectre.Console.Rx;
+global using Spectre.Console.Rx.Testing;
+global using UsingSpectreDotConsole;
diff --git a/src/Tests/OtherUsagesTest.WhenDisplayFigLetIsInvoked_ThenDisplayFiglet.verified.txt b/src/Tests/OtherUsagesTest.WhenDisplayFigLetIsInvoked_ThenDisplayFiglet.verified.txt
new file mode 100644
index 0000000..38f45c3
--- /dev/null
+++ b/src/Tests/OtherUsagesTest.WhenDisplayFigLetIsInvoked_ThenDisplayFiglet.verified.txt
@@ -0,0 +1,6 @@
+ ____ _ _ __
+ | __ ) ___ __ _ _ _ | |_ (_) / _| _ _
+ | _ \ / _ \ / _` | | | | | | __| | | | |_ | | | |
+ | |_) | | __/ | (_| | | |_| | | |_ | | | _| | |_| |
+ |____/ \___| \__,_| \__,_| \__| |_| |_| \__, |
+ |___/
diff --git a/src/Tests/OtherUsagesTest.WhenPromptingStudentForAge_ThenReturnErrorIfValidationFails.verified.txt b/src/Tests/OtherUsagesTest.WhenPromptingStudentForAge_ThenReturnErrorIfValidationFails.verified.txt
new file mode 100644
index 0000000..aa8f664
--- /dev/null
+++ b/src/Tests/OtherUsagesTest.WhenPromptingStudentForAge_ThenReturnErrorIfValidationFails.verified.txt
@@ -0,0 +1 @@
+How old are you? 102
diff --git a/src/Tests/OtherUsagesTest.WhenStudentDataIsPassed_ThenRenderStudentsTable.verified.txt b/src/Tests/OtherUsagesTest.WhenStudentDataIsPassed_ThenRenderStudentsTable.verified.txt
new file mode 100644
index 0000000..a8db2de
--- /dev/null
+++ b/src/Tests/OtherUsagesTest.WhenStudentDataIsPassed_ThenRenderStudentsTable.verified.txt
@@ -0,0 +1,10 @@
+ STUDENTS
+┌────┬───────────┬─────┐
+│ Id │ FirstName │ Age │
+├────┼───────────┼─────┤
+│ 1 │ Julie │ 19 │
+│ 2 │ Michael │ 23 │
+│ 3 │ Joe │ 27 │
+│ 4 │ Sabrina │ 18 │
+│ 5 │ Hunter │ 19 │
+└────┴───────────┴─────┘
diff --git a/src/Tests/OtherUsagesTest.WhenStudentsDataIsPassed_ThenDisplayBarChart.verified.txt b/src/Tests/OtherUsagesTest.WhenStudentsDataIsPassed_ThenDisplayBarChart.verified.txt
new file mode 100644
index 0000000..add5a54
--- /dev/null
+++ b/src/Tests/OtherUsagesTest.WhenStudentsDataIsPassed_ThenDisplayBarChart.verified.txt
@@ -0,0 +1,6 @@
+ Number of Students per Hostel
+Lincoln ███████████████████████ 1
+ Louisa 0
+Laurent ███████████████████████ 1
+ George █████████████████████████████████████████████████ 2
+Kennedy ███████████████████████ 1
diff --git a/src/Tests/OtherUsagesTest.cs b/src/Tests/OtherUsagesTest.cs
new file mode 100644
index 0000000..b38202b
--- /dev/null
+++ b/src/Tests/OtherUsagesTest.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Tests;
+
+///
+/// OtherUsagesTest.
+///
+[UsesVerify]
+public class OtherUsagesTest
+{
+ ///
+ /// Whens the student data is passed then render students table.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public Task WhenStudentDataIsPassed_ThenRenderStudentsTable()
+ {
+ var console = new TestConsole();
+ console.Write(SpectreConsoleOtherUsages.CreateStudentTable());
+
+ return Verify(console.Output);
+ }
+
+ ///
+ /// Whens the prompting student for age then return error if validation fails.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public Task WhenPromptingStudentForAge_ThenReturnErrorIfValidationFails()
+ {
+ var console = new TestConsole();
+ console.Input.PushTextWithEnter("102");
+ console.Input.PushTextWithEnter("ABC");
+ console.Input.PushTextWithEnter("99");
+ console.Input.PushTextWithEnter("22");
+
+ console.Prompt(SpectreConsoleOtherUsages.GetAgeTextPrompt());
+
+ return Verify(console.Output);
+ }
+
+ ///
+ /// Whens the prompting student for hostel then display hotel selection prompt.
+ ///
+ [Fact]
+ public void WhenPromptingStudentForHostel_ThenDisplayHotelSelectionPrompt()
+ {
+ var console = new TestConsole();
+ console.Profile.Capabilities.Interactive = true;
+ console.Input.PushKey(ConsoleKey.Enter);
+
+ var prompt = SpectreConsoleOtherUsages.GetHostelSelectionPrompt();
+ prompt.Show(console);
+
+ console.Output.ShouldContain("Choose a Hostel");
+ console.Output.ShouldContain("Lincoln");
+ console.Output.ShouldContain("Louisa");
+ console.Output.ShouldContain("Laurent");
+ console.Output.ShouldContain("George");
+ console.Output.ShouldContain("Kennedy");
+ }
+
+ ///
+ /// Whens the students data is passed then display bar chart.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task WhenStudentsDataIsPassed_ThenDisplayBarChart()
+ {
+ var console = new TestConsole();
+ console.Write(SpectreConsoleOtherUsages.CreateHostelBarChart());
+
+ await Verify(console.Output);
+ }
+
+ ///
+ /// Whens the display fig let is invoked then display figlet.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task WhenDisplayFigLetIsInvoked_ThenDisplayFiglet()
+ {
+ var console = new TestConsole().Width(100);
+ console.Write(SpectreConsoleOtherUsages.DisplayFiglet());
+
+ await Verify(console.Output);
+ }
+}
diff --git a/src/Tests/Using.Spectre.Console.Rx.Tests.csproj b/src/Tests/Using.Spectre.Console.Rx.Tests.csproj
new file mode 100644
index 0000000..93750db
--- /dev/null
+++ b/src/Tests/Using.Spectre.Console.Rx.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ false
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/src/UsingSpectreConsole/Program.cs b/src/UsingSpectreConsole/Program.cs
new file mode 100644
index 0000000..6b6eea7
--- /dev/null
+++ b/src/UsingSpectreConsole/Program.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using UsingSpectreDotConsole;
+
+SpectreConsoleOtherUsages.CreateHostelBarChart();
+SpectreConsoleOtherUsages.GetHostelSelectionPrompt();
+SpectreConsoleOtherUsages.PromptStudent();
+SpectreConsoleOtherUsages.CreateStudentTable();
+SpectreConsoleOtherUsages.GetAgeTextPrompt();
+SpectreConsoleOtherUsages.WriteReadableException();
diff --git a/src/UsingSpectreConsole/SpectreConsoleBasicUsages.cs b/src/UsingSpectreConsole/SpectreConsoleBasicUsages.cs
new file mode 100644
index 0000000..809eddb
--- /dev/null
+++ b/src/UsingSpectreConsole/SpectreConsoleBasicUsages.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Spectre.Console.Rx;
+using Spectre.Console.Rx.Json;
+
+namespace UsingSpectreDotConsole;
+
+///
+/// SpectreConsoleBasicUsages.
+///
+public static class SpectreConsoleBasicUsages
+{
+ private static readonly List _students = StudentsGenerator.GenerateStudents();
+
+ ///
+ /// Sets the color of the text.
+ ///
+ public static void SetTextColor()
+ {
+ AnsiConsole.Markup($"[bold blue]Hello[/] [italic green]{_students[1].FirstName}[/]!");
+ AnsiConsole.Write(new Markup($"[underline #800080]{_students[2].FirstName}[/]"));
+ AnsiConsole.MarkupLine($"[rgb(128,0,0)]{_students[3].FirstName}[/]");
+ }
+
+ ///
+ /// Sets the color of the background.
+ ///
+ public static void SetBackgroundColor() =>
+ AnsiConsole.MarkupLine($"[red on yellow] Hello, {_students[4].LastName}![/]");
+
+ ///
+ /// Escapes the format characters.
+ ///
+ public static void EscapeFormatCharacters()
+ {
+ AnsiConsole.Markup($"[[{_students[3].FirstName}]]");
+ AnsiConsole.MarkupLine($"[blue][[{_students[3].Hostel}]][/]");
+ AnsiConsole.MarkupLine($"[{_students[3].Age}]".EscapeMarkup());
+ }
+
+ ///
+ /// Beautifies the students data json.
+ ///
+ /// A Panel.
+ public static Panel BeautifyStudentsDataJson()
+ {
+ var json = new JsonText(StudentsGenerator.ConvertStudentsToJson(_students));
+ var panel = new Panel(json)
+ .Header("Students")
+ .HeaderAlignment(Justify.Center)
+ .SquareBorder()
+ .Collapse()
+ .BorderColor(Color.LightSkyBlue1);
+
+ AnsiConsole.Write(panel);
+
+ return panel;
+ }
+
+ ///
+ /// Pretties the print calendar.
+ ///
+ /// A Calendar.
+ public static Calendar PrettyPrintCalendar()
+ {
+ var calendar = new Calendar(2023, 11)
+ .AddCalendarEvent(2023, 11, 19)
+ .HighlightStyle(Style.Parse("magenta bold"))
+ .HeaderStyle(Style.Parse("purple"));
+
+ AnsiConsole.Write(calendar);
+
+ return calendar;
+ }
+}
diff --git a/src/UsingSpectreConsole/SpectreConsoleOtherUsages.cs b/src/UsingSpectreConsole/SpectreConsoleOtherUsages.cs
new file mode 100644
index 0000000..d0dfb6d
--- /dev/null
+++ b/src/UsingSpectreConsole/SpectreConsoleOtherUsages.cs
@@ -0,0 +1,162 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Spectre.Console.Rx;
+
+namespace UsingSpectreDotConsole;
+
+///
+/// SpectreConsoleOtherUsages.
+///
+public static class SpectreConsoleOtherUsages
+{
+ private static readonly List _students = StudentsGenerator.GenerateStudents();
+
+ ///
+ /// Creates the student table.
+ ///
+ /// A Table.
+ public static Table CreateStudentTable()
+ {
+ var table = new Table
+ {
+ Title = new TableTitle("STUDENTS", "bold green")
+ };
+
+ table.AddColumns("[yellow]Id[/]", $"[{Color.Olive}]FirstName[/]", "[Fuchsia]Age[/]");
+
+ foreach (var student in _students)
+ {
+ table.AddRow(student.Id.ToString(), $"[red]{student.FirstName}[/]", $"[cyan]{student.Age}[/]");
+ }
+
+ AnsiConsole.Write(table);
+
+ return table;
+ }
+
+ ///
+ /// Writes the readable exception.
+ ///
+ public static void WriteReadableException()
+ {
+ try
+ {
+ File.OpenRead("nofile.txt");
+ }
+ catch (FileNotFoundException ex)
+ {
+ AnsiConsole.WriteException(ex, ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenMethods);
+ }
+ }
+
+ ///
+ /// Gets the age text prompt.
+ ///
+ /// A TextPrompt.
+ public static TextPrompt GetAgeTextPrompt() =>
+ new TextPrompt("[green]How old are you[/]?")
+ .PromptStyle("green")
+ .ValidationErrorMessage("[red]That's not a valid age[/]")
+ .Validate(age => age switch
+ {
+ <= 10 => ValidationResult.Error("[red]You must be above 10 years[/]"),
+ >= 123 => ValidationResult.Error("[red]You must be younger than that[/]"),
+ _ => ValidationResult.Success(),
+ });
+
+ ///
+ /// Gets the hostel selection prompt.
+ ///
+ /// A SelectionPrompt.
+ public static SelectionPrompt GetHostelSelectionPrompt()
+ {
+ var hostels = StudentsGenerator.Hostels;
+
+ return new SelectionPrompt()
+ .Title("Choose a hostel")
+ .AddChoices(hostels);
+ }
+
+ ///
+ /// Prompts the student.
+ ///
+ /// A Student.
+ public static Student PromptStudent()
+ {
+ var student = new Student
+ {
+ FirstName = AnsiConsole.Ask("[green]What's your First Name[/]?"),
+ LastName = AnsiConsole.Ask("[green]What's your Last Name[/]?"),
+
+ Age = AnsiConsole.Prompt(GetAgeTextPrompt()),
+
+ Hostel = AnsiConsole.Prompt(GetHostelSelectionPrompt())
+ };
+
+ AnsiConsole.MarkupLine($"Alright [yellow]{student.FirstName} {student.LastName}[/], welcome!");
+
+ return student;
+ }
+
+ ///
+ /// Creates the hostel bar chart.
+ ///
+ /// A BarChart.
+ public static BarChart CreateHostelBarChart()
+ {
+ var barChart = new BarChart()
+ .Width(60)
+ .Label("[orange1 bold underline]Number of Students per Hostel[/]")
+ .CenterLabel();
+
+ var hostels = StudentsGenerator.Hostels;
+ var colors = new List { Color.Red, Color.Fuchsia, Color.Blue, Color.Yellow, Color.Magenta1 };
+
+ for (var i = 0; i < hostels.Length; i++)
+ {
+ var hostel = hostels[i];
+ var color = colors[i];
+ var count = _students.Count(s => s.Hostel == hostel);
+ barChart.AddItem(hostel, count, color);
+ }
+
+ AnsiConsole.Write(barChart);
+
+ return barChart;
+ }
+
+ ///
+ /// Displays the progress.
+ ///
+ public static void DisplayProgress()
+ {
+ var incrementValue = 100 / _students.Count;
+
+ AnsiConsoleRx.Progress()
+ .Subscribe(ctx =>
+ {
+ var streamingTask = ctx.AddTask("Student Streaming");
+
+ foreach (var x in StudentsGenerator.StreamStudentsFromDatabase())
+ {
+ streamingTask.Increment(incrementValue);
+ }
+ });
+ }
+
+ ///
+ /// Displays the figlet.
+ ///
+ /// A FigletText.
+ public static FigletText DisplayFiglet()
+ {
+ var text = new FigletText("Beautify")
+ .LeftJustified()
+ .Color(Color.Orchid);
+
+ AnsiConsole.Write(text);
+
+ return text;
+ }
+}
diff --git a/src/UsingSpectreConsole/Student.cs b/src/UsingSpectreConsole/Student.cs
new file mode 100644
index 0000000..9209aea
--- /dev/null
+++ b/src/UsingSpectreConsole/Student.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace UsingSpectreDotConsole;
+
+///
+/// Student.
+///
+public class Student
+{
+ ///
+ /// Gets or sets the identifier.
+ ///
+ ///
+ /// The identifier.
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the first name.
+ ///
+ ///
+ /// The first name.
+ ///
+ public string? FirstName { get; set; }
+
+ ///
+ /// Gets or sets the last name.
+ ///
+ ///
+ /// The last name.
+ ///
+ public string? LastName { get; set; }
+
+ ///
+ /// Gets or sets the age.
+ ///
+ ///
+ /// The age.
+ ///
+ public int Age { get; set; }
+
+ ///
+ /// Gets or sets the hostel.
+ ///
+ ///
+ /// The hostel.
+ ///
+ public string? Hostel { get; set; }
+}
diff --git a/src/UsingSpectreConsole/StudentsGenerator.cs b/src/UsingSpectreConsole/StudentsGenerator.cs
new file mode 100644
index 0000000..f942732
--- /dev/null
+++ b/src/UsingSpectreConsole/StudentsGenerator.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Chris Pulman. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.Json;
+
+namespace UsingSpectreDotConsole;
+
+///
+/// StudentsGenerator.
+///
+public static class StudentsGenerator
+{
+ private static readonly JsonSerializerOptions _writeOptions = new()
+ {
+ WriteIndented = true
+ };
+
+ ///
+ /// Gets the hostels.
+ ///
+ ///
+ /// The hostels.
+ ///
+ public static string[] Hostels { get; }
+ = ["Lincoln", "Louisa", "Laurent", "George", "Kennedy"];
+
+ ///
+ /// Generates the students.
+ ///
+ /// A collection of Student.
+ public static List GenerateStudents() => new()
+ {
+ new Student { Id = 1, FirstName = "Julie", LastName = "Matthew", Age = 19, Hostel = Hostels[0] },
+ new Student { Id = 2, FirstName = "Michael", LastName = "Taylor", Age = 23, Hostel = Hostels[3] },
+ new Student { Id = 3, FirstName = "Joe", LastName = "Hardy", Age = 27, Hostel = Hostels[2] },
+ new Student { Id = 4, FirstName = "Sabrina", LastName = "Azulon", Age = 18, Hostel = Hostels[3] },
+ new Student { Id = 5, FirstName = "Hunter", LastName = "Cyril", Age = 19, Hostel = Hostels[4] },
+ };
+
+ ///
+ /// Converts the students to json.
+ ///
+ /// The students.
+ /// A string.
+ public static string ConvertStudentsToJson(List students) =>
+ JsonSerializer.Serialize(students, _writeOptions);
+
+ ///
+ /// Streams the students from database.
+ ///
+ /// A collection of Student.
+ public static IEnumerable StreamStudentsFromDatabase()
+ {
+ var students = GenerateStudents();
+
+ for (var i = 0; i < students.Count; i++)
+ {
+ yield return students[i];
+ Thread.Sleep(1000);
+ }
+ }
+}
diff --git a/src/UsingSpectreConsole/UsingSpectreConsole.csproj b/src/UsingSpectreConsole/UsingSpectreConsole.csproj
new file mode 100644
index 0000000..99e3c8a
--- /dev/null
+++ b/src/UsingSpectreConsole/UsingSpectreConsole.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ False
+
+
+
+
+
+
+