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

@ -0,0 +1,119 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Spectre.Console.Composition;
namespace Spectre.Console
{
/// <summary>
/// Represents a panel which contains another renderable item.
/// </summary>
public sealed class Panel : IRenderable
{
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>
/// <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/>
public int Measure(Encoding encoding, int maxWidth)
{
var childWidth = _child.Measure(encoding, maxWidth);
return childWidth + 4;
}
/// <inheritdoc/>
public IEnumerable<Segment> Render(Encoding encoding, int width)
{
var childWidth = width - 4;
if (!_fit)
{
childWidth = _child.Measure(encoding, width - 2);
}
var result = new List<Segment>();
var panelWidth = childWidth + 2;
result.Add(new Segment("┌"));
result.Add(new Segment(new string('─', panelWidth)));
result.Add(new Segment("┐"));
result.Add(new Segment("\n"));
// Render the child.
var childSegments = _child.Render(encoding, childWidth);
// Split the child segments into lines.
var lines = Segment.SplitLines(childSegments, childWidth);
foreach (var line in lines)
{
result.Add(new Segment("│ "));
var content = new List<Segment>();
var length = line.Sum(segment => segment.CellLength(encoding));
if (length < childWidth)
{
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"));
}
result.Add(new Segment("└"));
result.Add(new Segment(new string('─', panelWidth)));
result.Add(new Segment("┘"));
result.Add(new Segment("\n"));
return result;
}
}
}

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