Improve text composite

- A `Text` object should not be able to justify itself.
  All justification needs to be done by a parent.
- Apply colors and styles to part of a `Text` object
- Markup parser should return a `Text` object
This commit is contained in:
Patrik Svensson 2020-07-30 23:26:22 +02:00 committed by Patrik Svensson
parent 8e4f33bba4
commit f19202b427
33 changed files with 728 additions and 434 deletions

View File

@ -75,3 +75,6 @@ dotnet_diagnostic.CA1032.severity = none
# CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly
dotnet_diagnostic.CA1826.severity = none
# RCS1079: Throwing of new NotImplementedException.
dotnet_diagnostic.RCS1079.severity = warning

View File

@ -53,9 +53,10 @@ namespace Sample
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))))));
"[underline]I[/] heard [underline on blue]you[/] like 📦\n\n\n\n" +
"So I put a 📦 in a 📦\nin a 📦 in a 📦\n\n" +
"😅",
foreground: Color.White), content: Justify.Center)))));
// Reset colors
AnsiConsole.ResetColors();
@ -69,16 +70,14 @@ namespace Sample
// Centered panel with text
AnsiConsole.Render(new Panel(
Text.New("Centered\nCenter",
foreground: Color.White,
justify: Justify.Center),
fit: true));
foreground: Color.White),
fit: true, content: Justify.Center));
// Right adjusted panel with text
AnsiConsole.Render(new Panel(
Text.New("Right adjusted\nRight",
foreground: Color.White,
justify: Justify.Right),
fit: true));
foreground: Color.White),
fit: true, content: Justify.Right));
}
}
}

View File

@ -21,3 +21,6 @@ dotnet_diagnostic.CA1034.severity = none
# CA2000: Dispose objects before losing scope
dotnet_diagnostic.CA2000.severity = none
# SA1118: Parameter should not span multiple lines
dotnet_diagnostic.SA1118.severity = none

View File

@ -0,0 +1,13 @@
using System;
namespace Spectre.Console.Tests
{
public static class StringExtensions
{
public static string NormalizeLineEndings(this string text)
{
return text?.Replace("\r\n", "\n", StringComparison.OrdinalIgnoreCase)
?.Replace("\r", string.Empty, StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@ -11,16 +11,17 @@ namespace Spectre.Console.Tests
public string Output => _writer.ToString();
public AnsiConsoleFixture(ColorSystem system, AnsiSupport ansi = AnsiSupport.Yes)
public AnsiConsoleFixture(ColorSystem system, AnsiSupport ansi = AnsiSupport.Yes, int width = 80)
{
_writer = new StringWriter();
Console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = ansi,
ColorSystem = (ColorSystemSupport)system,
Out = _writer,
});
Console = new ConsoleWithWidth(
AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = ansi,
ColorSystem = (ColorSystemSupport)system,
Out = _writer,
}), width);
}
public void Dispose()

View File

@ -0,0 +1,31 @@
using System.Text;
namespace Spectre.Console.Tests
{
public sealed class ConsoleWithWidth : IAnsiConsole
{
private readonly IAnsiConsole _console;
public Capabilities Capabilities => _console.Capabilities;
public int Width { get; }
public int Height => _console.Height;
public Encoding Encoding => _console.Encoding;
public Styles Style { get => _console.Style; set => _console.Style = value; }
public Color Foreground { get => _console.Foreground; set => _console.Foreground = value; }
public Color Background { get => _console.Background; set => _console.Background = value; }
public ConsoleWithWidth(IAnsiConsole console, int width)
{
_console = console;
Width = width;
}
public void Write(string text)
{
_console.Write(text);
}
}
}

View File

