namespace Spectre.Console.Rendering; /// /// Represents a renderable segment. /// [DebuggerDisplay("{Text,nq}")] public class Segment { /// /// Gets the segment text. /// public string Text { get; } /// /// Gets a value indicating whether or not this is an explicit line break /// that should be preserved. /// public bool IsLineBreak { get; } /// /// Gets a value indicating whether or not this is a whitespace /// that should be preserved but not taken into account when /// layouting text. /// public bool IsWhiteSpace { get; } /// /// Gets a value indicating whether or not his is a /// control code such as cursor movement. /// public bool IsControlCode { get; } /// /// Gets the segment style. /// public Style Style { get; } /// /// Gets a segment representing a line break. /// public static Segment LineBreak { get; } = new Segment(Environment.NewLine, Style.Plain, true, false); /// /// Gets an empty segment. /// public static Segment Empty { get; } = new Segment(string.Empty, Style.Plain, false, false); /// /// Creates padding segment. /// /// Number of whitespace characters. /// Segment for specified padding size. public static Segment Padding(int size) => new(new string(' ', size)); /// /// Initializes a new instance of the class. /// /// The segment text. public Segment(string text) : this(text, Style.Plain) { } /// /// Initializes a new instance of the class. /// /// The segment text. /// The segment style. public Segment(string text, Style style) : this(text, style, false, false) { } private Segment(string text, Style style, bool lineBreak, bool control) { Text = text?.NormalizeNewLines() ?? throw new ArgumentNullException(nameof(text)); Style = style ?? throw new ArgumentNullException(nameof(style)); IsLineBreak = lineBreak; IsWhiteSpace = string.IsNullOrWhiteSpace(text); IsControlCode = control; } /// /// Creates a control segment. /// /// The control code. /// A segment representing a control code. public static Segment Control(string control) { return new Segment(control, Style.Plain, false, true); } /// /// Gets the number of cells that this segment /// occupies in the console. /// /// The number of cells that this segment occupies in the console. public int CellCount() { if (IsControlCode) { return 0; } return Cell.GetCellLength(Text); } /// /// Gets the number of cells that the segments occupies in the console. /// /// The segments to measure. /// The number of cells that the segments occupies in the console. public static int CellCount(IEnumerable segments) { if (segments is null) { throw new ArgumentNullException(nameof(segments)); } var sum = 0; foreach (var segment in segments) { sum += segment.CellCount(); } return sum; } /// /// 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').TrimEnd('\r'), Style); } /// /// Splits the segment at the offset. /// /// The offset where to split the segment. /// One or two new segments representing the split. public (Segment First, Segment? Second) Split(int offset) { if (offset < 0) { return (this, null); } if (offset >= CellCount()) { return (this, null); } var index = 0; if (offset > 0) { var accumulated = 0; foreach (var character in Text) { index++; accumulated += Cell.GetCellLength(character); if (accumulated >= offset) { break; } } } return ( new Segment(Text.Substring(0, index), Style), new Segment(Text.Substring(index, Text.Length - index), Style)); } /// /// Clones the segment. /// /// A new segment that's identical to this one. public Segment Clone() { return new Segment(Text, Style); } /// /// Splits the provided segments into lines. /// /// The segments to split. /// A collection of lines. public static List SplitLines(IEnumerable segments) { if (segments is null) { throw new ArgumentNullException(nameof(segments)); } return SplitLines(segments, int.MaxValue); } /// /// Splits the provided segments into lines with a maximum width. /// /// The segments to split into lines. /// The maximum width. /// The height (if any). /// A list of lines. public static List SplitLines(IEnumerable segments, int maxWidth, int? height = null) { if (segments is null) { throw new ArgumentNullException(nameof(segments)); } var lines = new List(); var line = new SegmentLine(); var stack = new Stack(segments.Reverse()); while (stack.Count > 0) { var segment = stack.Pop(); var segmentLength = segment.CellCount(); // Does this segment make the line exceed the max width? var lineLength = line.CellCount(); if (lineLength + segmentLength > maxWidth) { var diff = -(maxWidth - (lineLength + segmentLength)); 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; } // Does the segment contain a newline? if (segment.Text.ContainsExact("\n")) { // Is it a new line? if (segment.Text == "\n") { if (line.Length != 0 || segment.IsLineBreak) { lines.Add(line); line = new SegmentLine(); } continue; } var text = segment.Text; while (text != null) { var parts = text.SplitLines(); if (parts.Length > 0) { if (parts[0].Length > 0) { line.Add(new Segment(parts[0], segment.Style)); } } if (parts.Length > 1) { if (line.Length > 0) { lines.Add(line); line = new SegmentLine(); } text = string.Concat(parts.Skip(1).Take(parts.Length - 1)); } else { text = null; } } } else { line.Add(segment); } } if (line.Count > 0) { lines.Add(line); } // Got a height specified? if (height != null) { if (lines.Count >= height) { // Remove lines lines.RemoveRange(height.Value, lines.Count - height.Value); } else { // Add lines var missing = height - lines.Count; for (var i = 0; i < missing; i++) { lines.Add(new SegmentLine()); } } } return lines; } /// /// Splits an overflowing segment into several new segments. /// /// The segment to split. /// The overflow strategy to use. /// The maximum width. /// A list of segments that has been split. public static List SplitOverflow(Segment segment, Overflow? overflow, int maxWidth) { if (segment is null) { throw new ArgumentNullException(nameof(segment)); } if (segment.CellCount() <= maxWidth) { return new List(1) { segment }; } // Default to folding overflow ??= Overflow.Fold; var result = new List(); if (overflow == Overflow.Fold) { var splitted = SplitSegment(segment.Text, maxWidth); foreach (var str in splitted) { result.Add(new Segment(str, segment.Style)); } } else if (overflow == Overflow.Crop) { if (Math.Max(0, maxWidth - 1) == 0) { result.Add(new Segment(string.Empty, segment.Style)); } else { result.Add(new Segment(segment.Text.Substring(0, maxWidth), segment.Style)); } } else if (overflow == Overflow.Ellipsis) { if (Math.Max(0, maxWidth - 1) == 0) { result.Add(new Segment("…", segment.Style)); } else { result.Add(new Segment(segment.Text.Substring(0, maxWidth - 1) + "…", segment.Style)); } } return result; } /// /// Truncates the segments to the specified width. /// /// The segments to truncate. /// The maximum width that the segments may occupy. /// A list of segments that has been truncated. public static List Truncate(IEnumerable segments, int maxWidth) { if (segments is null) { throw new ArgumentNullException(nameof(segments)); } var result = new List(); var totalWidth = 0; foreach (var segment in segments) { var segmentCellWidth = segment.CellCount(); if (totalWidth + segmentCellWidth > maxWidth) { break; } result.Add(segment); totalWidth += segmentCellWidth; } if (result.Count == 0 && segments.Any()) { var segment = Truncate(segments.First(), maxWidth); if (segment != null) { result.Add(segment); } } return result; } /// /// Truncates the segment to the specified width. /// /// The segment to truncate. /// The maximum width that the segment may occupy. /// A new truncated segment, or null. public static Segment? Truncate(Segment? segment, int maxWidth) { if (segment is null) { return null; } if (segment.CellCount() <= maxWidth) { return segment; } var builder = new StringBuilder(); foreach (var character in segment.Text) { var accumulatedCellWidth = builder.ToString().GetCellWidth(); if (accumulatedCellWidth >= maxWidth) { break; } builder.Append(character); } if (builder.Length == 0) { return null; } return new Segment(builder.ToString(), segment.Style); } internal static IEnumerable Merge(IEnumerable segments) { if (segments is null) { throw new ArgumentNullException(nameof(segments)); } var result = new List(); var segmentBuilder = (SegmentBuilder?)null; foreach (var segment in segments) { if (segmentBuilder == null) { segmentBuilder = new SegmentBuilder(segment); continue; } // Both control codes? if (segment.IsControlCode && segmentBuilder.IsControlCode()) { segmentBuilder.Append(segment.Text); continue; } // Same style? if (segmentBuilder.StyleEquals(segment.Style) && !segmentBuilder.IsLineBreak() && !segmentBuilder.IsControlCode()) { segmentBuilder.Append(segment.Text); continue; } result.Add(segmentBuilder.Build()); segmentBuilder.Reset(segment); } if (segmentBuilder != null) { result.Add(segmentBuilder.Build()); } return result; } internal static List TruncateWithEllipsis(IEnumerable segments, int maxWidth) { if (segments is null) { throw new ArgumentNullException(nameof(segments)); } if (CellCount(segments) <= maxWidth) { return new List(segments); } segments = TrimEnd(Truncate(segments, maxWidth - 1)); if (!segments.Any()) { return new List(1); } var result = new List(segments); result.Add(new Segment("…", result.Last().Style)); return result; } internal static List TrimEnd(IEnumerable segments) { if (segments is null) { throw new ArgumentNullException(nameof(segments)); } var stack = new Stack(); var checkForWhitespace = true; foreach (var segment in segments.Reverse()) { if (checkForWhitespace) { if (segment.IsWhiteSpace) { continue; } checkForWhitespace = false; } stack.Push(segment); } return stack.ToList(); } // TODO: Move this to Table internal static List> MakeSameHeight(int cellHeight, List> cells) { if (cells is null) { throw new ArgumentNullException(nameof(cells)); } foreach (var cell in cells) { if (cell.Count < cellHeight) { while (cell.Count != cellHeight) { cell.Add(new SegmentLine()); } } } return cells; } internal static List MakeWidth(int expectedWidth, List lines) { foreach (var line in lines) { var width = line.CellCount(); if (width < expectedWidth) { var diff = expectedWidth - width; line.Add(new Segment(new string(' ', diff))); } } return lines; } internal static List SplitSegment(string text, int maxCellLength) { var list = new List(); var length = 0; var sb = new StringBuilder(); foreach (var ch in text) { if (length + UnicodeCalculator.GetWidth(ch) > maxCellLength) { list.Add(sb.ToString()); sb.Clear(); length = 0; } length += UnicodeCalculator.GetWidth(ch); sb.Append(ch); } list.Add(sb.ToString()); return list; } private class SegmentBuilder { private readonly StringBuilder _textBuilder = new(); private Segment _originalSegment; public SegmentBuilder(Segment originalSegment) { _originalSegment = originalSegment; Reset(originalSegment); } public bool IsControlCode() => _originalSegment.IsControlCode; public bool IsLineBreak() => _originalSegment.IsLineBreak; public bool StyleEquals(Style segmentStyle) => segmentStyle.Equals(_originalSegment.Style); public void Append(string text) { _textBuilder.Append(text); } public Segment Build() { return new Segment(_textBuilder.ToString(), _originalSegment.Style, _originalSegment.IsLineBreak, _originalSegment.IsControlCode); } public void Reset(Segment segment) { _textBuilder.Clear(); _textBuilder.Append(segment.Text); _originalSegment = segment; } } }