using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; 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 expicit 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); /// /// 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 render context. /// The number of cells that this segment occupies in the console. public int CellCount(RenderContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } if (IsControlCode) { return 0; } return Text.CellLength(context); } /// /// Gets the number of cells that the segments occupies in the console. /// /// The render context. /// The segments to measure. /// The number of cells that the segments occupies in the console. public static int CellCount(RenderContext context, IEnumerable segments) { if (context is null) { throw new ArgumentNullException(nameof(context)); } if (segments is null) { throw new ArgumentNullException(nameof(segments)); } return segments.Sum(segment => segment.CellCount(context)); } /// /// 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 >= Text.Length) { return (this, null); } return ( new Segment(Text.Substring(0, offset), Style), new Segment(Text.Substring(offset, Text.Length - offset), 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 render context. /// The segments to split. /// A collection of lines. public static List SplitLines(RenderContext context, IEnumerable segments) { if (context is null) { throw new ArgumentNullException(nameof(context)); } if (segments is null) { throw new ArgumentNullException(nameof(segments)); } return SplitLines(context, segments, int.MaxValue); } /// /// Splits the provided segments into lines with a maximum width. /// /// The render context. /// The segments to split into lines. /// The maximum width. /// A list of lines. public static List SplitLines(RenderContext context, IEnumerable segments, int maxWidth) { if (context is null) { throw new ArgumentNullException(nameof(context)); } 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(); // Does this segment make the line exceed the max width? if (line.CellCount(context) + segment.CellCount(context) > 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; } // 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); } return lines; } /// /// Splits an overflowing segment into several new segments. /// /// The segment to split. /// The overflow strategy to use. /// The render context. /// The maximum width. /// A list of segments that has been split. public static List SplitOverflow(Segment segment, Overflow? overflow, RenderContext context, int maxWidth) { if (segment is null) { throw new ArgumentNullException(nameof(segment)); } if (context is null) { throw new ArgumentNullException(nameof(context)); } if (segment.CellCount(context) <= maxWidth) { return new List(1) { segment }; } // Default to folding overflow ??= Overflow.Fold; var result = new List(); if (overflow == Overflow.Fold) { var totalLength = segment.Text.CellLength(context); var lengthLeft = totalLength; while (lengthLeft > 0) { var index = totalLength - lengthLeft; // How many characters should we take? var take = Math.Min(maxWidth, totalLength - index); if (take <= 0) { // This shouldn't really occur, but I don't like // never ending loops if it does... return new List(); } result.Add(new Segment(segment.Text.Substring(index, take), segment.Style)); lengthLeft -= take; } } 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 render context. /// The segments to truncate. /// The maximum width that the segments may occupy. /// A list of segments that has been truncated. public static List Truncate(RenderContext context, IEnumerable segments, int maxWidth) { if (context is null) { throw new ArgumentNullException(nameof(context)); } 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(context); if (totalWidth + segmentCellWidth > maxWidth) { break; } result.Add(segment); totalWidth += segmentCellWidth; } if (result.Count == 0 && segments.Any()) { var segment = Truncate(context, segments.First(), maxWidth); if (segment != null) { result.Add(segment); } } return result; } /// /// Truncates the segment to the specified width. /// /// The render context. /// The segment to truncate. /// The maximum width that the segment may occupy. /// A new truncated segment, or null. public static Segment? Truncate(RenderContext context, Segment segment, int maxWidth) { if (context is null) { throw new ArgumentNullException(nameof(context)); } if (segment is null) { return null; } if (segment.CellCount(context) <= maxWidth) { return segment; } var builder = new StringBuilder(); foreach (var character in segment.Text) { var accumulatedCellWidth = builder.ToString().CellLength(context); 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 previous = (Segment?)null; foreach (var segment in segments) { if (previous == null) { previous = segment; continue; } // Both control codes? if (segment.IsControlCode && previous.IsControlCode) { previous = Control(previous.Text + segment.Text); continue; } // Same style? if (previous.Style.Equals(segment.Style) && !previous.IsLineBreak && !previous.IsControlCode) { previous = new Segment(previous.Text + segment.Text, previous.Style); continue; } result.Add(previous); previous = segment; } if (previous != null) { result.Add(previous); } return result; } internal static Segment TruncateWithEllipsis(string text, Style style, RenderContext context, int maxWidth) { if (text is null) { throw new ArgumentNullException(nameof(text)); } if (style is null) { throw new ArgumentNullException(nameof(style)); } if (context is null) { throw new ArgumentNullException(nameof(context)); } var overflow = SplitOverflow(new Segment(text, style), Overflow.Ellipsis, context, maxWidth); if (overflow.Count == 0) { if (maxWidth > 0) { return new Segment(text, style); } // We got space for an ellipsis return new Segment("…", style); } return SplitOverflow( new Segment(text, style), Overflow.Ellipsis, context, maxWidth)[0]; } internal static List TruncateWithEllipsis(IEnumerable segments, RenderContext context, int maxWidth) { if (segments is null) { throw new ArgumentNullException(nameof(segments)); } if (context is null) { throw new ArgumentNullException(nameof(context)); } if (CellCount(context, segments) <= maxWidth) { return new List(segments); } segments = TrimEnd(Truncate(context, 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 (int Width, int Height) GetShape(RenderContext context, List lines) { if (context is null) { throw new ArgumentNullException(nameof(context)); } if (lines is null) { throw new ArgumentNullException(nameof(lines)); } var height = lines.Count; var width = lines.Max(l => CellCount(context, l)); return (width, height); } internal static List SetShape(RenderContext context, List lines, (int Width, int Height) shape) { foreach (var line in lines) { var length = CellCount(context, line); var missing = shape.Width - length; if (missing > 0) { line.Add(new Segment(new string(' ', missing))); } } if (lines.Count < shape.Height) { var missing = shape.Height - lines.Count; for (int i = 0; i < missing; i++) { var line = new SegmentLine(); line.Add(new Segment(new string(' ', shape.Width))); lines.Add(line); } } return lines; } } }