From 8e4f33bba4b2f4d4e585c7f32c2c7b738a71cc98 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Wed, 29 Jul 2020 17:34:54 +0200 Subject: [PATCH] Added initial support for rendering composites This is far from complete, but it's a start and it will enable us to create things like tables and other complex objects in the long run. --- src/Sample/Program.cs | 44 ++++- src/Spectre.Console.Tests/PlainConsole.cs | 42 +++++ .../{ => Unit}/AnsiConsoleFixture.cs | 0 .../{ => Unit}/AnsiConsoleTests.Colors.cs | 0 .../{ => Unit}/AnsiConsoleTests.Markup.cs | 0 .../{ => Unit}/AnsiConsoleTests.Style.cs | 0 .../{ => Unit}/AnsiConsoleTests.cs | 0 .../Unit/Composition/PanelTests.cs | 126 +++++++++++++++ .../Unit/Composition/SegmentTests.cs | 67 ++++++++ .../Unit/Composition/TextTests.cs | 61 +++++++ src/Spectre.Console/AnsiConsole.Rendering.cs | 19 +++ src/Spectre.Console/AnsiConsole.cs | 2 +- src/Spectre.Console/Appearance.cs | 86 ++++++++++ ...ConsoleCapabilities.cs => Capabilities.cs} | 4 +- src/Spectre.Console/Color.cs | 7 + .../Composition/IRenderable.cs | 27 ++++ src/Spectre.Console/Composition/Segment.cs | 130 +++++++++++++++ .../Composition/SegmentLine.cs | 13 ++ .../ConsoleExtensions.Rendering.cs | 45 ++++++ src/Spectre.Console/IAnsiConsole.cs | 9 +- .../Internal/Ansi/AnsiDetector.cs | 10 +- .../{Rendering => }/AnsiConsoleRenderer.cs | 9 +- .../Internal/ConsoleBuilder.cs | 2 +- .../Internal/Extensions/CharExtensions.cs | 12 ++ .../Internal/Extensions/ConsoleExtensions.cs | 42 +++++ .../Internal/Extensions/StringExtensions.cs | 36 +++++ .../FallbackConsoleRenderer.cs | 15 +- src/Spectre.Console/Internal/Text/Cell.cs | 150 ++++++++++++++++++ .../{ => Text}/Markup/Ast/MarkupBlockNode.cs | 0 .../{ => Text}/Markup/Ast/MarkupStyleNode.cs | 0 .../{ => Text}/Markup/Ast/MarkupTextNode.cs | 0 .../Internal/{ => Text}/Markup/IMarkupNode.cs | 0 .../{ => Text}/Markup/MarkupParser.cs | 0 .../{ => Text}/Markup/MarkupStyleParser.cs | 0 .../Internal/{ => Text}/Markup/MarkupToken.cs | 0 .../{ => Text}/Markup/MarkupTokenKind.cs | 0 .../{ => Text}/Markup/MarkupTokenizer.cs | 0 .../Internal/{Markup => Text}/StringBuffer.cs | 0 src/Spectre.Console/Justify.cs | 23 +++ src/Spectre.Console/Renderables/Panel.cs | 80 ++++++++++ src/Spectre.Console/Renderables/Text.cs | 127 +++++++++++++++ 41 files changed, 1164 insertions(+), 24 deletions(-) create mode 100644 src/Spectre.Console.Tests/PlainConsole.cs rename src/Spectre.Console.Tests/{ => Unit}/AnsiConsoleFixture.cs (100%) rename src/Spectre.Console.Tests/{ => Unit}/AnsiConsoleTests.Colors.cs (100%) rename src/Spectre.Console.Tests/{ => Unit}/AnsiConsoleTests.Markup.cs (100%) rename src/Spectre.Console.Tests/{ => Unit}/AnsiConsoleTests.Style.cs (100%) rename src/Spectre.Console.Tests/{ => Unit}/AnsiConsoleTests.cs (100%) create mode 100644 src/Spectre.Console.Tests/Unit/Composition/PanelTests.cs create mode 100644 src/Spectre.Console.Tests/Unit/Composition/SegmentTests.cs create mode 100644 src/Spectre.Console.Tests/Unit/Composition/TextTests.cs create mode 100644 src/Spectre.Console/AnsiConsole.Rendering.cs create mode 100644 src/Spectre.Console/Appearance.cs rename src/Spectre.Console/{AnsiConsoleCapabilities.cs => Capabilities.cs} (89%) create mode 100644 src/Spectre.Console/Composition/IRenderable.cs create mode 100644 src/Spectre.Console/Composition/Segment.cs create mode 100644 src/Spectre.Console/Composition/SegmentLine.cs create mode 100644 src/Spectre.Console/ConsoleExtensions.Rendering.cs rename src/Spectre.Console/Internal/{Rendering => }/AnsiConsoleRenderer.cs (85%) create mode 100644 src/Spectre.Console/Internal/Extensions/CharExtensions.cs create mode 100644 src/Spectre.Console/Internal/Extensions/StringExtensions.cs rename src/Spectre.Console/Internal/{Rendering => }/FallbackConsoleRenderer.cs (89%) create mode 100644 src/Spectre.Console/Internal/Text/Cell.cs rename src/Spectre.Console/Internal/{ => Text}/Markup/Ast/MarkupBlockNode.cs (100%) rename src/Spectre.Console/Internal/{ => Text}/Markup/Ast/MarkupStyleNode.cs (100%) rename src/Spectre.Console/Internal/{ => Text}/Markup/Ast/MarkupTextNode.cs (100%) rename src/Spectre.Console/Internal/{ => Text}/Markup/IMarkupNode.cs (100%) rename src/Spectre.Console/Internal/{ => Text}/Markup/MarkupParser.cs (100%) rename src/Spectre.Console/Internal/{ => Text}/Markup/MarkupStyleParser.cs (100%) rename src/Spectre.Console/Internal/{ => Text}/Markup/MarkupToken.cs (100%) rename src/Spectre.Console/Internal/{ => Text}/Markup/MarkupTokenKind.cs (100%) rename src/Spectre.Console/Internal/{ => Text}/Markup/MarkupTokenizer.cs (100%) rename src/Spectre.Console/Internal/{Markup => Text}/StringBuffer.cs (100%) create mode 100644 src/Spectre.Console/Justify.cs create mode 100644 src/Spectre.Console/Renderables/Panel.cs create mode 100644 src/Spectre.Console/Renderables/Text.cs diff --git a/src/Sample/Program.cs b/src/Sample/Program.cs index cc9d8ea..57e6c2a 100644 --- a/src/Sample/Program.cs +++ b/src/Sample/Program.cs @@ -13,7 +13,7 @@ namespace Sample AnsiConsole.WriteLine("Hello World!"); AnsiConsole.Reset(); AnsiConsole.MarkupLine("Capabilities: [yellow underline]{0}[/]", AnsiConsole.Capabilities); - AnsiConsole.WriteLine($"Width={AnsiConsole.Width}, Height={AnsiConsole.Height}"); + AnsiConsole.MarkupLine("Width=[yellow]{0}[/], Height=[yellow]{1}[/]", AnsiConsole.Width, AnsiConsole.Height); AnsiConsole.MarkupLine("[white on red]Good[/] [red]bye[/]!"); AnsiConsole.WriteLine(); @@ -44,13 +44,41 @@ namespace Sample console.WriteLine("Hello World!"); console.ResetColors(); console.ResetStyle(); - console.WriteLine("Capabilities: {0}", AnsiConsole.Capabilities); - console.MarkupLine("Width=[yellow]{0}[/], Height=[yellow]{1}[/]", AnsiConsole.Width, AnsiConsole.Height); - console.WriteLine("Good bye!"); + console.MarkupLine("Capabilities: [yellow underline]{0}[/]", console.Capabilities); + console.MarkupLine("Width=[yellow]{0}[/], Height=[yellow]{1}[/]", console.Width, console.Height); + console.MarkupLine("[white on red]Good[/] [red]bye[/]!"); console.WriteLine(); + + // Nest some panels and text + AnsiConsole.Foreground = Color.Maroon; + AnsiConsole.Render(new Panel(new Panel(new Panel(new Panel( + Text.New( + "I heard you like πŸ“¦\n\n\n\nSo I put a πŸ“¦ in a πŸ“¦", + foreground: Color.White, + justify: Justify.Center)))))); + + // Reset colors + AnsiConsole.ResetColors(); + + // Left adjusted panel with text + AnsiConsole.Render(new Panel( + Text.New("Left adjusted\nLeft", + foreground: Color.White), + fit: true)); + + // Centered panel with text + AnsiConsole.Render(new Panel( + Text.New("Centered\nCenter", + foreground: Color.White, + justify: Justify.Center), + fit: true)); + + // Right adjusted panel with text + AnsiConsole.Render(new Panel( + Text.New("Right adjusted\nRight", + foreground: Color.White, + justify: Justify.Right), + fit: true)); } } -} - - - +} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/PlainConsole.cs b/src/Spectre.Console.Tests/PlainConsole.cs new file mode 100644 index 0000000..fb84731 --- /dev/null +++ b/src/Spectre.Console.Tests/PlainConsole.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Spectre.Console.Tests +{ + public sealed class PlainConsole : IAnsiConsole, IDisposable + { + public Capabilities Capabilities => throw new NotSupportedException(); + public Encoding Encoding { get; } + + public int Width { get; } + public int Height { get; } + + public Styles Style { get; set; } + public Color Foreground { get; set; } + public Color Background { get; set; } + + public StringWriter Writer { get; } + public string Output => Writer.ToString().TrimEnd('\n'); + public IReadOnlyList Lines => Output.Split(new char[] { '\n' }); + + public PlainConsole(int width = 80, int height = 9000, Encoding encoding = null) + { + Width = width; + Height = height; + Encoding = encoding ?? Encoding.UTF8; + Writer = new StringWriter(); + } + + public void Dispose() + { + Writer.Dispose(); + } + + public void Write(string text) + { + Writer.Write(text); + } + } +} diff --git a/src/Spectre.Console.Tests/AnsiConsoleFixture.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleFixture.cs similarity index 100% rename from src/Spectre.Console.Tests/AnsiConsoleFixture.cs rename to src/Spectre.Console.Tests/Unit/AnsiConsoleFixture.cs diff --git a/src/Spectre.Console.Tests/AnsiConsoleTests.Colors.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Colors.cs similarity index 100% rename from src/Spectre.Console.Tests/AnsiConsoleTests.Colors.cs rename to src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Colors.cs diff --git a/src/Spectre.Console.Tests/AnsiConsoleTests.Markup.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Markup.cs similarity index 100% rename from src/Spectre.Console.Tests/AnsiConsoleTests.Markup.cs rename to src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Markup.cs diff --git a/src/Spectre.Console.Tests/AnsiConsoleTests.Style.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Style.cs similarity index 100% rename from src/Spectre.Console.Tests/AnsiConsoleTests.Style.cs rename to src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Style.cs diff --git a/src/Spectre.Console.Tests/AnsiConsoleTests.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.cs similarity index 100% rename from src/Spectre.Console.Tests/AnsiConsoleTests.cs rename to src/Spectre.Console.Tests/Unit/AnsiConsoleTests.cs diff --git a/src/Spectre.Console.Tests/Unit/Composition/PanelTests.cs b/src/Spectre.Console.Tests/Unit/Composition/PanelTests.cs new file mode 100644 index 0000000..74383e7 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/Composition/PanelTests.cs @@ -0,0 +1,126 @@ +using Shouldly; +using Spectre.Console.Composition; +using Xunit; + +namespace Spectre.Console.Tests.Unit.Composition +{ + public sealed class PanelTests + { + [Fact] + public void Should_Render_Panel() + { + // Given + var console = new PlainConsole(width: 80); + + // When + console.Render(new Panel(new Text("Hello World"))); + + // Then + console.Lines.Count.ShouldBe(3); + console.Lines[0].ShouldBe("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”"); + console.Lines[1].ShouldBe("β”‚ Hello World β”‚"); + console.Lines[2].ShouldBe("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜"); + } + + [Fact] + public void Should_Render_Panel_With_Unicode_Correctly() + { + // Given + var console = new PlainConsole(width: 80); + + // When + console.Render(new Panel(new Text(" \nπŸ’©\n "))); + + // Then + console.Lines.Count.ShouldBe(5); + console.Lines[0].ShouldBe("β”Œβ”€β”€β”€β”€β”"); + console.Lines[1].ShouldBe("β”‚ β”‚"); + console.Lines[2].ShouldBe("β”‚ πŸ’© β”‚"); + console.Lines[3].ShouldBe("β”‚ β”‚"); + console.Lines[4].ShouldBe("β””β”€β”€β”€β”€β”˜"); + } + + [Fact] + public void Should_Render_Panel_With_Multiple_Lines() + { + // Given + var console = new PlainConsole(width: 80); + + // When + console.Render(new Panel(new Text("Hello World\nFoo Bar"))); + + // Then + console.Lines.Count.ShouldBe(4); + console.Lines[0].ShouldBe("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”"); + console.Lines[1].ShouldBe("β”‚ Hello World β”‚"); + console.Lines[2].ShouldBe("β”‚ Foo Bar β”‚"); + console.Lines[3].ShouldBe("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜"); + } + + [Fact] + public void Should_Fit_Panel_To_Parent_If_Enabled() + { + // Given + var console = new PlainConsole(width: 25); + + // When + console.Render(new Panel(new Text("Hello World"), fit: true)); + + // Then + console.Lines.Count.ShouldBe(3); + console.Lines[0].ShouldBe("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”"); + console.Lines[1].ShouldBe("β”‚ Hello World β”‚"); + console.Lines[2].ShouldBe("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜"); + } + + [Fact] + public void Should_Justify_Child_To_Right() + { + // Given + var console = new PlainConsole(width: 25); + + // When + console.Render(new Panel(new Text("Hello World", justify: Justify.Right), fit: true)); + + // Then + console.Lines.Count.ShouldBe(3); + console.Lines[0].ShouldBe("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”"); + console.Lines[1].ShouldBe("β”‚ Hello World β”‚"); + console.Lines[2].ShouldBe("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜"); + } + + [Fact] + public void Should_Justify_Child_To_Center() + { + // Given + var console = new PlainConsole(width: 25); + + // When + console.Render(new Panel(new Text("Hello World", justify: Justify.Center), fit: true)); + + // Then + console.Lines.Count.ShouldBe(3); + console.Lines[0].ShouldBe("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”"); + console.Lines[1].ShouldBe("β”‚ Hello World β”‚"); + console.Lines[2].ShouldBe("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜"); + } + + [Fact] + public void Should_Render_Panel_Inside_Panel_Correctly() + { + // Given + var console = new PlainConsole(width: 80); + + // When + console.Render(new Panel(new Panel(new Text("Hello World")))); + + // Then + console.Lines.Count.ShouldBe(5); + console.Lines[0].ShouldBe("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”"); + console.Lines[1].ShouldBe("β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚"); + console.Lines[2].ShouldBe("β”‚ β”‚ Hello World β”‚ β”‚"); + console.Lines[3].ShouldBe("β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚"); + console.Lines[4].ShouldBe("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜"); + } + } +} diff --git a/src/Spectre.Console.Tests/Unit/Composition/SegmentTests.cs b/src/Spectre.Console.Tests/Unit/Composition/SegmentTests.cs new file mode 100644 index 0000000..1d7ec12 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/Composition/SegmentTests.cs @@ -0,0 +1,67 @@ +using Shouldly; +using Spectre.Console.Composition; +using Xunit; + +namespace Spectre.Console.Tests.Unit.Composition +{ + public sealed class SegmentTests + { + [Fact] + public void Should_Split_Segment() + { + var lines = Segment.Split(new[] + { + new Segment("Foo"), + new Segment("Bar"), + new Segment("\n"), + new Segment("Baz"), + new Segment("Qux"), + new Segment("\n"), + new Segment("Corgi"), + }); + + // Then + lines.Count.ShouldBe(3); + + lines[0].Count.ShouldBe(2); + lines[0][0].Text.ShouldBe("Foo"); + lines[0][1].Text.ShouldBe("Bar"); + + lines[1].Count.ShouldBe(2); + lines[1][0].Text.ShouldBe("Baz"); + lines[1][1].Text.ShouldBe("Qux"); + + lines[2].Count.ShouldBe(1); + lines[2][0].Text.ShouldBe("Corgi"); + } + + [Fact] + public void Should_Split_Segments_With_Linebreak_In_Text() + { + var lines = Segment.Split(new[] + { + new Segment("Foo\n"), + new Segment("Bar\n"), + new Segment("Baz"), + new Segment("Qux\n"), + new Segment("Corgi"), + }); + + // Then + lines.Count.ShouldBe(4); + + lines[0].Count.ShouldBe(1); + lines[0][0].Text.ShouldBe("Foo"); + + lines[1].Count.ShouldBe(1); + lines[1][0].Text.ShouldBe("Bar"); + + lines[2].Count.ShouldBe(2); + lines[2][0].Text.ShouldBe("Baz"); + lines[2][1].Text.ShouldBe("Qux"); + + lines[3].Count.ShouldBe(1); + lines[3][0].Text.ShouldBe("Corgi"); + } + } +} diff --git a/src/Spectre.Console.Tests/Unit/Composition/TextTests.cs b/src/Spectre.Console.Tests/Unit/Composition/TextTests.cs new file mode 100644 index 0000000..4502678 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/Composition/TextTests.cs @@ -0,0 +1,61 @@ +using Shouldly; +using Spectre.Console.Composition; +using Xunit; + +namespace Spectre.Console.Tests.Composition +{ + public sealed class TextTests + { + [Fact] + public void Should_Render_Text_To_Console() + { + // Given + var console = new PlainConsole(); + + // When + console.Render(new Text("Hello World")); + + // Then + console.Output.ShouldBe("Hello World"); + } + + [Fact] + public void Should_Right_Align_Text_To_Parent() + { + // Given + var console = new PlainConsole(width: 15); + + // When + console.Render(new Text("Hello World", justify: Justify.Right)); + + // Then + console.Output.ShouldBe(" Hello World"); + } + + [Fact] + public void Should_Center_Text_To_Parent() + { + // Given + var console = new PlainConsole(width: 15); + + // When + console.Render(new Text("Hello World", justify: Justify.Center)); + + // Then + console.Output.ShouldBe(" Hello World "); + } + + [Fact] + public void Should_Split_Text_To_Multiple_Lines_If_It_Does_Not_Fit() + { + // Given + var console = new PlainConsole(width: 5); + + // When + console.Render(new Text("Hello World")); + + // Then + console.Output.ShouldBe("Hello\n Worl\nd"); + } + } +} diff --git a/src/Spectre.Console/AnsiConsole.Rendering.cs b/src/Spectre.Console/AnsiConsole.Rendering.cs new file mode 100644 index 0000000..c49e139 --- /dev/null +++ b/src/Spectre.Console/AnsiConsole.Rendering.cs @@ -0,0 +1,19 @@ +using Spectre.Console.Composition; + +namespace Spectre.Console +{ + /// + /// A console capable of writing ANSI escape sequences. + /// + public static partial class AnsiConsole + { + /// + /// Renders the specified object to the console. + /// + /// The object to render. + public static void Render(IRenderable renderable) + { + Console.Render(renderable); + } + } +} diff --git a/src/Spectre.Console/AnsiConsole.cs b/src/Spectre.Console/AnsiConsole.cs index 682effa..a1e5612 100644 --- a/src/Spectre.Console/AnsiConsole.cs +++ b/src/Spectre.Console/AnsiConsole.cs @@ -26,7 +26,7 @@ namespace Spectre.Console /// /// Gets the console's capabilities. /// - public static AnsiConsoleCapabilities Capabilities => Console.Capabilities; + public static Capabilities Capabilities => Console.Capabilities; /// /// Gets the buffer width of the console. diff --git a/src/Spectre.Console/Appearance.cs b/src/Spectre.Console/Appearance.cs new file mode 100644 index 0000000..eb1b986 --- /dev/null +++ b/src/Spectre.Console/Appearance.cs @@ -0,0 +1,86 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Represents color and style. + /// + public sealed class Appearance : IEquatable + { + /// + /// Gets the foreground color. + /// + public Color Foreground { get; } + + /// + /// Gets the background color. + /// + public Color Background { get; } + + /// + /// Gets the style. + /// + public Styles Style { get; } + + /// + /// Gets an with the + /// default color and without style. + /// + public static Appearance Plain { get; } + + static Appearance() + { + Plain = new Appearance(); + } + + private Appearance() + : this(null, null, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The foreground color. + /// The background color. + /// The style. + public Appearance(Color? foreground = null, Color? background = null, Styles? style = null) + { + Foreground = foreground ?? Color.Default; + Background = background ?? Color.Default; + Style = style ?? Styles.None; + } + + /// + public override int GetHashCode() + { + unchecked + { + var hash = (int)2166136261; + hash = (hash * 16777619) ^ Foreground.GetHashCode(); + hash = (hash * 16777619) ^ Background.GetHashCode(); + hash = (hash * 16777619) ^ Style.GetHashCode(); + return hash; + } + } + + /// + public override bool Equals(object obj) + { + return Equals(obj as Appearance); + } + + /// + public bool Equals(Appearance other) + { + if (other == null) + { + return false; + } + + return Foreground.Equals(other.Foreground) && + Background.Equals(other.Background) && + Style == other.Style; + } + } +} diff --git a/src/Spectre.Console/AnsiConsoleCapabilities.cs b/src/Spectre.Console/Capabilities.cs similarity index 89% rename from src/Spectre.Console/AnsiConsoleCapabilities.cs rename to src/Spectre.Console/Capabilities.cs index 1c3a6f3..abc3e91 100644 --- a/src/Spectre.Console/AnsiConsoleCapabilities.cs +++ b/src/Spectre.Console/Capabilities.cs @@ -3,7 +3,7 @@ namespace Spectre.Console /// /// Represents console capabilities. /// - public sealed class AnsiConsoleCapabilities + public sealed class Capabilities { /// /// Gets a value indicating whether or not @@ -16,7 +16,7 @@ namespace Spectre.Console /// public ColorSystem ColorSystem { get; } - internal AnsiConsoleCapabilities(bool supportsAnsi, ColorSystem colorSystem) + internal Capabilities(bool supportsAnsi, ColorSystem colorSystem) { SupportsAnsi = supportsAnsi; ColorSystem = colorSystem; diff --git a/src/Spectre.Console/Color.cs b/src/Spectre.Console/Color.cs index a111970..c60d18b 100644 --- a/src/Spectre.Console/Color.cs +++ b/src/Spectre.Console/Color.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Globalization; using System.Linq; using Spectre.Console.Internal; @@ -207,5 +208,11 @@ namespace Spectre.Console _ => Default, }; } + + /// + public override string ToString() + { + return Name ?? string.Format(CultureInfo.InvariantCulture, "#{0:2X}{1:2X}{2:2X}", R, G, B); + } } } diff --git a/src/Spectre.Console/Composition/IRenderable.cs b/src/Spectre.Console/Composition/IRenderable.cs new file mode 100644 index 0000000..464f7e8 --- /dev/null +++ b/src/Spectre.Console/Composition/IRenderable.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Text; + +namespace Spectre.Console.Composition +{ + /// + /// Represents something that can be rendered to the console. + /// + public interface IRenderable + { + /// + /// Measures the renderable object. + /// + /// The encoding to use. + /// The maximum allowed width. + /// The width of the object. + int Measure(Encoding encoding, int maxWidth); + + /// + /// Renders the object. + /// + /// The encoding to use. + /// The width of the render area. + /// A collection of segments. + IEnumerable Render(Encoding encoding, int width); + } +} diff --git a/src/Spectre.Console/Composition/Segment.cs b/src/Spectre.Console/Composition/Segment.cs new file mode 100644 index 0000000..f9216b1 --- /dev/null +++ b/src/Spectre.Console/Composition/Segment.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Spectre.Console.Internal; + +namespace Spectre.Console.Composition +{ + /// + /// Represents a renderable segment. + /// + public sealed class Segment + { + /// + /// Gets the segment text. + /// + public string Text { get; } + + /// + /// Gets the appearance of the segment. + /// + public Appearance Appearance { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The segment text. + public Segment(string text) + : this(text, Appearance.Plain) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The segment text. + /// The segment appearance. + public Segment(string text, Appearance appearance) + { + Text = text?.NormalizeLineEndings() ?? throw new ArgumentNullException(nameof(text)); + Appearance = appearance; + } + + /// + /// Gets the number of cells that this segment + /// occupies in the console. + /// + /// The encoding to use. + /// The number of cells that this segment occupies in the console. + public int CellLength(Encoding encoding) + { + return Text.CellLength(encoding); + } + + /// + /// Returns a new segment without any trailing line endings. + /// + /// A new segment without any trailing line endings. + public Segment StripLineEndings() + { + return new Segment(Text.TrimEnd('\n'), Appearance); + } + + /// + /// Splits the provided segments into lines. + /// + /// The segments to split. + /// A collection of lines. + public static List Split(IEnumerable segments) + { + if (segments is null) + { + throw new ArgumentNullException(nameof(segments)); + } + + var lines = new List(); + var line = new SegmentLine(); + + foreach (var segment in segments) + { + if (segment.Text.Contains("\n")) + { + if (segment.Text == "\n") + { + lines.Add(line); + line = new SegmentLine(); + continue; + } + + var text = segment.Text; + while (text != null) + { + var parts = text.SplitLines(); + if (parts.Length > 0) + { + line.Add(new Segment(parts[0], segment.Appearance)); + } + + if (parts.Length > 1) + { + lines.Add(line); + line = new SegmentLine(); + + text = string.Concat(parts.Skip(1).Take(parts.Length - 1)); + if (string.IsNullOrWhiteSpace(text)) + { + text = null; + } + } + else + { + text = null; + } + } + } + else + { + line.Add(segment); + } + } + + if (line.Count > 0) + { + lines.Add(line); + } + + return lines; + } + } +} diff --git a/src/Spectre.Console/Composition/SegmentLine.cs b/src/Spectre.Console/Composition/SegmentLine.cs new file mode 100644 index 0000000..ebc0003 --- /dev/null +++ b/src/Spectre.Console/Composition/SegmentLine.cs @@ -0,0 +1,13 @@ +ο»Ώusing System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Spectre.Console.Composition +{ + /// + /// Represents a line of segments. + /// + [SuppressMessage("Naming", "CA1710:Identifiers should have correct suffix")] + public sealed class SegmentLine : List + { + } +} diff --git a/src/Spectre.Console/ConsoleExtensions.Rendering.cs b/src/Spectre.Console/ConsoleExtensions.Rendering.cs new file mode 100644 index 0000000..2589645 --- /dev/null +++ b/src/Spectre.Console/ConsoleExtensions.Rendering.cs @@ -0,0 +1,45 @@ +using System; +using Spectre.Console.Composition; +using Spectre.Console.Internal; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static partial class ConsoleExtensions + { + /// + /// Renders the specified object to the console. + /// + /// The console to render to. + /// The object to render. + public static void Render(this IAnsiConsole console, IRenderable renderable) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + if (renderable is null) + { + throw new ArgumentNullException(nameof(renderable)); + } + + foreach (var segment in renderable.Render(console.Encoding, console.Width)) + { + if (!segment.Appearance.Equals(Appearance.Plain)) + { + using (var appearance = console.PushAppearance(segment.Appearance)) + { + console.Write(segment.Text); + } + } + else + { + console.Write(segment.Text); + } + } + } + } +} diff --git a/src/Spectre.Console/IAnsiConsole.cs b/src/Spectre.Console/IAnsiConsole.cs index aa45f66..78fd832 100644 --- a/src/Spectre.Console/IAnsiConsole.cs +++ b/src/Spectre.Console/IAnsiConsole.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace Spectre.Console { /// @@ -8,7 +10,7 @@ namespace Spectre.Console /// /// Gets the console's capabilities. /// - AnsiConsoleCapabilities Capabilities { get; } + Capabilities Capabilities { get; } /// /// Gets the buffer width of the console. @@ -20,6 +22,11 @@ namespace Spectre.Console /// int Height { get; } + /// + /// Gets the console output encoding. + /// + Encoding Encoding { get; } + /// /// Gets or sets the current style. /// diff --git a/src/Spectre.Console/Internal/Ansi/AnsiDetector.cs b/src/Spectre.Console/Internal/Ansi/AnsiDetector.cs index 2c61bf3..6d48d3e 100644 --- a/src/Spectre.Console/Internal/Ansi/AnsiDetector.cs +++ b/src/Spectre.Console/Internal/Ansi/AnsiDetector.cs @@ -13,7 +13,7 @@ namespace Spectre.Console.Internal { internal static class AnsiDetector { - private static readonly Regex[] Regexes = new[] + private static readonly Regex[] _regexes = new[] { new Regex("^xterm"), // xterm, PuTTY, Mintty new Regex("^rxvt"), // RXVT @@ -32,7 +32,7 @@ namespace Spectre.Console.Internal new Regex("bvterm"), // Bitvise SSH Client }; - public static bool SupportsAnsi(bool upgrade) + public static bool Detect(bool upgrade) { // Github action doesn't setup a correct PTY but supports ANSI. if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("GITHUB_ACTION"))) @@ -57,7 +57,7 @@ namespace Spectre.Console.Internal var term = Environment.GetEnvironmentVariable("TERM"); if (!string.IsNullOrWhiteSpace(term)) { - if (Regexes.Any(regex => regex.IsMatch(term))) + if (_regexes.Any(regex => regex.IsMatch(term))) { return true; } @@ -71,8 +71,10 @@ namespace Spectre.Console.Internal { [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore")] private const int STD_OUTPUT_HANDLE = -11; + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore")] private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004; + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore")] private const uint DISABLE_NEWLINE_AUTO_RETURN = 0x0008; @@ -94,7 +96,7 @@ namespace Spectre.Console.Internal try { var @out = GetStdHandle(STD_OUTPUT_HANDLE); - if (!GetConsoleMode(@out, out uint mode)) + if (!GetConsoleMode(@out, out var mode)) { // Could not get console mode. return false; diff --git a/src/Spectre.Console/Internal/Rendering/AnsiConsoleRenderer.cs b/src/Spectre.Console/Internal/AnsiConsoleRenderer.cs similarity index 85% rename from src/Spectre.Console/Internal/Rendering/AnsiConsoleRenderer.cs rename to src/Spectre.Console/Internal/AnsiConsoleRenderer.cs index 3c5421d..8f689ed 100644 --- a/src/Spectre.Console/Internal/Rendering/AnsiConsoleRenderer.cs +++ b/src/Spectre.Console/Internal/AnsiConsoleRenderer.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Text; namespace Spectre.Console.Internal { @@ -8,7 +9,8 @@ namespace Spectre.Console.Internal private readonly TextWriter _out; private readonly ColorSystem _system; - public AnsiConsoleCapabilities Capabilities { get; } + public Capabilities Capabilities { get; } + public Encoding Encoding { get; } public Styles Style { get; set; } public Color Foreground { get; set; } public Color Background { get; set; } @@ -44,7 +46,8 @@ namespace Spectre.Console.Internal _out = @out ?? throw new ArgumentNullException(nameof(@out)); _system = system; - Capabilities = new AnsiConsoleCapabilities(true, system); + Capabilities = new Capabilities(true, system); + Encoding = @out.IsStandardOut() ? System.Console.OutputEncoding : Encoding.UTF8; Foreground = Color.Default; Background = Color.Default; Style = Styles.None; @@ -73,7 +76,7 @@ namespace Spectre.Console.Internal _out.Write(AnsiBuilder.GetAnsi( _system, - text, + text.NormalizeLineEndings(native: true), Style, Foreground, Background)); diff --git a/src/Spectre.Console/Internal/ConsoleBuilder.cs b/src/Spectre.Console/Internal/ConsoleBuilder.cs index 1e257ed..24b14ec 100644 --- a/src/Spectre.Console/Internal/ConsoleBuilder.cs +++ b/src/Spectre.Console/Internal/ConsoleBuilder.cs @@ -14,7 +14,7 @@ namespace Spectre.Console.Internal var buffer = settings.Out ?? System.Console.Out; var supportsAnsi = settings.Ansi == AnsiSupport.Detect - ? AnsiDetector.SupportsAnsi(true) + ? AnsiDetector.Detect(true) : settings.Ansi == AnsiSupport.Yes; var colorSystem = settings.ColorSystem == ColorSystemSupport.Detect diff --git a/src/Spectre.Console/Internal/Extensions/CharExtensions.cs b/src/Spectre.Console/Internal/Extensions/CharExtensions.cs new file mode 100644 index 0000000..ac393f3 --- /dev/null +++ b/src/Spectre.Console/Internal/Extensions/CharExtensions.cs @@ -0,0 +1,12 @@ +using System.Text; + +namespace Spectre.Console.Internal +{ + internal static class CharExtensions + { + public static int CellLength(this char token, Encoding encoding) + { + return Cell.GetCellLength(encoding, token); + } + } +} diff --git a/src/Spectre.Console/Internal/Extensions/ConsoleExtensions.cs b/src/Spectre.Console/Internal/Extensions/ConsoleExtensions.cs index 8fd0771..4ecfd87 100644 --- a/src/Spectre.Console/Internal/Extensions/ConsoleExtensions.cs +++ b/src/Spectre.Console/Internal/Extensions/ConsoleExtensions.cs @@ -1,10 +1,25 @@ using System; using System.Diagnostics.CodeAnalysis; +using Spectre.Console.Composition; namespace Spectre.Console.Internal { internal static class ConsoleExtensions { + public static IDisposable PushAppearance(this IAnsiConsole console, Appearance appearance) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + var current = new Appearance(console.Foreground, console.Background, console.Style); + console.SetColor(appearance.Foreground, true); + console.SetColor(appearance.Background, false); + console.Style = appearance.Style; + return new AppearanceScope(console, current); + } + public static IDisposable PushColor(this IAnsiConsole console, Color color, bool foreground) { if (console is null) @@ -47,6 +62,33 @@ namespace Spectre.Console.Internal } } + internal sealed class AppearanceScope : IDisposable + { + private readonly IAnsiConsole _console; + private readonly Appearance _apperance; + + public AppearanceScope(IAnsiConsole console, Appearance appearance) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _apperance = appearance; + } + + [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations")] + [SuppressMessage("Performance", "CA1821:Remove empty Finalizers")] + ~AppearanceScope() + { + throw new InvalidOperationException("Appearance scope was not disposed."); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + _console.SetColor(_apperance.Foreground, true); + _console.SetColor(_apperance.Background, false); + _console.Style = _apperance.Style; + } + } + internal sealed class ColorScope : IDisposable { private readonly IAnsiConsole _console; diff --git a/src/Spectre.Console/Internal/Extensions/StringExtensions.cs b/src/Spectre.Console/Internal/Extensions/StringExtensions.cs new file mode 100644 index 0000000..58ce136 --- /dev/null +++ b/src/Spectre.Console/Internal/Extensions/StringExtensions.cs @@ -0,0 +1,36 @@ +using System; +using System.Text; + +namespace Spectre.Console.Internal +{ + internal static class StringExtensions + { + // Cache whether or not internally normalized line endings + // already are normalized. No reason to do yet another replace if it is. + private static readonly bool _alreadyNormalized + = Environment.NewLine.Equals("\n", StringComparison.OrdinalIgnoreCase); + + public static int CellLength(this string text, Encoding encoding) + { + return Cell.GetCellLength(encoding, text); + } + + public static string NormalizeLineEndings(this string text, bool native = false) + { + var normalized = text?.Replace("\r\n", "\n") + ?.Replace("\r", string.Empty); + + if (native && !_alreadyNormalized) + { + normalized = normalized.Replace("\n", Environment.NewLine); + } + + return normalized; + } + + public static string[] SplitLines(this string text) + { + return text.NormalizeLineEndings().Split(new[] { '\n' }, StringSplitOptions.None); + } + } +} diff --git a/src/Spectre.Console/Internal/Rendering/FallbackConsoleRenderer.cs b/src/Spectre.Console/Internal/FallbackConsoleRenderer.cs similarity index 89% rename from src/Spectre.Console/Internal/Rendering/FallbackConsoleRenderer.cs rename to src/Spectre.Console/Internal/FallbackConsoleRenderer.cs index 0d845cb..c47b4d3 100644 --- a/src/Spectre.Console/Internal/Rendering/FallbackConsoleRenderer.cs +++ b/src/Spectre.Console/Internal/FallbackConsoleRenderer.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Text; namespace Spectre.Console.Internal { @@ -13,7 +14,9 @@ namespace Spectre.Console.Internal private ConsoleColor _foreground; private ConsoleColor _background; - public AnsiConsoleCapabilities Capabilities { get; } + public Capabilities Capabilities { get; } + + public Encoding Encoding { get; } public int Width { @@ -87,23 +90,27 @@ namespace Spectre.Console.Internal _out = @out; _system = system; - Capabilities = new AnsiConsoleCapabilities(false, _system); - if (_out.IsStandardOut()) { _defaultForeground = System.Console.ForegroundColor; _defaultBackground = System.Console.BackgroundColor; + + Encoding = System.Console.OutputEncoding; } else { _defaultForeground = ConsoleColor.Gray; _defaultBackground = ConsoleColor.Black; + + Encoding = Encoding.UTF8; } + + Capabilities = new Capabilities(false, _system); } public void Write(string text) { - _out.Write(text); + _out.Write(text.NormalizeLineEndings(native: true)); } } } diff --git a/src/Spectre.Console/Internal/Text/Cell.cs b/src/Spectre.Console/Internal/Text/Cell.cs new file mode 100644 index 0000000..aeec84d --- /dev/null +++ b/src/Spectre.Console/Internal/Text/Cell.cs @@ -0,0 +1,150 @@ +// Taken and modified from NStack project by Miguel de Icaza, licensed under BSD-3 +// https://github.com/migueldeicaza/NStack/blob/3fc024fb2c2e99927d3e12991570fb54db8ce01e/NStack/unicode/Rune.ColumnWidth.cs + +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; + +namespace Spectre.Console.Internal +{ + internal static class Cell + { + [SuppressMessage("Design", "RCS1169:Make field read-only.")] + [SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1500:Braces for multi-line statements should not share line")] + private static readonly uint[,] _combining = new uint[,] + { + { 0x0300, 0x036F }, { 0x0483, 0x0486 }, { 0x0488, 0x0489 }, + { 0x0591, 0x05BD }, { 0x05BF, 0x05BF }, { 0x05C1, 0x05C2 }, + { 0x05C4, 0x05C5 }, { 0x05C7, 0x05C7 }, { 0x0600, 0x0603 }, + { 0x0610, 0x0615 }, { 0x064B, 0x065E }, { 0x0670, 0x0670 }, + { 0x06D6, 0x06E4 }, { 0x06E7, 0x06E8 }, { 0x06EA, 0x06ED }, + { 0x070F, 0x070F }, { 0x0711, 0x0711 }, { 0x0730, 0x074A }, + { 0x07A6, 0x07B0 }, { 0x07EB, 0x07F3 }, { 0x0901, 0x0902 }, + { 0x093C, 0x093C }, { 0x0941, 0x0948 }, { 0x094D, 0x094D }, + { 0x0951, 0x0954 }, { 0x0962, 0x0963 }, { 0x0981, 0x0981 }, + { 0x09BC, 0x09BC }, { 0x09C1, 0x09C4 }, { 0x09CD, 0x09CD }, + { 0x09E2, 0x09E3 }, { 0x0A01, 0x0A02 }, { 0x0A3C, 0x0A3C }, + { 0x0A41, 0x0A42 }, { 0x0A47, 0x0A48 }, { 0x0A4B, 0x0A4D }, + { 0x0A70, 0x0A71 }, { 0x0A81, 0x0A82 }, { 0x0ABC, 0x0ABC }, + { 0x0AC1, 0x0AC5 }, { 0x0AC7, 0x0AC8 }, { 0x0ACD, 0x0ACD }, + { 0x0AE2, 0x0AE3 }, { 0x0B01, 0x0B01 }, { 0x0B3C, 0x0B3C }, + { 0x0B3F, 0x0B3F }, { 0x0B41, 0x0B43 }, { 0x0B4D, 0x0B4D }, + { 0x0B56, 0x0B56 }, { 0x0B82, 0x0B82 }, { 0x0BC0, 0x0BC0 }, + { 0x0BCD, 0x0BCD }, { 0x0C3E, 0x0C40 }, { 0x0C46, 0x0C48 }, + { 0x0C4A, 0x0C4D }, { 0x0C55, 0x0C56 }, { 0x0CBC, 0x0CBC }, + { 0x0CBF, 0x0CBF }, { 0x0CC6, 0x0CC6 }, { 0x0CCC, 0x0CCD }, + { 0x0CE2, 0x0CE3 }, { 0x0D41, 0x0D43 }, { 0x0D4D, 0x0D4D }, + { 0x0DCA, 0x0DCA }, { 0x0DD2, 0x0DD4 }, { 0x0DD6, 0x0DD6 }, + { 0x0E31, 0x0E31 }, { 0x0E34, 0x0E3A }, { 0x0E47, 0x0E4E }, + { 0x0EB1, 0x0EB1 }, { 0x0EB4, 0x0EB9 }, { 0x0EBB, 0x0EBC }, + { 0x0EC8, 0x0ECD }, { 0x0F18, 0x0F19 }, { 0x0F35, 0x0F35 }, + { 0x0F37, 0x0F37 }, { 0x0F39, 0x0F39 }, { 0x0F71, 0x0F7E }, + { 0x0F80, 0x0F84 }, { 0x0F86, 0x0F87 }, { 0x0F90, 0x0F97 }, + { 0x0F99, 0x0FBC }, { 0x0FC6, 0x0FC6 }, { 0x102D, 0x1030 }, + { 0x1032, 0x1032 }, { 0x1036, 0x1037 }, { 0x1039, 0x1039 }, + { 0x1058, 0x1059 }, { 0x1160, 0x11FF }, { 0x135F, 0x135F }, + { 0x1712, 0x1714 }, { 0x1732, 0x1734 }, { 0x1752, 0x1753 }, + { 0x1772, 0x1773 }, { 0x17B4, 0x17B5 }, { 0x17B7, 0x17BD }, + { 0x17C6, 0x17C6 }, { 0x17C9, 0x17D3 }, { 0x17DD, 0x17DD }, + { 0x180B, 0x180D }, { 0x18A9, 0x18A9 }, { 0x1920, 0x1922 }, + { 0x1927, 0x1928 }, { 0x1932, 0x1932 }, { 0x1939, 0x193B }, + { 0x1A17, 0x1A18 }, { 0x1B00, 0x1B03 }, { 0x1B34, 0x1B34 }, + { 0x1B36, 0x1B3A }, { 0x1B3C, 0x1B3C }, { 0x1B42, 0x1B42 }, + { 0x1B6B, 0x1B73 }, { 0x1DC0, 0x1DCA }, { 0x1DFE, 0x1DFF }, + { 0x200B, 0x200F }, { 0x202A, 0x202E }, { 0x2060, 0x2063 }, + { 0x206A, 0x206F }, { 0x20D0, 0x20EF }, { 0x302A, 0x302F }, + { 0x3099, 0x309A }, { 0xA806, 0xA806 }, { 0xA80B, 0xA80B }, + { 0xA825, 0xA826 }, { 0xFB1E, 0xFB1E }, { 0xFE00, 0xFE0F }, + { 0xFE20, 0xFE23 }, { 0xFEFF, 0xFEFF }, { 0xFFF9, 0xFFFB }, + { 0x10A01, 0x10A03 }, { 0x10A05, 0x10A06 }, { 0x10A0C, 0x10A0F }, + { 0x10A38, 0x10A3A }, { 0x10A3F, 0x10A3F }, { 0x1D167, 0x1D169 }, + { 0x1D173, 0x1D182 }, { 0x1D185, 0x1D18B }, { 0x1D1AA, 0x1D1AD }, + { 0x1D242, 0x1D244 }, { 0xE0001, 0xE0001 }, { 0xE0020, 0xE007F }, + { 0xE0100, 0xE01EF }, + }; + + public static int GetCellLength(Encoding encoding, string text) + { + return text.Sum(c => GetCellLength(encoding, c)); + } + + public static int GetCellLength(Encoding encoding, char rune) + { + // Is it represented by a single byte? + // In that case we don't have to calculate the + // actual cell width. + if (encoding.GetByteCount(new[] { rune }) == 1) + { + return 1; + } + + var irune = (uint)rune; + if (irune < 32) + { + return 0; + } + + if (irune < 127) + { + return 1; + } + + if (irune >= 0x7f && irune <= 0xa0) + { + return 0; + } + + // Binary search in table of non-spacing characters + if (BinarySearch(irune, _combining, _combining.GetLength(0) - 1) != 0) + { + return 0; + } + + // If we arrive here, ucs is not a combining or C0/C1 control character + return 1 + + ((irune >= 0x1100 && + (irune <= 0x115f || /* Hangul Jamo init. consonants */ + irune == 0x2329 || irune == 0x232a || + (irune >= 0x2e80 && irune <= 0xa4cf && + irune != 0x303f) || /* CJK ... Yi */ + (irune >= 0xac00 && irune <= 0xd7a3) || /* Hangul Syllables */ + (irune >= 0xf900 && irune <= 0xfaff) || /* CJK Compatibility Ideographs */ + (irune >= 0xfe10 && irune <= 0xfe19) || /* Vertical forms */ + (irune >= 0xfe30 && irune <= 0xfe6f) || /* CJK Compatibility Forms */ + (irune >= 0xff00 && irune <= 0xff60) || /* Fullwidth Forms */ + (irune >= 0xffe0 && irune <= 0xffe6) || + (irune >= 0x20000 && irune <= 0x2fffd) || + (irune >= 0x30000 && irune <= 0x3fffd))) ? 1 : 0); + } + + private static int BinarySearch(uint rune, uint[,] table, int max) + { + var min = 0; + int mid; + + if (rune < table[0, 0] || rune > table[max, 1]) + { + return 0; + } + + while (max >= min) + { + mid = (min + max) / 2; + if (rune > table[mid, 1]) + { + min = mid + 1; + } + else if (rune < table[mid, 0]) + { + max = mid - 1; + } + else + { + return 1; + } + } + + return 0; + } + } +} diff --git a/src/Spectre.Console/Internal/Markup/Ast/MarkupBlockNode.cs b/src/Spectre.Console/Internal/Text/Markup/Ast/MarkupBlockNode.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/Ast/MarkupBlockNode.cs rename to src/Spectre.Console/Internal/Text/Markup/Ast/MarkupBlockNode.cs diff --git a/src/Spectre.Console/Internal/Markup/Ast/MarkupStyleNode.cs b/src/Spectre.Console/Internal/Text/Markup/Ast/MarkupStyleNode.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/Ast/MarkupStyleNode.cs rename to src/Spectre.Console/Internal/Text/Markup/Ast/MarkupStyleNode.cs diff --git a/src/Spectre.Console/Internal/Markup/Ast/MarkupTextNode.cs b/src/Spectre.Console/Internal/Text/Markup/Ast/MarkupTextNode.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/Ast/MarkupTextNode.cs rename to src/Spectre.Console/Internal/Text/Markup/Ast/MarkupTextNode.cs diff --git a/src/Spectre.Console/Internal/Markup/IMarkupNode.cs b/src/Spectre.Console/Internal/Text/Markup/IMarkupNode.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/IMarkupNode.cs rename to src/Spectre.Console/Internal/Text/Markup/IMarkupNode.cs diff --git a/src/Spectre.Console/Internal/Markup/MarkupParser.cs b/src/Spectre.Console/Internal/Text/Markup/MarkupParser.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/MarkupParser.cs rename to src/Spectre.Console/Internal/Text/Markup/MarkupParser.cs diff --git a/src/Spectre.Console/Internal/Markup/MarkupStyleParser.cs b/src/Spectre.Console/Internal/Text/Markup/MarkupStyleParser.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/MarkupStyleParser.cs rename to src/Spectre.Console/Internal/Text/Markup/MarkupStyleParser.cs diff --git a/src/Spectre.Console/Internal/Markup/MarkupToken.cs b/src/Spectre.Console/Internal/Text/Markup/MarkupToken.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/MarkupToken.cs rename to src/Spectre.Console/Internal/Text/Markup/MarkupToken.cs diff --git a/src/Spectre.Console/Internal/Markup/MarkupTokenKind.cs b/src/Spectre.Console/Internal/Text/Markup/MarkupTokenKind.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/MarkupTokenKind.cs rename to src/Spectre.Console/Internal/Text/Markup/MarkupTokenKind.cs diff --git a/src/Spectre.Console/Internal/Markup/MarkupTokenizer.cs b/src/Spectre.Console/Internal/Text/Markup/MarkupTokenizer.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/MarkupTokenizer.cs rename to src/Spectre.Console/Internal/Text/Markup/MarkupTokenizer.cs diff --git a/src/Spectre.Console/Internal/Markup/StringBuffer.cs b/src/Spectre.Console/Internal/Text/StringBuffer.cs similarity index 100% rename from src/Spectre.Console/Internal/Markup/StringBuffer.cs rename to src/Spectre.Console/Internal/Text/StringBuffer.cs diff --git a/src/Spectre.Console/Justify.cs b/src/Spectre.Console/Justify.cs new file mode 100644 index 0000000..a3f25e7 --- /dev/null +++ b/src/Spectre.Console/Justify.cs @@ -0,0 +1,23 @@ +namespace Spectre.Console +{ + /// + /// Represents text justification. + /// + public enum Justify + { + /// + /// Left aligned. + /// + Left = 0, + + /// + /// Right aligned. + /// + Right = 1, + + /// + /// Centered + /// + Center = 2, + } +} diff --git a/src/Spectre.Console/Renderables/Panel.cs b/src/Spectre.Console/Renderables/Panel.cs new file mode 100644 index 0000000..b1b8229 --- /dev/null +++ b/src/Spectre.Console/Renderables/Panel.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Spectre.Console.Composition; + +namespace Spectre.Console +{ + /// + /// Represents a panel which contains another renderable item. + /// + public sealed class Panel : IRenderable + { + private readonly IRenderable _child; + private readonly bool _fit; + + /// + /// Initializes a new instance of the class. + /// + /// The child. + /// Whether or not to fit the panel to it's parent. + public Panel(IRenderable child, bool fit = false) + { + _child = child; + _fit = fit; + } + + /// + public int Measure(Encoding encoding, int maxWidth) + { + var childWidth = _child.Measure(encoding, maxWidth); + return childWidth + 4; + } + + /// + public IEnumerable Render(Encoding encoding, int width) + { + var childWidth = width - 4; + if (!_fit) + { + childWidth = _child.Measure(encoding, width - 2); + } + + var result = new List(); + var panelWidth = childWidth + 2; + + result.Add(new Segment("β”Œ")); + result.Add(new Segment(new string('─', panelWidth))); + result.Add(new Segment("┐")); + result.Add(new Segment("\n")); + + var childSegments = _child.Render(encoding, childWidth); + foreach (var line in Segment.Split(childSegments)) + { + result.Add(new Segment("β”‚ ")); + + foreach (var segment in line) + { + result.Add(segment.StripLineEndings()); + } + + var length = line.Sum(segment => segment.CellLength(encoding)); + if (length < childWidth) + { + var diff = childWidth - length; + result.Add(new Segment(new string(' ', diff))); + } + + result.Add(new Segment(" β”‚")); + result.Add(new Segment("\n")); + } + + result.Add(new Segment("β””")); + result.Add(new Segment(new string('─', panelWidth))); + result.Add(new Segment("β”˜")); + result.Add(new Segment("\n")); + + return result; + } + } +} diff --git a/src/Spectre.Console/Renderables/Text.cs b/src/Spectre.Console/Renderables/Text.cs new file mode 100644 index 0000000..8d9c6ca --- /dev/null +++ b/src/Spectre.Console/Renderables/Text.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using Spectre.Console.Composition; +using Spectre.Console.Internal; + +namespace Spectre.Console +{ + /// + /// Represents text with color and style. + /// + [SuppressMessage("Naming", "CA1724:Type names should not match namespaces")] + public sealed class Text : IRenderable + { + private readonly string _text; + private readonly Appearance _appearance; + private readonly Justify _justify; + + /// + /// Initializes a new instance of the class. + /// + /// The text. + /// The appearance. + /// The justification. + public Text(string text, Appearance appearance = null, Justify justify = Justify.Left) + { + _text = text ?? throw new ArgumentNullException(nameof(text)); + _appearance = appearance ?? Appearance.Plain; + _justify = justify; + } + + /// + /// Initializes a new instance of the class. + /// + /// The text. + /// The foreground. + /// The background. + /// The style. + /// The justification. + /// A instance. + public static Text New( + string text, Color? foreground = null, Color? background = null, + Styles? style = null, Justify justify = Justify.Left) + { + return new Text(text, new Appearance(foreground, background, style), justify); + } + + /// + public int Measure(Encoding encoding, int maxWidth) + { + return _text.SplitLines().Max(x => x.CellLength(encoding)); + } + + /// + public IEnumerable Render(Encoding encoding, int width) + { + var result = new List(); + + foreach (var line in Partition(encoding, _text, width)) + { + result.Add(new Segment(line, _appearance)); + } + + return result; + } + + private IEnumerable Partition(Encoding encoding, string text, int width) + { + var lines = new List(); + var line = new StringBuilder(); + + var position = 0; + foreach (var token in text) + { + if (token == '\n') + { + lines.Add(line.ToString()); + line.Clear(); + position = 0; + continue; + } + + if (position >= width) + { + lines.Add(line.ToString()); + line.Clear(); + position = 0; + } + + line.Append(token); + position += token.CellLength(encoding); + } + + if (line.Length > 0) + { + lines.Add(line.ToString()); + } + + // Justify lines + for (var i = 0; i < lines.Count; i++) + { + if (_justify != Justify.Left && lines[i].CellLength(encoding) < width) + { + if (_justify == Justify.Right) + { + var diff = width - lines[i].CellLength(encoding); + lines[i] = new string(' ', diff) + lines[i]; + } + else if (_justify == Justify.Center) + { + var diff = (width - lines[i].CellLength(encoding)) / 2; + lines[i] = new string(' ', diff) + lines[i] + new string(' ', diff); + } + } + + if (i < lines.Count - 1) + { + lines[i] += "\n"; + } + } + + return lines; + } + } +}