namespace Terminal.Gui; /// /// Provides text formatting. Supports s, horizontal alignment, vertical alignment, /// multiple lines, and word-based line wrap. /// public class TextFormatter { private bool _autoSize; private Key _hotKey = new (); private int _hotKeyPos = -1; private List _lines = new (); private bool _multiLine; private bool _preserveTrailingSpaces; private Size _size; private int _tabWidth = 4; private string _text; private TextAlignment _textAlignment; private TextDirection _textDirection; private VerticalTextAlignment _textVerticalAlignment; private bool _wordWrap = true; /// Controls the horizontal text-alignment property. /// The text alignment. public TextAlignment Alignment { get => _textAlignment; set => _textAlignment = EnableNeedsFormat (value); } /// Gets or sets whether the should be automatically changed to fit the . /// /// Used by to resize the view's to fit . /// /// and /// are ignored when is . /// /// public bool AutoSize { get => _autoSize; set { _autoSize = EnableNeedsFormat (value); if (_autoSize) { Size = GetAutoSize (); } } } private Size GetAutoSize () { if (string.IsNullOrEmpty(_text)) { return Size.Empty; } int width = int.MaxValue; int height = int.MaxValue; string text = _text; List lines; if (FindHotKey (_text, HotKeySpecifier, out _hotKeyPos, out Key newHotKey)) { HotKey = newHotKey; text = RemoveHotKeySpecifier (Text, _hotKeyPos, HotKeySpecifier); text = ReplaceHotKeyWithTag (text, _hotKeyPos); } if (IsVerticalDirection (Direction)) { int colsWidth = GetSumMaxCharWidth (text, 0, 1, TabWidth); lines = Format ( text, height, VerticalAlignment == VerticalTextAlignment.Justified, width > colsWidth && WordWrap, PreserveTrailingSpaces, TabWidth, Direction, MultiLine ); colsWidth = GetMaxColsForWidth (lines, width, TabWidth); if (lines.Count > colsWidth) { lines.RemoveRange (colsWidth, lines.Count - colsWidth); } height = lines.Max (static line => line.GetColumns ()); width = lines.Count; } else { lines = Format ( text, width, false, // Ignore justification because autosize means no justification height > 1 && WordWrap, PreserveTrailingSpaces, TabWidth, Direction, MultiLine ); // Format always returns at least 1 line if (lines.Count == 1 && string.IsNullOrEmpty (lines [0])) { return Size.Empty; } width = lines.Max (static line => line.GetColumns ()); height = lines.Count; } return new (width, height); } /// /// Gets the cursor position of the . If the is defined, the cursor will /// be positioned over it. /// public int CursorPosition { get; internal set; } /// Controls the text-direction property. /// The text vertical alignment. public TextDirection Direction { get => _textDirection; set { _textDirection = EnableNeedsFormat (value); if (AutoSize) { Size = GetAutoSize (); } } } /// /// Determines if the viewport width will be used or only the text width will be used, /// If all the viewport area will be filled with whitespaces and the same background color /// showing a perfect rectangle. /// public bool FillRemaining { get; set; } /// Gets or sets the hot key. Fires the event. public Key HotKey { get => _hotKey; internal set { if (_hotKey != value) { Key oldKey = _hotKey; _hotKey = value; HotKeyChanged?.Invoke (this, new KeyChangedEventArgs (oldKey, value)); } } } /// The position in the text of the hot key. The hot key will be rendered using the hot color. public int HotKeyPos { get => _hotKeyPos; internal set => _hotKeyPos = value; } /// /// The specifier character for the hot key (e.g. '_'). Set to '\xffff' to disable hot key support for this View /// instance. The default is '\xffff'. /// public Rune HotKeySpecifier { get; set; } = (Rune)0xFFFF; /// Gets or sets a value indicating whether multi line is allowed. /// Multi line is ignored if is . public bool MultiLine { get => _multiLine; set => _multiLine = EnableNeedsFormat (value); } /// Gets or sets whether the needs to format the text. /// /// If when Draw is called, the Draw call will be faster. /// Used by /// Set to when any of the properties of are set. /// Set to when the text is formatted (if is accessed). /// public bool NeedsFormat { get; set; } /// /// Gets or sets whether trailing spaces at the end of word-wrapped lines are preserved or not when /// is enabled. If trailing spaces at the end of wrapped /// lines will be removed when is formatted for display. The default is . /// public bool PreserveTrailingSpaces { get => _preserveTrailingSpaces; set => _preserveTrailingSpaces = EnableNeedsFormat (value); } /// Gets or sets the size will be constrained to when formatted. /// /// /// Does not return the size of the formatted text but the size that will be used to constrain the text when /// formatted. /// /// When set, is set to . /// public Size Size { get => _size; set { if (AutoSize)// && Alignment != TextAlignment.Justified && VerticalAlignment != VerticalTextAlignment.Justified) { //_size = EnableNeedsFormat (CalcRect (0, 0, Text, Direction, TabWidth).Size); _size = EnableNeedsFormat (value); } else { _size = EnableNeedsFormat (value); } } } /// Gets or sets the number of columns used for a tab. public int TabWidth { get => _tabWidth; set => _tabWidth = EnableNeedsFormat (value); } /// The text to be formatted. This string is never modified. public virtual string Text { get => _text; set { bool textWasNull = _text is null && value != null; _text = EnableNeedsFormat (value); // BUGBUG: If AutoSize is false, there should be no "automatic behavior" like setting the size if (AutoSize || (textWasNull && Size.IsEmpty)) { Size = GetAutoSize (); } } } /// Controls the vertical text-alignment property. /// The text vertical alignment. public VerticalTextAlignment VerticalAlignment { get => _textVerticalAlignment; set => _textVerticalAlignment = EnableNeedsFormat (value); } /// Gets or sets whether word wrap will be used to fit to . public bool WordWrap { get => _wordWrap; set => _wordWrap = EnableNeedsFormat (value); } /// Draws the text held by to using the colors specified. /// /// Causes the text to be formatted (references ). Sets to /// false. /// /// Specifies the screen-relative location and maximum size for drawing the text. /// The color to use for all text except the hotkey /// The color to use to draw the hotkey /// Specifies the screen-relative location and maximum container size. /// The console driver currently used by the application. /// public void Draw ( Rectangle screen, Attribute normalColor, Attribute hotColor, Rectangle maximum = default, ConsoleDriver driver = null ) { // With this check, we protect against subclasses with overrides of Text (like Button) if (string.IsNullOrEmpty (Text)) { return; } driver ??= Application.Driver; driver?.SetAttribute (normalColor); List linesFormatted = GetLines (); switch (Direction) { case TextDirection.TopBottom_RightLeft: case TextDirection.LeftRight_BottomTop: case TextDirection.RightLeft_BottomTop: case TextDirection.BottomTop_RightLeft: linesFormatted.Reverse (); break; } bool isVertical = IsVerticalDirection (Direction); Rectangle maxScreen = screen; if (driver is { }) { // INTENT: What, exactly, is the intent of this? maxScreen = maximum == default (Rectangle) ? screen : new ( Math.Max (maximum.X, screen.X), Math.Max (maximum.Y, screen.Y), Math.Max ( Math.Min (maximum.Width, maximum.Right - screen.Left), 0 ), Math.Max ( Math.Min ( maximum.Height, maximum.Bottom - screen.Top ), 0 ) ); } if (maxScreen.Width == 0 || maxScreen.Height == 0) { return; } int lineOffset = !isVertical && screen.Y < 0 ? Math.Abs (screen.Y) : 0; for (int line = lineOffset; line < linesFormatted.Count; line++) { if ((isVertical && line > screen.Width) || (!isVertical && line > screen.Height)) { continue; } if ((isVertical && line >= maxScreen.Left + maxScreen.Width) || (!isVertical && line >= maxScreen.Top + maxScreen.Height + lineOffset)) { break; } Rune [] runes = linesFormatted [line].ToRunes (); runes = Direction switch { TextDirection.RightLeft_BottomTop => runes.Reverse ().ToArray (), TextDirection.RightLeft_TopBottom => runes.Reverse ().ToArray (), TextDirection.BottomTop_LeftRight => runes.Reverse ().ToArray (), TextDirection.BottomTop_RightLeft => runes.Reverse ().ToArray (), _ => runes }; // When text is justified, we lost left or right, so we use the direction to align. int x, y; // Horizontal Alignment if (Alignment == TextAlignment.Right || (Alignment == TextAlignment.Justified && !IsLeftToRight (Direction))) { if (isVertical) { int runesWidth = GetWidestLineLength (linesFormatted, 0, line, TabWidth); x = screen.Right - runesWidth; CursorPosition = screen.Width - runesWidth + (_hotKeyPos > -1 ? _hotKeyPos : 0); } else { int runesWidth = StringExtensions.ToString (runes).GetColumns (); x = screen.Right - runesWidth; CursorPosition = screen.Width - runesWidth + (_hotKeyPos > -1 ? _hotKeyPos : 0); } } else if (Alignment is TextAlignment.Left or TextAlignment.Justified) { if (isVertical) { int runesWidth = line > 0 ? GetWidestLineLength (linesFormatted, 0, line, TabWidth) : 0; x = screen.Left + runesWidth; } else { x = screen.Left; } CursorPosition = _hotKeyPos > -1 ? _hotKeyPos : 0; } else if (Alignment == TextAlignment.Centered) { if (isVertical) { int runesWidth = GetWidestLineLength (linesFormatted, 0, line, TabWidth); x = screen.Left + line + (screen.Width - runesWidth) / 2; CursorPosition = (screen.Width - runesWidth) / 2 + (_hotKeyPos > -1 ? _hotKeyPos : 0); } else { int runesWidth = StringExtensions.ToString (runes).GetColumns (); x = screen.Left + (screen.Width - runesWidth) / 2; CursorPosition = (screen.Width - runesWidth) / 2 + (_hotKeyPos > -1 ? _hotKeyPos : 0); } } else { throw new ArgumentOutOfRangeException ($"{nameof (Alignment)}"); } // Vertical Alignment if (VerticalAlignment == VerticalTextAlignment.Bottom || (VerticalAlignment == VerticalTextAlignment.Justified && !IsTopToBottom (Direction))) { if (isVertical) { y = screen.Bottom - runes.Length; } else { y = screen.Bottom - linesFormatted.Count + line; } } else if (VerticalAlignment is VerticalTextAlignment.Top or VerticalTextAlignment.Justified) { if (isVertical) { y = screen.Top; } else { y = screen.Top + line; } } else if (VerticalAlignment == VerticalTextAlignment.Middle) { if (isVertical) { int s = (screen.Height - runes.Length) / 2; y = screen.Top + s; } else { int s = (screen.Height - linesFormatted.Count) / 2; y = screen.Top + line + s; } } else { throw new ArgumentOutOfRangeException ($"{nameof (VerticalAlignment)}"); } int colOffset = screen.X < 0 ? Math.Abs (screen.X) : 0; int start = isVertical ? screen.Top : screen.Left; int size = isVertical ? screen.Height : screen.Width; int current = start + colOffset; List lastZeroWidthPos = null; Rune rune = default; int zeroLengthCount = isVertical ? runes.Sum (r => r.GetColumns () == 0 ? 1 : 0) : 0; for (int idx = (isVertical ? start - y : start - x) + colOffset; current < start + size + zeroLengthCount; idx++) { Rune lastRuneUsed = rune; if (lastZeroWidthPos is null) { if (idx < 0 || x + current + colOffset < 0) { current++; continue; } if (!FillRemaining && idx > runes.Length - 1) { break; } if ((!isVertical && current - start > maxScreen.Left + maxScreen.Width - screen.X + colOffset) || (isVertical && idx > maxScreen.Top + maxScreen.Height - screen.Y)) { break; } } //if ((!isVertical && idx > maxBounds.Left + maxBounds.Width - viewport.X + colOffset) // || (isVertical && idx > maxBounds.Top + maxBounds.Height - viewport.Y)) // break; rune = (Rune)' '; if (isVertical) { if (idx >= 0 && idx < runes.Length) { rune = runes [idx]; } if (lastZeroWidthPos is null) { driver?.Move (x, current); } else { int foundIdx = lastZeroWidthPos.IndexOf ( p => p is { } && p.Value.Y == current ); if (foundIdx > -1) { if (rune.IsCombiningMark ()) { lastZeroWidthPos [foundIdx] = new Point ( lastZeroWidthPos [foundIdx].Value.X + 1, current ); driver?.Move ( lastZeroWidthPos [foundIdx].Value.X, current ); } else if (!rune.IsCombiningMark () && lastRuneUsed.IsCombiningMark ()) { current++; driver?.Move (x, current); } else { driver?.Move (x, current); } } else { driver?.Move (x, current); } } } else { driver?.Move (current, y); if (idx >= 0 && idx < runes.Length) { rune = runes [idx]; } } int runeWidth = GetRuneWidth (rune, TabWidth); if (HotKeyPos > -1 && idx == HotKeyPos) { if ((isVertical && VerticalAlignment == VerticalTextAlignment.Justified) || (!isVertical && Alignment == TextAlignment.Justified)) { CursorPosition = idx - start; } driver?.SetAttribute (hotColor); driver?.AddRune (rune); driver?.SetAttribute (normalColor); } else { if (isVertical) { if (runeWidth == 0) { if (lastZeroWidthPos is null) { lastZeroWidthPos = new List (); } int foundIdx = lastZeroWidthPos.IndexOf ( p => p is { } && p.Value.Y == current ); if (foundIdx == -1) { current--; lastZeroWidthPos.Add (new Point (x + 1, current)); } driver?.Move (x + 1, current); } } driver?.AddRune (rune); } if (isVertical) { if (runeWidth > 0) { current++; } } else { current += runeWidth; } int nextRuneWidth = idx + 1 > -1 && idx + 1 < runes.Length ? runes [idx + 1].GetColumns () : 0; if (!isVertical && idx + 1 < runes.Length && current + nextRuneWidth > start + size) { break; } } } } /// Returns the formatted text, constrained to . /// /// If is , causes a format, resetting /// to . /// /// The formatted text. public string Format () { var sb = new StringBuilder (); // Lines_get causes a Format foreach (string line in GetLines ()) { sb.AppendLine (line); } return sb.ToString ().TrimEnd (Environment.NewLine.ToCharArray ()); } /// Gets the size required to hold the formatted text, given the constraints placed by . /// Causes a format, resetting to . /// The size required to hold the formatted text. public Size FormatAndGetSize () { if (string.IsNullOrEmpty (Text) || Size.Height == 0 || Size.Width == 0) { return Size.Empty; } int width = GetLines ().Max (static line => line.GetColumns ()); int height = GetLines ().Count; return new (width, height); } /// Gets a list of formatted lines, constrained to . /// /// /// If the text needs to be formatted (if is ) /// will be called and upon return /// will be . /// /// /// If either of the dimensions of are zero, the text will not be formatted and no lines will /// be returned. /// /// public List GetLines () { // With this check, we protect against subclasses with overrides of Text if (string.IsNullOrEmpty (Text) || Size.Height == 0 || Size.Width == 0) { _lines = new List { string.Empty }; NeedsFormat = false; return _lines; } if (NeedsFormat) { string text = _text; if (FindHotKey (_text, HotKeySpecifier, out _hotKeyPos, out Key newHotKey)) { HotKey = newHotKey; text = RemoveHotKeySpecifier (Text, _hotKeyPos, HotKeySpecifier); text = ReplaceHotKeyWithTag (text, _hotKeyPos); } if (IsVerticalDirection (Direction)) { int colsWidth = GetSumMaxCharWidth (text, 0, 1, TabWidth); _lines = Format ( text, Size.Height, VerticalAlignment == VerticalTextAlignment.Justified, Size.Width > colsWidth && WordWrap, PreserveTrailingSpaces, TabWidth, Direction, MultiLine ); if (!AutoSize) { colsWidth = GetMaxColsForWidth (_lines, Size.Width, TabWidth); if (_lines.Count > colsWidth) { _lines.RemoveRange (colsWidth, _lines.Count - colsWidth); } } } else { _lines = Format ( text, Size.Width, Alignment == TextAlignment.Justified, Size.Height > 1 && WordWrap, PreserveTrailingSpaces, TabWidth, Direction, MultiLine ); if (!AutoSize && _lines.Count > Size.Height) { _lines.RemoveRange (Size.Height, _lines.Count - Size.Height); } } NeedsFormat = false; } return _lines; } /// Event invoked when the is changed. public event EventHandler HotKeyChanged; /// Sets to and returns the value. /// /// /// private T EnableNeedsFormat (T value) { NeedsFormat = true; return value; } #region Static Members /// Check if it is a horizontal direction public static bool IsHorizontalDirection (TextDirection textDirection) { return textDirection switch { TextDirection.LeftRight_TopBottom => true, TextDirection.LeftRight_BottomTop => true, TextDirection.RightLeft_TopBottom => true, TextDirection.RightLeft_BottomTop => true, _ => false }; } /// Check if it is a vertical direction public static bool IsVerticalDirection (TextDirection textDirection) { return textDirection switch { TextDirection.TopBottom_LeftRight => true, TextDirection.TopBottom_RightLeft => true, TextDirection.BottomTop_LeftRight => true, TextDirection.BottomTop_RightLeft => true, _ => false }; } /// Check if it is Left to Right direction public static bool IsLeftToRight (TextDirection textDirection) { return textDirection switch { TextDirection.LeftRight_TopBottom => true, TextDirection.LeftRight_BottomTop => true, _ => false }; } /// Check if it is Top to Bottom direction public static bool IsTopToBottom (TextDirection textDirection) { return textDirection switch { TextDirection.TopBottom_LeftRight => true, TextDirection.TopBottom_RightLeft => true, _ => false }; } // TODO: Move to StringExtensions? private static string StripCRLF (string str, bool keepNewLine = false) { List runes = str.ToRuneList (); for (var i = 0; i < runes.Count; i++) { switch ((char)runes [i].Value) { case '\n': if (!keepNewLine) { runes.RemoveAt (i); } break; case '\r': if (i + 1 < runes.Count && runes [i + 1].Value == '\n') { runes.RemoveAt (i); if (!keepNewLine) { runes.RemoveAt (i); } i++; } else { if (!keepNewLine) { runes.RemoveAt (i); } } break; } } return StringExtensions.ToString (runes); } // TODO: Move to StringExtensions? private static string ReplaceCRLFWithSpace (string str) { List runes = str.ToRuneList (); for (var i = 0; i < runes.Count; i++) { switch (runes [i].Value) { case '\n': runes [i] = (Rune)' '; break; case '\r': if (i + 1 < runes.Count && runes [i + 1].Value == '\n') { runes [i] = (Rune)' '; runes.RemoveAt (i + 1); i++; } else { runes [i] = (Rune)' '; } break; } } return StringExtensions.ToString (runes); } // TODO: Move to StringExtensions? private static string ReplaceTABWithSpaces (string str, int tabWidth) { if (tabWidth == 0) { return str.Replace ("\t", ""); } return str.Replace ("\t", new string (' ', tabWidth)); } // TODO: Move to StringExtensions? /// /// Splits all newlines in the into a list and supports both CRLF and LF, preserving the /// ending newline. /// /// The text. /// A list of text without the newline characters. public static List SplitNewLine (string text) { List runes = text.ToRuneList (); List lines = new (); var start = 0; for (var i = 0; i < runes.Count; i++) { int end = i; switch (runes [i].Value) { case '\n': lines.Add (StringExtensions.ToString (runes.GetRange (start, end - start))); i++; start = i; break; case '\r': if (i + 1 < runes.Count && runes [i + 1].Value == '\n') { lines.Add (StringExtensions.ToString (runes.GetRange (start, end - start))); i += 2; start = i; } else { lines.Add (StringExtensions.ToString (runes.GetRange (start, end - start))); i++; start = i; } break; } } switch (runes.Count) { case > 0 when lines.Count == 0: lines.Add (StringExtensions.ToString (runes)); break; case > 0 when start < runes.Count: lines.Add (StringExtensions.ToString (runes.GetRange (start, runes.Count - start))); break; default: lines.Add (""); break; } return lines; } // TODO: Move to StringExtensions? /// /// Adds trailing whitespace or truncates so that it fits exactly /// columns. Note that some unicode characters take 2+ columns /// /// /// /// public static string ClipOrPad (string text, int width) { if (string.IsNullOrEmpty (text)) { return text; } // if value is not wide enough if (text.EnumerateRunes ().Sum (c => c.GetColumns ()) < width) { // pad it out with spaces to the given alignment int toPad = width - text.EnumerateRunes ().Sum (c => c.GetColumns ()); return text + new string (' ', toPad); } // value is too wide return new string (text.TakeWhile (c => (width -= ((Rune)c).GetColumns ()) >= 0).ToArray ()); } /// Formats the provided text to fit within the width provided using word wrapping. /// The text to word wrap /// The number of columns to constrain the text to /// /// If trailing spaces at the end of wrapped lines will be /// preserved. If , trailing spaces at the end of wrapped lines will be trimmed. /// /// The number of columns used for a tab. /// The text direction. /// A list of word wrapped lines. /// /// This method does not do any justification. /// This method strips Newline ('\n' and '\r\n') sequences before processing. /// /// If is at most one space will be preserved /// at the end of the last line. /// /// /// A list of lines. public static List WordWrapText ( string text, int width, bool preserveTrailingSpaces = false, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom ) { if (width < 0) { throw new ArgumentOutOfRangeException ($"{nameof (width)} cannot be negative."); } int start = 0, end; List lines = new (); if (string.IsNullOrEmpty (text)) { return lines; } List runes = StripCRLF (text).ToRuneList (); if (preserveTrailingSpaces) { while ((end = start) < runes.Count) { end = GetNextWhiteSpace (start, width, out bool incomplete); if (end == 0 && incomplete) { start = text.GetRuneCount (); break; } lines.Add (StringExtensions.ToString (runes.GetRange (start, end - start))); start = end; if (incomplete) { start = text.GetRuneCount (); break; } } } else { if (IsHorizontalDirection (textDirection)) { while ((end = start + GetLengthThatFits ( runes.GetRange (start, runes.Count - start), width, tabWidth )) < runes.Count) { while (runes [end].Value != ' ' && end > start) { end--; } if (end == start) { end = start + GetLengthThatFits ( runes.GetRange (end, runes.Count - end), width, tabWidth ); } var str = StringExtensions.ToString (runes.GetRange (start, end - start)); if (end > start && GetRuneWidth (str, tabWidth) <= width) { lines.Add (str); start = end; if (runes [end].Value == ' ') { start++; } } else { end++; start = end; } } } else { while ((end = start + width) < runes.Count) { while (runes [end].Value != ' ' && end > start) { end--; } if (end == start) { end = start + width; } var zeroLength = 0; for (int i = end; i < runes.Count - start; i++) { Rune r = runes [i]; if (r.GetColumns () == 0) { zeroLength++; } else { break; } } lines.Add ( StringExtensions.ToString ( runes.GetRange ( start, end - start + zeroLength ) ) ); end += zeroLength; start = end; if (runes [end].Value == ' ') { start++; } } } } int GetNextWhiteSpace (int from, int cWidth, out bool incomplete, int cLength = 0) { int to = from; int length = cLength; incomplete = false; while (length < cWidth && to < runes.Count) { Rune rune = runes [to]; if (IsHorizontalDirection (textDirection)) { length += rune.GetColumns (); } else { length++; } if (length > cWidth) { if (to >= runes.Count || (length > 1 && cWidth <= 1)) { incomplete = true; } return to; } switch (rune.Value) { case ' ' when length == cWidth: return to + 1; case ' ' when length > cWidth: return to; case ' ': return GetNextWhiteSpace (to + 1, cWidth, out incomplete, length); case '\t': { length += tabWidth + 1; if (length == tabWidth && tabWidth > cWidth) { return to + 1; } if (length > cWidth && tabWidth > cWidth) { return to; } return GetNextWhiteSpace (to + 1, cWidth, out incomplete, length); } default: to++; break; } } return cLength switch { > 0 when to < runes.Count && runes [to].Value != ' ' && runes [to].Value != '\t' => from, > 0 when to < runes.Count && (runes [to].Value == ' ' || runes [to].Value == '\t') => from, _ => to }; } if (start < text.GetRuneCount ()) { string str = ReplaceTABWithSpaces ( StringExtensions.ToString (runes.GetRange (start, runes.Count - start)), tabWidth ); if (IsVerticalDirection (textDirection) || preserveTrailingSpaces || str.GetColumns () <= width) { lines.Add (str); } } return lines; } /// Justifies text within a specified width. /// The text to justify. /// /// The number of columns to clip the text to. Text longer than will be /// clipped. /// /// Alignment. /// The text direction. /// The number of columns used for a tab. /// Justified and clipped text. public static string ClipAndJustify ( string text, int width, TextAlignment talign, TextDirection textDirection = TextDirection.LeftRight_TopBottom, int tabWidth = 0 ) { return ClipAndJustify (text, width, talign == TextAlignment.Justified, textDirection, tabWidth); } /// Justifies text within a specified width. /// The text to justify. /// /// The number of columns to clip the text to. Text longer than will be /// clipped. /// /// Justify. /// The text direction. /// The number of columns used for a tab. /// Justified and clipped text. public static string ClipAndJustify ( string text, int width, bool justify, TextDirection textDirection = TextDirection.LeftRight_TopBottom, int tabWidth = 0 ) { if (width < 0) { throw new ArgumentOutOfRangeException ($"{nameof (width)} cannot be negative."); } if (string.IsNullOrEmpty (text)) { return text; } text = ReplaceTABWithSpaces (text, tabWidth); List runes = text.ToRuneList (); if (runes.Count > width) { if (IsHorizontalDirection (textDirection)) { return StringExtensions.ToString ( runes.GetRange ( 0, GetLengthThatFits (text, width, tabWidth) ) ); } int zeroLength = runes.Sum (r => r.GetColumns () == 0 ? 1 : 0); return StringExtensions.ToString (runes.GetRange (0, width + zeroLength)); } if (justify) { return Justify (text, width, ' ', textDirection, tabWidth); } if (IsHorizontalDirection (textDirection) && GetRuneWidth (text, tabWidth) > width) { return StringExtensions.ToString ( runes.GetRange ( 0, GetLengthThatFits (text, width, tabWidth) ) ); } return text; } /// /// Justifies the text to fill the width provided. Space will be added between words to make the text just fit /// width. Spaces will not be added to the start or end. /// /// /// /// Character to replace whitespace and pad with. For debugging purposes. /// The text direction. /// The number of columns used for a tab. /// The justified text. public static string Justify ( string text, int width, char spaceChar = ' ', TextDirection textDirection = TextDirection.LeftRight_TopBottom, int tabWidth = 0 ) { if (width < 0) { throw new ArgumentOutOfRangeException ($"{nameof (width)} cannot be negative."); } if (string.IsNullOrEmpty (text)) { return text; } text = ReplaceTABWithSpaces (text, tabWidth); string [] words = text.Split (' '); int textCount; if (IsHorizontalDirection (textDirection)) { textCount = words.Sum (arg => GetRuneWidth (arg, tabWidth)); } else { textCount = words.Sum (arg => arg.GetRuneCount ()); } int spaces = words.Length > 1 ? (width - textCount) / (words.Length - 1) : 0; int extras = words.Length > 1 ? (width - textCount) % (words.Length - 1) : 0; var s = new StringBuilder (); for (var w = 0; w < words.Length; w++) { string x = words [w]; s.Append (x); if (w + 1 < words.Length) { for (var i = 0; i < spaces; i++) { s.Append (spaceChar); } } if (extras > 0) { for (var i = 0; i < 1; i++) { s.Append (spaceChar); } extras--; } if (w + 1 == words.Length - 1) { for (var i = 0; i < extras; i++) { s.Append (spaceChar); } } } return s.ToString (); } /// Formats text into lines, applying text alignment and optionally wrapping text to new lines on word boundaries. /// /// The number of columns to constrain the text to for word wrapping and clipping. /// Specifies how the text will be aligned horizontally. /// /// If , the text will be wrapped to new lines no longer than /// . If , forces text to fit a single line. Line breaks are converted /// to spaces. The text will be clipped to . /// /// /// If trailing spaces at the end of wrapped lines will be /// preserved. If , trailing spaces at the end of wrapped lines will be trimmed. /// /// The number of columns used for a tab. /// The text direction. /// If new lines are allowed. /// A list of word wrapped lines. /// /// An empty string will result in one empty line. /// If is 0, a single, empty line will be returned. /// If is int.MaxValue, the text will be formatted to the maximum width possible. /// public static List Format ( string text, int width, TextAlignment talign, bool wordWrap, bool preserveTrailingSpaces = false, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom, bool multiLine = false ) { return Format ( text, width, talign == TextAlignment.Justified, wordWrap, preserveTrailingSpaces, tabWidth, textDirection, multiLine ); } /// Formats text into lines, applying text alignment and optionally wrapping text to new lines on word boundaries. /// /// The number of columns to constrain the text to for word wrapping and clipping. /// Specifies whether the text should be justified. /// /// If , the text will be wrapped to new lines no longer than /// . If , forces text to fit a single line. Line breaks are converted /// to spaces. The text will be clipped to . /// /// /// If trailing spaces at the end of wrapped lines will be /// preserved. If , trailing spaces at the end of wrapped lines will be trimmed. /// /// The number of columns used for a tab. /// The text direction. /// If new lines are allowed. /// A list of word wrapped lines. /// /// An empty string will result in one empty line. /// If is 0, a single, empty line will be returned. /// If is int.MaxValue, the text will be formatted to the maximum width possible. /// public static List Format ( string text, int width, bool justify, bool wordWrap, bool preserveTrailingSpaces = false, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom, bool multiLine = false ) { if (width < 0) { throw new ArgumentOutOfRangeException ($"{nameof (width)} cannot be negative."); } List lineResult = new (); if (string.IsNullOrEmpty (text) || width == 0) { lineResult.Add (string.Empty); return lineResult; } if (!wordWrap) { text = ReplaceTABWithSpaces (text, tabWidth); if (multiLine) { // Abhorrent case: Just a new line if (text == "\n") { lineResult.Add (string.Empty); return lineResult; } string [] lines = null; if (text.Contains ("\r\n")) { lines = text.Split ("\r\n"); } else if (text.Contains ('\n')) { lines = text.Split ('\n'); } lines ??= new [] { text }; foreach (string line in lines) { lineResult.Add (ClipAndJustify (line, width, justify, textDirection, tabWidth)); } return lineResult; } text = ReplaceCRLFWithSpace (text); lineResult.Add (ClipAndJustify (text, width, justify, textDirection, tabWidth)); return lineResult; } List runes = StripCRLF (text, true).ToRuneList (); int runeCount = runes.Count; var lp = 0; for (var i = 0; i < runeCount; i++) { Rune c = runes [i]; if (c.Value == '\n') { List wrappedLines = WordWrapText ( StringExtensions.ToString (runes.GetRange (lp, i - lp)), width, preserveTrailingSpaces, tabWidth, textDirection ); foreach (string line in wrappedLines) { lineResult.Add (ClipAndJustify (line, width, justify, textDirection, tabWidth)); } if (wrappedLines.Count == 0) { lineResult.Add (string.Empty); } lp = i + 1; } } foreach (string line in WordWrapText ( StringExtensions.ToString (runes.GetRange (lp, runeCount - lp)), width, preserveTrailingSpaces, tabWidth, textDirection )) { lineResult.Add (ClipAndJustify (line, width, justify, textDirection, tabWidth)); } return lineResult; } /// Returns the number of lines needed to render the specified text given the width. /// Calls . /// Number of lines. /// Text, may contain newlines. /// The minimum width for the text. public static int GetLineCount (string text, int width) { List result = Format (text, width, false, true); return result.Count; } /// /// Returns the maximum number of columns needed to render the text (single line or multiple lines, word wrapped) /// given a number of columns to constrain the text to. /// /// /// Calls . This API will return incorrect results if the text includes glyphs who's width /// is dependent on surrounding glyphs (e.g. Arabic). /// /// Width of the longest line after formatting the text constrained by . /// Text, may contain newlines. /// The number of columns to constrain the text to for formatting. /// The number of columns used for a tab. public static int GetWidestLineLength (string text, int maxColumns, int tabWidth = 0) { List result = Format (text, maxColumns, false, true); var max = 0; result.ForEach ( s => { var m = 0; s.ToRuneList ().ForEach (r => m += GetRuneWidth (r, tabWidth)); if (m > max) { max = m; } } ); return max; } /// /// Returns the number of columns in the widest line in the text, without word wrap, accounting for wide-glyphs /// (uses ). if it contains newlines. /// /// /// This API will return incorrect results if the text includes glyphs who's width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// Text, may contain newlines. /// The number of columns used for a tab. /// The length of the longest line. public static int GetWidestLineLength (string text, int tabWidth = 0) { List result = SplitNewLine (text); return result.Max (x => GetRuneWidth (x, tabWidth)); } /// /// Returns the number of columns in the widest line in the list based on the and /// the . /// /// /// This API will return incorrect results if the text includes glyphs who's width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The lines. /// The start index. /// The length. /// The number of columns used for a tab. /// The maximum characters width. public static int GetWidestLineLength ( List lines, int startIndex = -1, int length = -1, int tabWidth = 0 ) { var max = 0; for (int i = startIndex == -1 ? 0 : startIndex; i < (length == -1 ? lines.Count : startIndex + length); i++) { string runes = lines [i]; if (runes.Length > 0) { max += runes.EnumerateRunes ().Max (r => GetRuneWidth (r, tabWidth)); } } return max; } /// /// Gets the maximum number of columns from the text based on the and the /// . /// /// /// This API will return incorrect results if the text includes glyphs who's width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The text. /// The start index. /// The length. /// The number of columns used for a tab. /// The maximum characters width. public static int GetSumMaxCharWidth (string text, int startIndex = -1, int length = -1, int tabWidth = 0) { var max = 0; Rune [] runes = text.ToRunes (); for (int i = startIndex == -1 ? 0 : startIndex; i < (length == -1 ? runes.Length : startIndex + length); i++) { max += GetRuneWidth (runes [i], tabWidth); } return max; } /// Gets the number of the Runes in the text that will fit in . /// /// This API will return incorrect results if the text includes glyphs who's width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The text. /// The width. /// The number of columns used for a tab. /// The index of the text that fit the width. public static int GetLengthThatFits (string text, int columns, int tabWidth = 0) { return GetLengthThatFits (text?.ToRuneList (), columns, tabWidth); } /// Gets the number of the Runes in a list of Runes that will fit in . /// /// This API will return incorrect results if the text includes glyphs who's width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The list of runes. /// The width. /// The number of columns used for a tab. /// The index of the last Rune in that fit in . public static int GetLengthThatFits (List runes, int columns, int tabWidth = 0) { if (runes is null || runes.Count == 0) { return 0; } var runesLength = 0; var runeIdx = 0; for (; runeIdx < runes.Count; runeIdx++) { int runeWidth = GetRuneWidth (runes [runeIdx], tabWidth); if (runesLength + runeWidth > columns) { break; } runesLength += runeWidth; } return runeIdx; } private static int GetRuneWidth (string str, int tabWidth) { return GetRuneWidth (str.EnumerateRunes ().ToList (), tabWidth); } private static int GetRuneWidth (List runes, int tabWidth) { return runes.Sum (r => GetRuneWidth (r, tabWidth)); } private static int GetRuneWidth (Rune rune, int tabWidth) { int runeWidth = rune.GetColumns (); if (rune.Value == '\t') { return tabWidth; } if (runeWidth < 0 || runeWidth > 0) { return Math.Max (runeWidth, 1); } return runeWidth; } /// Gets the index position from the list based on the . /// /// This API will return incorrect results if the text includes glyphs who's width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The lines. /// The width. /// The number of columns used for a tab. /// The index of the list that fit the width. public static int GetMaxColsForWidth (List lines, int width, int tabWidth = 0) { var runesLength = 0; var lineIdx = 0; for (; lineIdx < lines.Count; lineIdx++) { List runes = lines [lineIdx].ToRuneList (); int maxRruneWidth = runes.Count > 0 ? runes.Max (r => GetRuneWidth (r, tabWidth)) : 1; if (runesLength + maxRruneWidth > width) { break; } runesLength += maxRruneWidth; } return lineIdx; } /// Calculates the rectangle required to hold text, assuming no word wrapping, justification, or hotkeys. /// /// This API will return incorrect results if the text includes glyphs who's width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The x location of the rectangle /// The y location of the rectangle /// The text to measure /// The text direction. /// The number of columns used for a tab. /// public static Rectangle CalcRect ( int x, int y, string text, TextDirection direction = TextDirection.LeftRight_TopBottom, int tabWidth = 0 ) { if (string.IsNullOrEmpty (text)) { return new (new (x, y), Size.Empty); } int w, h; if (IsHorizontalDirection (direction)) { var mw = 0; var ml = 1; var cols = 0; foreach (Rune rune in text.EnumerateRunes ()) { if (rune.Value == '\n') { ml++; if (cols > mw) { mw = cols; } cols = 0; } else if (rune.Value != '\r') { cols++; var rw = 0; if (rune.Value == '\t') { rw += tabWidth - 1; } else { rw = rune.GetColumns (); if (rw > 0) { rw--; } else if (rw == 0) { cols--; } } cols += rw; } } if (cols > mw) { mw = cols; } w = mw; h = ml; } else { int vw = 1, cw = 1; var vh = 0; var rows = 0; foreach (Rune rune in text.EnumerateRunes ()) { if (rune.Value == '\n') { vw++; if (rows > vh) { vh = rows; } rows = 0; cw = 1; } else if (rune.Value != '\r') { rows++; var rw = 0; if (rune.Value == '\t') { rw += tabWidth - 1; rows += rw; } else { rw = rune.GetColumns (); if (rw == 0) { rows--; } else if (cw < rw) { cw = rw; vw++; } } } } if (rows > vh) { vh = rows; } w = vw; h = vh; } return new (x, y, w, h); } /// Finds the HotKey and its location in text. /// The text to look in. /// The HotKey specifier (e.g. '_') to look for. /// Outputs the Rune index into text. /// Outputs the hotKey. if not found. /// /// If true the legacy behavior of identifying the first upper case character as the /// HotKey will be enabled. Regardless of the value of this parameter, hotKeySpecifier takes precedence. /// Defaults to . /// /// true if a HotKey was found; false otherwise. public static bool FindHotKey ( string text, Rune hotKeySpecifier, out int hotPos, out Key hotKey, bool firstUpperCase = false ) { if (string.IsNullOrEmpty (text) || hotKeySpecifier == (Rune)0xFFFF) { hotPos = -1; hotKey = Key.Empty; return false; } var curHotKey = (Rune)0; int curHotPos = -1; // Use first hot_key char passed into 'hotKey'. // TODO: Ignore hot_key of two are provided // TODO: Do not support non-alphanumeric chars that can't be typed var i = 0; foreach (Rune c in text.EnumerateRunes ()) { if ((char)c.Value != 0xFFFD) { if (c == hotKeySpecifier) { curHotPos = i; } else if (curHotPos > -1) { curHotKey = c; break; } } i++; } // Legacy support - use first upper case char if the specifier was not found if (curHotPos == -1 && firstUpperCase) { i = 0; foreach (Rune c in text.EnumerateRunes ()) { if ((char)c.Value != 0xFFFD) { if (Rune.IsUpper (c)) { curHotKey = c; curHotPos = i; break; } } i++; } } if (curHotKey != (Rune)0 && curHotPos != -1) { hotPos = curHotPos; var newHotKey = (KeyCode)curHotKey.Value; if (newHotKey != KeyCode.Null && !(newHotKey == KeyCode.Space || Rune.IsControl (curHotKey))) { if ((newHotKey & ~KeyCode.Space) is >= KeyCode.A and <= KeyCode.Z) { newHotKey &= ~KeyCode.Space; } hotKey = newHotKey; //hotKey.Scope = KeyBindingScope.HotKey; return true; } } hotPos = -1; hotKey = KeyCode.Null; return false; } /// /// Replaces the Rune at the index specified by the hotPos parameter with a tag identifying it as the /// hotkey. /// /// The text to tag the hotkey in. /// The Rune index of the hotkey in text. /// The text with the hotkey tagged. /// The returned string will not render correctly without first un-doing the tag. To undo the tag, search for public static string ReplaceHotKeyWithTag (string text, int hotPos) { // Set the high bit List runes = text.ToRuneList (); if (Rune.IsLetterOrDigit (runes [hotPos])) { runes [hotPos] = new Rune ((uint)runes [hotPos].Value); } return StringExtensions.ToString (runes); } /// Removes the hotkey specifier from text. /// The text to manipulate. /// The hot-key specifier (e.g. '_') to look for. /// Returns the position of the hot-key in the text. -1 if not found. /// The input text with the hotkey specifier ('_') removed. public static string RemoveHotKeySpecifier (string text, int hotPos, Rune hotKeySpecifier) { if (string.IsNullOrEmpty (text)) { return text; } // Scan var start = string.Empty; var i = 0; foreach (Rune c in text) { if (c == hotKeySpecifier && i == hotPos) { i++; continue; } start += c; i++; } return start; } #endregion // Static Members }