@ -1,7 +1,7 @@
using Shouldly;
using Xunit;
namespace Spectre.Console.Tests
namespace Spectre.Console.Tests.Unit
{
public partial class AnsiConsoleTests
{

View File

@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis;
using Shouldly;
using Xunit;
namespace Spectre.Console.Tests
namespace Spectre.Console.Tests.Unit
{
public partial class AnsiConsoleTests
{

View File

@ -1,7 +1,7 @@
using Shouldly;
using Xunit;
namespace Spectre.Console.Tests
namespace Spectre.Console.Tests.Unit
{
public partial class AnsiConsoleTests
{

View File

@ -3,7 +3,7 @@ using System.Globalization;
using Shouldly;
using Xunit;
namespace Spectre.Console.Tests
namespace Spectre.Console.Tests.Unit
{
public partial class AnsiConsoleTests
{

View File

@ -0,0 +1,24 @@
using Shouldly;
using Xunit;
namespace Spectre.Console.Tests.Unit
{
public sealed class AppearanceTests
{
[Fact]
public void Should_Combine_Two_Appearances_As_Expected()
{
// Given
var first = new Appearance(Color.White, Color.Yellow, Styles.Bold | Styles.Italic);
var other = new Appearance(Color.Green, Color.Silver, Styles.Underline);
// When
var result = first.Combine(other);
// Then
result.Foreground.ShouldBe(Color.Green);
result.Background.ShouldBe(Color.Silver);
result.Style.ShouldBe(Styles.Bold | Styles.Italic | Styles.Underline);
}
}
}

View File

@ -1,8 +1,7 @@
using Shouldly;
using Spectre.Console.Composition;
using Xunit;
namespace Spectre.Console.Tests.Unit.Composition
namespace Spectre.Console.Tests.Unit
{
public sealed class PanelTests
{
@ -13,7 +12,7 @@ namespace Spectre.Console.Tests.Unit.Composition
var console = new PlainConsole(width: 80);
// When
console.Render(new Panel(new Text("Hello World")));
console.Render(new Panel(Text.New("Hello World")));
// Then
console.Lines.Count.ShouldBe(3);
@ -29,7 +28,7 @@ namespace Spectre.Console.Tests.Unit.Composition
var console = new PlainConsole(width: 80);
// When
console.Render(new Panel(new Text(" \n💩\n ")));
console.Render(new Panel(Text.New(" \n💩\n ")));
// Then
console.Lines.Count.ShouldBe(5);
@ -47,7 +46,7 @@ namespace Spectre.Console.Tests.Unit.Composition
var console = new PlainConsole(width: 80);
// When
console.Render(new Panel(new Text("Hello World\nFoo Bar")));
console.Render(new Panel(Text.New("Hello World\nFoo Bar")));
// Then
console.Lines.Count.ShouldBe(4);
@ -57,6 +56,29 @@ namespace Spectre.Console.Tests.Unit.Composition
console.Lines[3].ShouldBe("└─────────────┘");
}
[Fact]
public void Should_Preserve_Explicit_Line_Ending()
{
// Given
var console = new PlainConsole(width: 80);
var text = new Panel(
Text.New("I heard [underline on blue]you[/] like 📦\n\n\n\nSo I put a 📦 in a 📦"),
content: Justify.Center);
// When
console.Render(text);
// Then
console.Lines.Count.ShouldBe(7);
console.Lines[0].ShouldBe("┌───────────────────────┐");
console.Lines[1].ShouldBe("│ I heard you like 📦 │");
console.Lines[2].ShouldBe("│ │");
console.Lines[3].ShouldBe("│ │");
console.Lines[4].ShouldBe("│ │");
console.Lines[5].ShouldBe("│ So I put a 📦 in a 📦 │");
console.Lines[6].ShouldBe("└───────────────────────┘");
}
[Fact]
public void Should_Fit_Panel_To_Parent_If_Enabled()
{
@ -64,7 +86,7 @@ namespace Spectre.Console.Tests.Unit.Composition
var console = new PlainConsole(width: 25);
// When
console.Render(new Panel(new Text("Hello World"), fit: true));
console.Render(new Panel(Text.New("Hello World"), fit: true));
// Then
console.Lines.Count.ShouldBe(3);
@ -80,7 +102,7 @@ namespace Spectre.Console.Tests.Unit.Composition
var console = new PlainConsole(width: 25);
// When
console.Render(new Panel(new Text("Hello World", justify: Justify.Right), fit: true));
console.Render(new Panel(Text.New("Hello World"), fit: true, content: Justify.Right));
// Then
console.Lines.Count.ShouldBe(3);
@ -96,7 +118,7 @@ namespace Spectre.Console.Tests.Unit.Composition
var console = new PlainConsole(width: 25);
// When
console.Render(new Panel(new Text("Hello World", justify: Justify.Center), fit: true));
console.Render(new Panel(Text.New("Hello World"), fit: true, content: Justify.Center));
// Then
console.Lines.Count.ShouldBe(3);
@ -112,7 +134,7 @@ namespace Spectre.Console.Tests.Unit.Composition
var console = new PlainConsole(width: 80);
// When
console.Render(new Panel(new Panel(new Text("Hello World"))));
console.Render(new Panel(new Panel(Text.New("Hello World"))));
// Then
console.Lines.Count.ShouldBe(5);

View File

@ -2,66 +2,91 @@ using Shouldly;
using Spectre.Console.Composition;
using Xunit;
namespace Spectre.Console.Tests.Unit.Composition
namespace Spectre.Console.Tests.Unit
{
public sealed class SegmentTests
{
[Fact]
public void Should_Split_Segment()
public sealed class TheSplitMethod
{
var lines = Segment.Split(new[]
[Fact]
public void Should_Split_Segment_Correctly()
{
new Segment("Foo"),
new Segment("Bar"),
new Segment("\n"),
new Segment("Baz"),
new Segment("Qux"),
new Segment("\n"),
new Segment("Corgi"),
});
// Given
var appearance = new Appearance(Color.Red, Color.Green, Styles.Bold);
var segment = new Segment("Foo Bar", appearance);
// Then
lines.Count.ShouldBe(3);
// When
var (first, second) = segment.Split(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");
// Then
first.Text.ShouldBe("Foo");
first.Appearance.ShouldBe(appearance);
second.Text.ShouldBe(" Bar");
second.Appearance.ShouldBe(appearance);
}
}
[Fact]
public void Should_Split_Segments_With_Linebreak_In_Text()
public sealed class TheSplitLinesMethod
{
var lines = Segment.Split(new[]
[Fact]
public void Should_Split_Segment()
{
new Segment("Foo\n"),
new Segment("Bar\n"),
new Segment("Baz"),
new Segment("Qux\n"),
new Segment("Corgi"),
});
var lines = Segment.SplitLines(
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(4);
// Then
lines.Count.ShouldBe(3);
lines[0].Count.ShouldBe(1);
lines[0][0].Text.ShouldBe("Foo");
lines[0].Count.ShouldBe(2);
lines[0][0].Text.ShouldBe("Foo");
lines[0][1].Text.ShouldBe("Bar");
lines[1].Count.ShouldBe(1);
lines[1][0].Text.ShouldBe("Bar");
lines[1].Count.ShouldBe(2);
lines[1][0].Text.ShouldBe("Baz");
lines[1][1].Text.ShouldBe("Qux");
lines[2].Count.ShouldBe(2);
lines[2][0].Text.ShouldBe("Baz");
lines[2][1].Text.ShouldBe("Qux");
lines[2].Count.ShouldBe(1);
lines[2][0].Text.ShouldBe("Corgi");
}
lines[3].Count.ShouldBe(1);
lines[3][0].Text.ShouldBe("Corgi");
[Fact]
public void Should_Split_Segments_With_Linebreak_In_Text()
{
var lines = Segment.SplitLines(
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");
}
}
}
}

View File

@ -1,61 +1,77 @@
using Shouldly;
using Spectre.Console.Composition;
using Xunit;
namespace Spectre.Console.Tests.Composition
namespace Spectre.Console.Tests.Unit
{
public sealed class TextTests
{
[Fact]
public void Should_Render_Text_To_Console()
public void Should_Render_Unstyled_Text_As_Expected()
{
// Given
var console = new PlainConsole();
var fixture = new PlainConsole(width: 80);
var text = Text.New("Hello World");
// When
console.Render(new Text("Hello World"));
fixture.Render(text);
// Then
console.Output.ShouldBe("Hello World");
fixture.Output
.NormalizeLineEndings()
.ShouldBe("Hello World");
}
[Fact]
public void Should_Right_Align_Text_To_Parent()
public void Should_Split_Unstyled_Text_To_New_Lines_If_Width_Exceeds_Console_Width()
{
// Given
var console = new PlainConsole(width: 15);
var fixture = new PlainConsole(width: 5);
var text = Text.New("Hello World");
// When
console.Render(new Text("Hello World", justify: Justify.Right));
fixture.Render(text);
// Then
console.Output.ShouldBe(" Hello World");
fixture.Output
.NormalizeLineEndings()
.ShouldBe("Hello\n Worl\nd");
}
[Fact]
public void Should_Center_Text_To_Parent()
public sealed class TheStylizeMethod
{
// Given
var console = new PlainConsole(width: 15);
[Fact]
public void Should_Apply_Style_To_Text()
{
// Given
var fixture = new AnsiConsoleFixture(ColorSystem.Standard);
var text = Text.New("Hello World");
text.Stylize(start: 3, end: 8, new Appearance(style: Styles.Underline));
// When
console.Render(new Text("Hello World", justify: Justify.Center));
// When
fixture.Console.Render(text);
// Then
console.Output.ShouldBe(" Hello World ");
}
// Then
fixture.Output
.NormalizeLineEndings()
.ShouldBe("Hello World");
}
[Fact]
public void Should_Split_Text_To_Multiple_Lines_If_It_Does_Not_Fit()
{
// Given
var console = new PlainConsole(width: 5);
[Fact]
public void Should_Apply_Style_To_Text_Which_Spans_Over_Multiple_Lines()
{
// Given
var fixture = new AnsiConsoleFixture(ColorSystem.Standard, width: 5);
var text = Text.New("Hello World");
text.Stylize(start: 3, end: 8, new Appearance(style: Styles.Underline));
// When
console.Render(new Text("Hello World"));
// When
fixture.Console.Render(text);
// Then
console.Output.ShouldBe("Hello\n Worl\nd");
// Then
fixture.Output
.NormalizeLineEndings()
.ShouldBe("Hello\n Worl\nd");
}
}
}
}

View File

@ -26,12 +26,7 @@ namespace Spectre.Console
/// Gets an <see cref="Appearance"/> with the
/// default color and without style.
/// </summary>
public static Appearance Plain { get; }
static Appearance()
{
Plain = new Appearance();
}
public static Appearance Plain { get; } = new Appearance();
private Appearance()
: this(null, null, null)
@ -51,6 +46,33 @@ namespace Spectre.Console
Style = style ?? Styles.None;
}
/// <summary>
/// Combines this appearance with another one.
/// </summary>
/// <param name="other">The item to combine with this.</param>
/// <returns>A new appearance representing a combination of this and the other one.</returns>
public Appearance Combine(Appearance other)
{
if (other is null)
{
throw new ArgumentNullException(nameof(other));
}
var foreground = Foreground;
if (!other.Foreground.IsDefault)
{
foreground = other.Foreground;
}
var background = Background;
if (!other.Background.IsDefault)
{
background = other.Background;
}
return new Appearance(foreground, background, Style | other.Style);
}
/// <inheritdoc/>
public override int GetHashCode()
{

View File

@ -12,16 +12,19 @@ namespace Spectre.Console
{
private readonly IRenderable _child;
private readonly bool _fit;
private readonly Justify _content;
/// <summary>
/// Initializes a new instance of the <see cref="Panel"/> class.
/// </summary>
/// <param name="child">The child.</param>
/// <param name="fit">Whether or not to fit the panel to it's parent.</param>
public Panel(IRenderable child, bool fit = false)
/// <param name="content">The justification of the panel content.</param>
public Panel(IRenderable child, bool fit = false, Justify content = Justify.Left)
{
_child = child;
_fit = fit;
_content = content;
}
/// <inheritdoc/>
@ -48,23 +51,59 @@ namespace Spectre.Console
result.Add(new Segment("┐"));
result.Add(new Segment("\n"));
// Render the child.
var childSegments = _child.Render(encoding, childWidth);
foreach (var line in Segment.Split(childSegments))
// Split the child segments into lines.
var lines = Segment.SplitLines(childSegments, childWidth);
foreach (var line in lines)
{
result.Add(new Segment("│ "));
foreach (var segment in line)
{
result.Add(segment.StripLineEndings());
}
var content = new List<Segment>();
var length = line.Sum(segment => segment.CellLength(encoding));
if (length < childWidth)
{
var diff = childWidth - length;
result.Add(new Segment(new string(' ', diff)));
if (_content == Justify.Right)
{
var diff = childWidth - length;
content.Add(new Segment(new string(' ', diff)));
}
else if (_content == Justify.Center)
{
var diff = (childWidth - length) / 2;
content.Add(new Segment(new string(' ', diff)));
}
}
foreach (var segment in line)
{
content.Add(segment.StripLineEndings());
}
if (length < childWidth)
{
if (_content == Justify.Left)
{
var diff = childWidth - length;
content.Add(new Segment(new string(' ', diff)));
}
else if (_content == Justify.Center)
{
var diff = (childWidth - length) / 2;
content.Add(new Segment(new string(' ', diff)));
var remainder = (childWidth - length) % 2;
if (remainder != 0)
{
content.Add(new Segment(new string(' ', remainder)));
}
}
}
result.AddRange(content);
result.Add(new Segment(" │"));
result.Add(new Segment("\n"));
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using Spectre.Console.Internal;
@ -9,13 +10,20 @@ namespace Spectre.Console.Composition
/// <summary>
/// Represents a renderable segment.
/// </summary>
public sealed class Segment
[DebuggerDisplay("{Text,nq}")]
public class Segment
{
/// <summary>
/// Gets the segment text.
/// </summary>
public string Text { get; }
/// <summary>
/// Gets a value indicating whether or not this is an expicit line break
/// that should be preserved.
/// </summary>
public bool IsLineBreak { get; }
/// <summary>
/// Gets the appearance of the segment.
/// </summary>
@ -36,9 +44,24 @@ namespace Spectre.Console.Composition
/// <param name="text">The segment text.</param>
/// <param name="appearance">The segment appearance.</param>
public Segment(string text, Appearance appearance)
: this(text, appearance, false)
{
}
private Segment(string text, Appearance appearance, bool lineBreak)
{
Text = text?.NormalizeLineEndings() ?? throw new ArgumentNullException(nameof(text));
Appearance = appearance;
IsLineBreak = lineBreak;
}
/// <summary>
/// Creates a segment that represents an implicit line break.
/// </summary>
/// <returns>A segment that represents an implicit line break.</returns>
public static Segment LineBreak()
{
return new Segment("\n", Appearance.Plain, true);
}
/// <summary>
@ -61,12 +84,45 @@ namespace Spectre.Console.Composition
return new Segment(Text.TrimEnd('\n'), Appearance);
}
/// <summary>
/// Splits the segment at the offset.
/// </summary>
/// <param name="offset">The offset where to split the segment.</param>
/// <returns>One or two new segments representing the split.</returns>
public (Segment First, Segment Second) Split(int offset)
{
if (offset < 0)
{
return (this, null);
}
if (offset >= Text.Length)
{
return (this, null);
}
return (
new Segment(Text.Substring(0, offset), Appearance),
new Segment(Text.Substring(offset, Text.Length - offset), Appearance));
}
/// <summary>
/// Splits the provided segments into lines.
/// </summary>
/// <param name="segments">The segments to split.</param>
/// <returns>A collection of lines.</returns>
public static List<SegmentLine> Split(IEnumerable<Segment> segments)
public static List<SegmentLine> SplitLines(IEnumerable<Segment> segments)
{
return SplitLines(segments, int.MaxValue);
}
/// <summary>
/// Splits the provided segments into lines with a maximum width.
/// </summary>
/// <param name="segments">The segments to split into lines.</param>
/// <param name="maxWidth">The maximum width.</param>
/// <returns>A list of lines.</returns>
public static List<SegmentLine> SplitLines(IEnumerable<Segment> segments, int maxWidth)
{
if (segments is null)
{
@ -76,14 +132,41 @@ namespace Spectre.Console.Composition
var lines = new List<SegmentLine>();
var line = new SegmentLine();
foreach (var segment in segments)
var stack = new Stack<Segment>(segments.Reverse());
while (stack.Count > 0)
{
var segment = stack.Pop();
if (line.Length + segment.Text.Length > maxWidth)
{
var diff = -(maxWidth - (line.Length + segment.Text.Length));
var offset = segment.Text.Length - diff;
var (first, second) = segment.Split(offset);
line.Add(first);
lines.Add(line);
line = new SegmentLine();
if (second != null)
{
stack.Push(second);
}
continue;
}
if (segment.Text.Contains("\n"))
{
if (segment.Text == "\n")
{
lines.Add(line);
line = new SegmentLine();
if (line.Length > 0 || segment.IsLineBreak)
{
lines.Add(line);
line = new SegmentLine();
}
continue;
}
@ -93,19 +176,21 @@ namespace Spectre.Console.Composition
var parts = text.SplitLines();
if (parts.Length > 0)
{
line.Add(new Segment(parts[0], segment.Appearance));
if (parts[0].Length > 0)
{
line.Add(new Segment(parts[0], segment.Appearance));
}
}
if (parts.Length > 1)
{
lines.Add(line);
line = new SegmentLine();
if (line.Length > 0)
{
lines.Add(line);
line = new SegmentLine();
}
text = string.Concat(parts.Skip(1).Take(parts.Length - 1));
if (string.IsNullOrWhiteSpace(text))
{
text = null;
}
}
else
{

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace Spectre.Console.Composition
{
@ -9,5 +10,9 @@ namespace Spectre.Console.Composition
[SuppressMessage("Naming", "CA1710:Identifiers should have correct suffix")]
public sealed class SegmentLine : List<Segment>
{
/// <summary>
/// Gets the length of the line.
/// </summary>
public int Length => this.Sum(line => line.Text.Length);
}
}

View File

@ -0,0 +1,216 @@
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
{
/// <summary>
/// Represents text with color and style.
/// </summary>
[SuppressMessage("Naming", "CA1724:Type names should not match namespaces")]
public sealed class Text : IRenderable
{
private readonly List<Span> _spans;
private string _text;
private sealed class Span
{
public int Start { get; }
public int End { get; }
public Appearance Appearance { get; }
public Span(int start, int end, Appearance appearance)
{
Start = start;
End = end;
Appearance = appearance ?? Appearance.Plain;
}
}
/// <summary>
/// Initializes a new instance of the <see cref="Console.Text"/> class.
/// </summary>
/// <param name="text">The text.</param>
internal Text(string text)
{
_text = text ?? throw new ArgumentNullException(nameof(text));
_spans = new List<Span>();
}
/// <summary>
/// Initializes a new instance of the <see cref="Text"/> class.
/// </summary>
/// <param name="text">The text.</param>
/// <param name="foreground">The foreground.</param>
/// <param name="background">The background.</param>
/// <param name="style">The style.</param>
/// <returns>A <see cref="Text"/> instance.</returns>
public static Text New(
string text, Color? foreground = null, Color? background = null, Styles? style = null)
{
var result = MarkupParser.Parse(text, new Appearance(foreground, background, style));
return result;
}
/// <summary>
/// Appends some text with a style.
/// </summary>
/// <param name="text">The text to append.</param>
/// <param name="appearance">The appearance of the text.</param>
public void Append(string text, Appearance appearance)
{
if (text == null)
{
throw new ArgumentNullException(nameof(text));
}
var start = _text.Length;
var end = _text.Length + text.Length;
_text += text;
Stylize(start, end, appearance);
}
/// <summary>
/// Stylizes a part of the text.
/// </summary>
/// <param name="start">The start position.</param>
/// <param name="end">The end position.</param>
/// <param name="appearance">The color and style to apply.</param>
public void Stylize(int start, int end, Appearance appearance)
{
if (start >= end)
{
throw new ArgumentOutOfRangeException(nameof(start), "Start position must be less than the end position.");
}
start = Math.Max(start, 0);
end = Math.Min(end, _text.Length);
_spans.Add(new Span(start, end, appearance));
}
/// <inheritdoc/>
public int Measure(Encoding encoding, int maxWidth)
{
var lines = _text.SplitLines();
return lines.Max(x => x.CellLength(encoding));
}
/// <inheritdoc/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
{
var result = new List<Segment>();
var segments = SplitLineBreaks(CreateSegments());
foreach (var (_, _, last, line) in Segment.SplitLines(segments, width).Enumerate())
{
foreach (var segment in line)
{
result.Add(segment.StripLineEndings());
}
if (!last)
{
result.Add(Segment.LineBreak());
}
}
return result;
}
private IEnumerable<Segment> SplitLineBreaks(IEnumerable<Segment> segments)
{
// Creates individual segments of line breaks.
var result = new List<Segment>();
var queue = new Queue<Segment>(segments);
while (queue.Count > 0)
{
var segment = queue.Dequeue();
var index = segment.Text.IndexOf("\n", StringComparison.OrdinalIgnoreCase);
if (index == -1)
{
result.Add(segment);
}
else
{
var (first, second) = segment.Split(index);
if (!string.IsNullOrEmpty(first.Text))
{
result.Add(first);
}
result.Add(Segment.LineBreak());
queue.Enqueue(new Segment(second.Text.Substring(1), second.Appearance));
}
}
return result;
}
private IEnumerable<Segment> CreateSegments()
{
// This excellent algorithm to sort spans was ported and adapted from
// https://github.com/willmcgugan/rich/blob/eb2f0d5277c159d8693636ec60c79c5442fd2e43/rich/text.py#L492
// Create the style map.
var styleMap = _spans.SelectIndex((span, index) => (span, index)).ToDictionary(x => x.index + 1, x => x.span.Appearance);
styleMap[0] = Appearance.Plain;
// Create a span list.
var spans = new List<(int Offset, bool Leaving, int Style)>();
spans.Add((0, false, 0));
spans.AddRange(_spans.SelectIndex((span, index) => (span.Start, false, index + 1)));
spans.AddRange(_spans.SelectIndex((span, index) => (span.End, true, index + 1)));
spans.Add((_text.Length, true, 0));
spans = spans.OrderBy(x => x.Offset).ThenBy(x => !x.Leaving).ToList();
// Keep track of applied appearances using a stack
var styleStack = new Stack<int>();
// Now build the segments.
var result = new List<Segment>();
foreach (var (offset, leaving, style, nextOffset) in BuildSkipList(spans))
{
if (leaving)
{
// Leaving
styleStack.Pop();
}
else
{
// Entering
styleStack.Push(style);
}
if (nextOffset > offset)
{
// Build the current style from the stack
var styleIndices = styleStack.OrderBy(index => index).ToArray();
var currentStyle = Appearance.Plain.Combine(styleIndices.Select(index => styleMap[index]));
// Create segment
var text = _text.Substring(offset, Math.Min(_text.Length - offset, nextOffset - offset));
result.Add(new Segment(text, currentStyle));
}
}
return result;
}
private static IEnumerable<(int Offset, bool Leaving, int Style, int NextOffset)> BuildSkipList(
List<(int Offset, bool Leaving, int Style)> spans)
{
return spans.Zip(spans.Skip(1), (first, second) => (first, second)).Select(
x => (x.first.Offset, x.first.Leaving, x.first.Style, NextOffset: x.second.Offset));
}
}
}

View File

@ -29,8 +29,7 @@ namespace Spectre.Console
/// <param name="args">An array of objects to write.</param>
public static void Markup(this IAnsiConsole console, IFormatProvider provider, string format, params object[] args)
{
var result = MarkupParser.Parse(string.Format(provider, format, args));
result.Render(console);
console.Render(MarkupParser.Parse(string.Format(provider, format, args)));
}
/// <summary>

View File

@ -92,12 +92,7 @@ namespace Spectre.Console.Internal
internal static Color ExactOrClosest(ColorSystem system, Color color)
{
var exact = Exact(system, color);
if (exact != null)
{
return exact.Value;
}
return Closest(system, color);
return exact ?? Closest(system, color);
}
private static Color? Exact(ColorSystem system, Color color)

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace Spectre.Console.Internal
{
internal static class AppearanceExtensions
{
public static Appearance Combine(this Appearance appearance, IEnumerable<Appearance> source)
{
var current = appearance;
foreach (var item in source)
{
current = current.Combine(item);
}
return current;
}
}
}

View File

@ -1,12 +0,0 @@
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);
}
}
}

View File

@ -1,6 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Spectre.Console.Composition;
namespace Spectre.Console.Internal
{

View File

@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Spectre.Console.Internal
{
internal static class EnumerableExtensions
{
public static IEnumerable<(int Index, bool First, bool Last, T Item)> Enumerate<T>(this IEnumerable<T> source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
return Enumerate(source.GetEnumerator());
}
public static IEnumerable<(int Index, bool First, bool Last, T Item)> Enumerate<T>(this IEnumerator<T> source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
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;
}
}
public static IEnumerable<TResult> SelectIndex<T, TResult>(this IEnumerable<T> source, Func<T, int, TResult> func)
{
return source.Select((value, index) => func(value, index));
}
}
}

View File

@ -1,30 +0,0 @@
using System.Collections.Generic;
namespace Spectre.Console.Internal
{
internal sealed class MarkupBlockNode : IMarkupNode
{
private readonly List<IMarkupNode> _elements;
public MarkupBlockNode()
{
_elements = new List<IMarkupNode>();
}
public void Append(IMarkupNode element)
{
if (element != null)
{
_elements.Add(element);
}
}
public void Render(IAnsiConsole renderer)
{
foreach (var element in _elements)
{
element.Render(renderer);
}
}
}
}

View File

@ -1,52 +0,0 @@
using System;
namespace Spectre.Console.Internal
{
internal sealed class MarkupStyleNode : IMarkupNode
{
private readonly Styles? _style;
private readonly Color? _foreground;
private readonly Color? _background;
private readonly IMarkupNode _element;
public MarkupStyleNode(
Styles? style,
Color? foreground,
Color? background,
IMarkupNode element)
{
_style = style;
_foreground = foreground;
_background = background;
_element = element ?? throw new ArgumentNullException(nameof(element));
}
public void Render(IAnsiConsole renderer)
{
var style = (IDisposable)null;
var foreground = (IDisposable)null;
var background = (IDisposable)null;
if (_style != null)
{
style = renderer.PushStyle(_style.Value);
}
if (_foreground != null)
{
foreground = renderer.PushColor(_foreground.Value, foreground: true);
}
if (_background != null)
{
background = renderer.PushColor(_background.Value, foreground: false);
}
_element.Render(renderer);
background?.Dispose();
foreground?.Dispose();
style?.Dispose();
}
}
}

View File

@ -1,19 +0,0 @@
using System;
namespace Spectre.Console.Internal
{
internal sealed class MarkupTextNode : IMarkupNode
{
public string Text { get; }
public MarkupTextNode(string text)
{
Text = text ?? throw new ArgumentNullException(nameof(text));
}
public void Render(IAnsiConsole renderer)
{
renderer.Write(Text);
}
}
}

View File

@ -1,14 +0,0 @@
namespace Spectre.Console.Internal
{
/// <summary>
/// Represents a parsed markup node.
/// </summary>
internal interface IMarkupNode
{
/// <summary>
/// Renders the node using the specified renderer.
/// </summary>
/// <param name="renderer">The renderer to use.</param>
void Render(IAnsiConsole renderer);
}
}

View File

@ -5,37 +5,23 @@ namespace Spectre.Console.Internal
{
internal static class MarkupParser
{
public static IMarkupNode Parse(string text)
public static Text Parse(string text, Appearance appearance = null)
{
appearance ??= Appearance.Plain;
var result = new Text(string.Empty);
using var tokenizer = new MarkupTokenizer(text);
var root = new MarkupBlockNode();
var stack = new Stack<MarkupBlockNode>();
var current = root;
var stack = new Stack<Appearance>();
while (true)
while (tokenizer.MoveNext())
{
var token = tokenizer.GetNext();
if (token == null)
{
break;
}
var token = tokenizer.Current;
if (token.Kind == MarkupTokenKind.Text)
{
current.Append(new MarkupTextNode(token.Value));
continue;
}
else if (token.Kind == MarkupTokenKind.Open)
if (token.Kind == MarkupTokenKind.Open)
{
var (style, foreground, background) = MarkupStyleParser.Parse(token.Value);
var content = new MarkupBlockNode();
current.Append(new MarkupStyleNode(style, foreground, background, content));
current = content;
stack.Push(current);
continue;
stack.Push(new Appearance(foreground, background, style));
}
else if (token.Kind == MarkupTokenKind.Close)
{
@ -45,20 +31,17 @@ namespace Spectre.Console.Internal
}
stack.Pop();
if (stack.Count == 0)
{
current = root;
}
else
{
current = stack.Peek();
}
continue;
}
throw new InvalidOperationException("Encountered unkown markup token.");
else if (token.Kind == MarkupTokenKind.Text)
{
// Get the effecive style.
var style = appearance.Combine(stack);
result.Append(token.Value, style);
}
else
{
throw new InvalidOperationException("Encountered unkown markup token.");
}
}
if (stack.Count > 0)
@ -66,7 +49,7 @@ namespace Spectre.Console.Internal
throw new InvalidOperationException("Unbalanced markup stack. Did you forget to close a tag?");
}
return root;
return result;
}
}
}

View File

@ -7,6 +7,8 @@ namespace Spectre.Console.Internal
{
private readonly StringBuffer _reader;
public MarkupToken Current { get; private set; }
public MarkupTokenizer(string text)
{
_reader = new StringBuffer(text ?? throw new ArgumentNullException(nameof(text)));
@ -17,11 +19,11 @@ namespace Spectre.Console.Internal
_reader.Dispose();
}
public MarkupToken GetNext()
public bool MoveNext()
{
if (_reader.Eof)
{
return null;
return false;
}
var current = _reader.Peek();
@ -40,7 +42,8 @@ namespace Spectre.Console.Internal
if (current == '[')
{
_reader.Read();
return new MarkupToken(MarkupTokenKind.Text, "[", position);
Current = new MarkupToken(MarkupTokenKind.Text, "[", position);
return true;
}
if (current == '/')
@ -59,7 +62,8 @@ namespace Spectre.Console.Internal
}
_reader.Read();
return new MarkupToken(MarkupTokenKind.Close, string.Empty, position);
Current = new MarkupToken(MarkupTokenKind.Close, string.Empty, position);
return true;
}
var builder = new StringBuilder();
@ -80,7 +84,8 @@ namespace Spectre.Console.Internal
}
_reader.Read();
return new MarkupToken(MarkupTokenKind.Open, builder.ToString(), position);
Current = new MarkupToken(MarkupTokenKind.Open, builder.ToString(), position);
return true;
}
else
{
@ -97,7 +102,8 @@ namespace Spectre.Console.Internal
builder.Append(_reader.Read());
}
return new MarkupToken(MarkupTokenKind.Text, builder.ToString(), position);
Current = new MarkupToken(MarkupTokenKind.Text, builder.ToString(), position);
return true;
}
}
}

View File

@ -1,127 +0,0 @@
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
{
/// <summary>
/// Represents text with color and style.
/// </summary>
[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;
/// <summary>
/// Initializes a new instance of the <see cref="Text"/> class.
/// </summary>
/// <param name="text">The text.</param>
/// <param name="appearance">The appearance.</param>
/// <param name="justify">The justification.</param>
public Text(string text, Appearance appearance = null, Justify justify = Justify.Left)
{
_text = text ?? throw new ArgumentNullException(nameof(text));
_appearance = appearance ?? Appearance.Plain;
_justify = justify;
}
/// <summary>
/// Initializes a new instance of the <see cref="Text"/> class.
/// </summary>
/// <param name="text">The text.</param>
/// <param name="foreground">The foreground.</param>
/// <param name="background">The background.</param>
/// <param name="style">The style.</param>
/// <param name="justify">The justification.</param>
/// <returns>A <see cref="Text"/> instance.</returns>
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);
}
/// <inheritdoc/>
public int Measure(Encoding encoding, int maxWidth)
{
return _text.SplitLines().Max(x => x.CellLength(encoding));
}
/// <inheritdoc/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
{
var result = new List<Segment>();
foreach (var line in Partition(encoding, _text, width))
{
result.Add(new Segment(line, _appearance));
}
return result;
}
private IEnumerable<string> Partition(Encoding encoding, string text, int width)
{
var lines = new List<string>();
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;
}
}
}