From 9f8ca6d6481c4e0e1f618ba040017dea013c3313 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Thu, 3 Sep 2020 14:34:13 +0200 Subject: [PATCH] Add text overflow support Closes #61 --- src/Spectre.Console.Tests/Unit/TextTests.cs | 20 ++++ src/Spectre.Console/Rendering/Markup.cs | 9 +- src/Spectre.Console/Rendering/Overflow.cs | 24 ++++ src/Spectre.Console/Rendering/Paragraph.cs | 105 +++++++++++++----- src/Spectre.Console/Rendering/Segment.cs | 51 +++++++++ src/Spectre.Console/Rendering/Text.cs | 11 +- .../Extensions/OverflowableExtensions.cs | 80 +++++++++++++ .../Rendering/Traits/IOverflowable.cs | 13 +++ 8 files changed, 282 insertions(+), 31 deletions(-) create mode 100644 src/Spectre.Console/Rendering/Overflow.cs create mode 100644 src/Spectre.Console/Rendering/Traits/Extensions/OverflowableExtensions.cs create mode 100644 src/Spectre.Console/Rendering/Traits/IOverflowable.cs diff --git a/src/Spectre.Console.Tests/Unit/TextTests.cs b/src/Spectre.Console.Tests/Unit/TextTests.cs index ef9803c..09f1141 100644 --- a/src/Spectre.Console.Tests/Unit/TextTests.cs +++ b/src/Spectre.Console.Tests/Unit/TextTests.cs @@ -83,5 +83,25 @@ namespace Spectre.Console.Tests.Unit .NormalizeLineEndings() .ShouldBe(expected); } + + [Theory] + [InlineData(Overflow.Fold, "foo \npneumonoultram\nicroscopicsili\ncovolcanoconio\nsis bar qux")] + [InlineData(Overflow.Crop, "foo \npneumonoultram\nbar qux")] + [InlineData(Overflow.Ellipsis, "foo \npneumonoultra…\nbar qux")] + public void Should_Overflow_Text_Correctly(Overflow overflow, string expected) + { + // Given + var fixture = new PlainConsole(14); + var text = new Text("foo pneumonoultramicroscopicsilicovolcanoconiosis bar qux") + .SetOverflow(overflow); + + // When + fixture.Render(text); + + // Then + fixture.Output + .NormalizeLineEndings() + .ShouldBe(expected); + } } } diff --git a/src/Spectre.Console/Rendering/Markup.cs b/src/Spectre.Console/Rendering/Markup.cs index 9dcc60e..c798016 100644 --- a/src/Spectre.Console/Rendering/Markup.cs +++ b/src/Spectre.Console/Rendering/Markup.cs @@ -7,7 +7,7 @@ namespace Spectre.Console /// /// A renderable piece of markup text. /// - public sealed class Markup : Renderable, IAlignable + public sealed class Markup : Renderable, IAlignable, IOverflowable { private readonly Paragraph _paragraph; @@ -18,6 +18,13 @@ namespace Spectre.Console set => _paragraph.Alignment = value; } + /// + public Overflow? Overflow + { + get => _paragraph.Overflow; + set => _paragraph.Overflow = value; + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Spectre.Console/Rendering/Overflow.cs b/src/Spectre.Console/Rendering/Overflow.cs new file mode 100644 index 0000000..0621d2d --- /dev/null +++ b/src/Spectre.Console/Rendering/Overflow.cs @@ -0,0 +1,24 @@ +namespace Spectre.Console +{ + /// + /// Represents text overflow. + /// + public enum Overflow + { + /// + /// Put any excess characters on the next line. + /// + Fold = 0, + + /// + /// Truncates the text at the end of the line. + /// + Crop = 1, + + /// + /// Truncates the text at the end of the line and + /// also inserts an ellipsis character. + /// + Ellipsis = 2, + } +} diff --git a/src/Spectre.Console/Rendering/Paragraph.cs b/src/Spectre.Console/Rendering/Paragraph.cs index aa97487..8167a6f 100644 --- a/src/Spectre.Console/Rendering/Paragraph.cs +++ b/src/Spectre.Console/Rendering/Paragraph.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -12,7 +12,7 @@ namespace Spectre.Console /// of the paragraph can have individual styling. /// [DebuggerDisplay("{_text,nq}")] - public sealed class Paragraph : Renderable, IAlignable + public sealed class Paragraph : Renderable, IAlignable, IOverflowable { private readonly List _lines; @@ -21,6 +21,11 @@ namespace Spectre.Console /// public Justify? Alignment { get; set; } + /// + /// Gets or sets the text overflow strategy. + /// + public Overflow? Overflow { get; set; } + /// /// Initializes a new instance of the class. /// @@ -197,34 +202,76 @@ namespace Spectre.Console var line = new SegmentLine(); var newLine = true; - using (var iterator = new SegmentLineIterator(_lines)) + + using var iterator = new SegmentLineIterator(_lines); + var queue = new Queue(); + while (true) { - while (iterator.MoveNext()) + var current = (Segment?)null; + if (queue.Count == 0) { - var current = iterator.Current; - if (current == null) + if (!iterator.MoveNext()) { - throw new InvalidOperationException("Iterator returned empty segment."); + break; } - if (newLine && current.IsWhiteSpace && !current.IsLineBreak) - { - newLine = false; - continue; - } + current = iterator.Current; + } + else + { + current = queue.Dequeue(); + } + if (current == null) + { + throw new InvalidOperationException("Iterator returned empty segment."); + } + + if (newLine && current.IsWhiteSpace && !current.IsLineBreak) + { newLine = false; + continue; + } - if (current.IsLineBreak) + newLine = false; + + if (current.IsLineBreak) + { + line.Add(current); + lines.Add(line); + line = new SegmentLine(); + newLine = true; + continue; + } + + var length = current.CellLength(context.Encoding); + if (length > maxWidth) + { + // The current segment is longer than the width of the console, + // so we will need to crop it up, into new segments. + var segments = Segment.SplitOverflow(current, Overflow, context.Encoding, maxWidth); + if (segments.Count > 0) { - line.Add(current); - lines.Add(line); - line = new SegmentLine(); - newLine = true; - continue; - } + if (line.CellWidth(context.Encoding) + segments[0].CellLength(context.Encoding) > maxWidth) + { + lines.Add(line); + line = new SegmentLine(); + newLine = true; - var length = current.CellLength(context.Encoding); + segments.ForEach(s => queue.Enqueue(s)); + continue; + } + else + { + // Add the segment and push the rest of them to the queue. + line.Add(segments[0]); + segments.Skip(1).ForEach(s => queue.Enqueue(s)); + continue; + } + } + } + else + { if (line.CellWidth(context.Encoding) + length > maxWidth) { line.Add(Segment.Empty); @@ -232,16 +279,16 @@ namespace Spectre.Console line = new SegmentLine(); newLine = true; } - - if (newLine && current.IsWhiteSpace) - { - continue; - } - - newLine = false; - - line.Add(current); } + + if (newLine && current.IsWhiteSpace) + { + continue; + } + + newLine = false; + + line.Add(current); } // Flush remaining. diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 2d93867..b398595 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -261,6 +261,57 @@ namespace Spectre.Console.Rendering return result; } + /// + /// Splits an overflowing segment into several new segments. + /// + /// The segment to split. + /// The overflow strategy to use. + /// The encodign to use. + /// The maxiumum width. + /// A list of segments that has been split. + public static List SplitOverflow(Segment segment, Overflow? overflow, Encoding encoding, int width) + { + if (segment is null) + { + throw new ArgumentNullException(nameof(segment)); + } + + if (segment.CellLength(encoding) <= width) + { + return new List(1) { segment }; + } + + // Default to folding + overflow ??= Overflow.Fold; + + var result = new List(); + + if (overflow == Overflow.Fold) + { + var totalLength = segment.Text.CellLength(encoding); + var lengthLeft = totalLength; + while (lengthLeft > 0) + { + var index = totalLength - lengthLeft; + var take = Math.Min(width, totalLength - index); + + result.Add(new Segment(segment.Text.Substring(index, take), segment.Style)); + lengthLeft -= take; + } + } + else if (overflow == Overflow.Crop) + { + result.Add(new Segment(segment.Text.Substring(0, width), segment.Style)); + } + else if (overflow == Overflow.Ellipsis) + { + result.Add(new Segment(segment.Text.Substring(0, width - 1), segment.Style)); + result.Add(new Segment("…", segment.Style)); + } + + return result; + } + internal static List> MakeSameHeight(int cellHeight, List> cells) { foreach (var cell in cells) diff --git a/src/Spectre.Console/Rendering/Text.cs b/src/Spectre.Console/Rendering/Text.cs index 9a7edaa..bb60f5e 100644 --- a/src/Spectre.Console/Rendering/Text.cs +++ b/src/Spectre.Console/Rendering/Text.cs @@ -10,7 +10,7 @@ namespace Spectre.Console /// [DebuggerDisplay("{_text,nq}")] [SuppressMessage("Naming", "CA1724:Type names should not match namespaces")] - public sealed class Text : Renderable, IAlignable + public sealed class Text : Renderable, IAlignable, IOverflowable { private readonly Paragraph _paragraph; @@ -38,6 +38,15 @@ namespace Spectre.Console set => _paragraph.Alignment = value; } + /// + /// Gets or sets the text overflow strategy. + /// + public Overflow? Overflow + { + get => _paragraph.Overflow; + set => _paragraph.Overflow = value; + } + /// protected override Measurement Measure(RenderContext context, int maxWidth) { diff --git a/src/Spectre.Console/Rendering/Traits/Extensions/OverflowableExtensions.cs b/src/Spectre.Console/Rendering/Traits/Extensions/OverflowableExtensions.cs new file mode 100644 index 0000000..2efc0b7 --- /dev/null +++ b/src/Spectre.Console/Rendering/Traits/Extensions/OverflowableExtensions.cs @@ -0,0 +1,80 @@ +using System; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class OverflowableExtensions + { + /// + /// Folds any overflowing text. + /// + /// An object implementing . + /// The overflowable object instance. + /// The same instance so that multiple calls can be chained. + public static T Fold(this T obj) + where T : class, IOverflowable + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + return SetOverflow(obj, Overflow.Fold); + } + + /// + /// Crops any overflowing text. + /// + /// An object implementing . + /// The overflowable object instance. + /// The same instance so that multiple calls can be chained. + public static T Crop(this T obj) + where T : class, IOverflowable + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + return SetOverflow(obj, Overflow.Crop); + } + + /// + /// Crops any overflowing text and adds an ellipsis to the end. + /// + /// An object implementing . + /// The overflowable object instance. + /// The same instance so that multiple calls can be chained. + public static T Ellipsis(this T obj) + where T : class, IOverflowable + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + return SetOverflow(obj, Overflow.Ellipsis); + } + + /// + /// Sets the overflow strategy. + /// + /// An object implementing . + /// The overflowable object instance. + /// The overflow strategy to use. + /// The same instance so that multiple calls can be chained. + public static T SetOverflow(this T obj, Overflow overflow) + where T : class, IOverflowable + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.Overflow = overflow; + return obj; + } + } +} diff --git a/src/Spectre.Console/Rendering/Traits/IOverflowable.cs b/src/Spectre.Console/Rendering/Traits/IOverflowable.cs new file mode 100644 index 0000000..1807a62 --- /dev/null +++ b/src/Spectre.Console/Rendering/Traits/IOverflowable.cs @@ -0,0 +1,13 @@ +namespace Spectre.Console +{ + /// + /// Represents something that can overflow. + /// + public interface IOverflowable + { + /// + /// Gets or sets the text overflow strategy. + /// + Overflow? Overflow { get; set; } + } +}