#nullable enable using System.Diagnostics; namespace Terminal.Gui; /// /// Provides text formatting. Supports s, horizontal and vertical alignment, text direction, /// multiple lines, and word-based line wrap. /// public class TextFormatter { private Key _hotKey = new (); private int _hotKeyPos = -1; private List _lines = new (); private bool _multiLine; private bool _preserveTrailingSpaces; private int _tabWidth = 4; private string? _text; private Alignment _textAlignment = Alignment.Start; private TextDirection _textDirection; private Alignment _textVerticalAlignment = Alignment.Start; private bool _wordWrap = true; /// Get or sets the horizontal text alignment. /// The text alignment. public Alignment Alignment { get => _textAlignment; set => _textAlignment = EnableNeedsFormat (value); } /// /// Gets the cursor position of the . If the is defined, the cursor will /// be positioned over it. /// public int CursorPosition { get; internal set; } /// Gets or sets the text-direction. /// The text direction. public TextDirection Direction { get => _textDirection; set => _textDirection = 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, IConsoleDriver? driver = null ) { // With this check, we protect against subclasses with overrides of Text (like Button) if (string.IsNullOrEmpty (Text)) { return; } if (driver is null) { driver = Application.Driver; } driver?.SetAttribute (normalColor); List linesFormatted = GetLines (); 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 (); // When text is justified, we lost left or right, so we use the direction to align. int x = 0, y = 0; // Horizontal Alignment if (Alignment is Alignment.End) { if (isVertical) { int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, line, linesFormatted.Count - 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 Alignment.Start) { if (isVertical) { int runesWidth = line > 0 ? GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth) : 0; x = screen.Left + runesWidth; } else { x = screen.Left; } CursorPosition = _hotKeyPos > -1 ? _hotKeyPos : 0; } else if (Alignment is Alignment.Fill) { if (isVertical) { int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth); int prevLineWidth = line > 0 ? GetColumnsRequiredForVerticalText (linesFormatted, line - 1, 1, TabWidth) : 0; int firstLineWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, 1, TabWidth); int lastLineWidth = GetColumnsRequiredForVerticalText (linesFormatted, linesFormatted.Count - 1, 1, TabWidth); var interval = (int)Math.Round ((double)(screen.Width + firstLineWidth + lastLineWidth) / linesFormatted.Count); x = line == 0 ? screen.Left : line < linesFormatted.Count - 1 ? screen.Width - runesWidth <= lastLineWidth ? screen.Left + prevLineWidth : screen.Left + line * interval : screen.Right - lastLineWidth; } else { x = screen.Left; } CursorPosition = _hotKeyPos > -1 ? _hotKeyPos : 0; } else if (Alignment is Alignment.Center) { if (isVertical) { int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth); int linesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth); x = screen.Left + linesWidth + (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 { Debug.WriteLine ($"Unsupported Alignment: {nameof (VerticalAlignment)}"); return; } // Vertical Alignment if (VerticalAlignment is Alignment.End) { if (isVertical) { y = screen.Bottom - runes.Length; } else { y = screen.Bottom - linesFormatted.Count + line; } } else if (VerticalAlignment is Alignment.Start) { if (isVertical) { y = screen.Top; } else { y = screen.Top + line; } } else if (VerticalAlignment is Alignment.Fill) { if (isVertical) { y = screen.Top; } else { var interval = (int)Math.Round ((double)(screen.Height + 2) / linesFormatted.Count); y = line == 0 ? screen.Top : line < linesFormatted.Count - 1 ? screen.Height - interval <= 1 ? screen.Top + 1 : screen.Top + line * interval : screen.Bottom - 1; } } else if (VerticalAlignment is Alignment.Center) { 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 { Debug.WriteLine ($"Unsupported Alignment: {nameof (VerticalAlignment)}"); return; } 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 || (isVertical ? VerticalAlignment != Alignment.End && current < 0 : Alignment != Alignment.End && x + current + colOffset < 0)) { current++; continue; } if (!FillRemaining && idx > runes.Length - 1) { break; } if ((!isVertical && (current - start > maxScreen.Left + maxScreen.Width - screen.X + colOffset || (idx < runes.Length && runes [idx].GetColumns () > screen.Width))) || (isVertical && ((current > start + size + zeroLengthCount && idx > maxScreen.Top + maxScreen.Height - screen.Y) || (idx < runes.Length && runes [idx].GetColumns () > screen.Width)))) { 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 == Alignment.Fill) || (!isVertical && Alignment == Alignment.Fill)) { CursorPosition = idx - start; } driver?.SetAttribute (hotColor); driver?.AddRune (rune); driver?.SetAttribute (normalColor); } else { if (isVertical) { if (runeWidth == 0) { if (lastZeroWidthPos is null) { lastZeroWidthPos = new (); } 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; } } } } /// /// 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; } /// 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 . /// /// If provided, will cause the text to be constrained to the provided size instead of and /// . /// /// The size required to hold the formatted text. public Size FormatAndGetSize (Size? constrainSize = null) { if (string.IsNullOrEmpty (Text)) { return System.Drawing.Size.Empty; } int? prevWidth = _constrainToWidth; int? prevHeight = _constrainToHeight; if (constrainSize is { }) { _constrainToWidth = constrainSize?.Width; _constrainToHeight = constrainSize?.Height; } // HACK: Fill normally will fill the entire constraint size, but we need to know the actual size of the text. Alignment prevAlignment = Alignment; if (Alignment == Alignment.Fill) { Alignment = Alignment.Start; } Alignment prevVerticalAlignment = VerticalAlignment; if (VerticalAlignment == Alignment.Fill) { VerticalAlignment = Alignment.Start; } // This calls Format List lines = GetLines (); // Undo hacks Alignment = prevAlignment; VerticalAlignment = prevVerticalAlignment; if (constrainSize is { }) { _constrainToWidth = prevWidth ?? null; _constrainToHeight = prevHeight ?? null; } if (lines.Count == 0) { return System.Drawing.Size.Empty; } int width; int height; if (IsVerticalDirection (Direction)) { width = GetColumnsRequiredForVerticalText (lines, 0, lines.Count, TabWidth); height = lines.Max (static line => line.Length); } else { width = lines.Max (static line => line.GetColumns ()); height = lines.Count; } return new (width, height); } /// /// Gets the width or height of the characters /// in the property. /// /// /// Only the first HotKey specifier found in is supported. /// /// /// If (the default) the width required for the HotKey specifier is returned. Otherwise, the /// height is returned. /// /// /// The number of characters required for the . If the text /// direction specified /// by does not match the parameter, 0 is returned. /// public int GetHotKeySpecifierLength (bool isWidth = true) { if (isWidth) { return IsHorizontalDirection (Direction) && Text?.Contains ((char)HotKeySpecifier.Value) == true ? Math.Max (HotKeySpecifier.GetColumns (), 0) : 0; } return IsVerticalDirection (Direction) && Text?.Contains ((char)HotKeySpecifier.Value) == true ? Math.Max (HotKeySpecifier.GetColumns (), 0) : 0; } /// 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 () { string text = _text!.ReplaceLineEndings (); // With this check, we protect against subclasses with overrides of Text if (string.IsNullOrEmpty (Text) || ConstrainToWidth is 0 || ConstrainToHeight is 0) { _lines = [string.Empty]; NeedsFormat = false; return _lines; } if (!NeedsFormat) { return _lines; } int width = ConstrainToWidth ?? int.MaxValue; int height = ConstrainToHeight ?? int.MaxValue; 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 == Alignment.Fill, width > colsWidth && WordWrap, PreserveTrailingSpaces, TabWidth, Direction, MultiLine, this ); colsWidth = GetMaxColsForWidth (_lines, width, TabWidth); if (_lines.Count > colsWidth) { _lines.RemoveRange (colsWidth, _lines.Count - colsWidth); } } else { _lines = Format ( text, width, Alignment == Alignment.Fill, height > 1 && WordWrap, PreserveTrailingSpaces, TabWidth, Direction, MultiLine, this ); if (_lines.Count > height) { _lines.RemoveRange (height, _lines.Count - height); } } NeedsFormat = false; return _lines; } private int? _constrainToWidth; /// Gets or sets the width will be constrained to when formatted. /// /// /// Does not return the width of the formatted text but the width that will be used to constrain the text when /// formatted. /// /// /// If the height will be unconstrained. if both and are the text will be formatted to the size of the text. /// /// /// Use to get the size of the formatted text. /// /// When set, is set to . /// public int? ConstrainToWidth { get => _constrainToWidth; set { if (_constrainToWidth == value) { return; } ArgumentOutOfRangeException.ThrowIfNegative (value.GetValueOrDefault (), nameof (ConstrainToWidth)); _constrainToWidth = EnableNeedsFormat (value); } } private int? _constrainToHeight; /// Gets or sets the height will be constrained to when formatted. /// /// /// Does not return the height of the formatted text but the height that will be used to constrain the text when /// formatted. /// /// /// If the height will be unconstrained. if both and are the text will be formatted to the size of the text. /// /// /// Use to get the size of the formatted text. /// /// When set, is set to . /// public int? ConstrainToHeight { get => _constrainToHeight; set { if (_constrainToHeight == value) { return; } ArgumentOutOfRangeException.ThrowIfNegative (value.GetValueOrDefault (), nameof (ConstrainToHeight)); _constrainToHeight = EnableNeedsFormat (value); } } /// Gets or sets the width and height 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. /// /// /// If both the width and height will be unconstrained and text will be formatted to the size of the text. /// /// /// Setting this property is the same as setting and separately. /// /// /// Use to get the size of the formatted text. /// /// When set, is set to . /// public Size? ConstrainToSize { get { if (_constrainToWidth is null || _constrainToHeight is null) { return null; } return new Size (_constrainToWidth.Value, _constrainToHeight.Value); } set { if (value is null) { _constrainToWidth = null; _constrainToHeight = null; EnableNeedsFormat (true); } else { _constrainToWidth = EnableNeedsFormat (value.Value.Width); _constrainToHeight = EnableNeedsFormat (value.Value.Height); } } } /// 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 (oldKey, value)); } } } /// Event invoked when the is changed. public event EventHandler? HotKeyChanged; /// 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 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 string Text { get => _text!; set => _text = EnableNeedsFormat (value); } /// Gets or sets the vertical text-alignment. /// The text vertical alignment. public Alignment 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); } /// Sets to and returns the value. /// /// /// private T EnableNeedsFormat (T value) { NeedsFormat = true; return value; } /// /// Calculates and returns a describing the areas where text would be output, based on the /// formatting rules of . /// /// /// Uses the same formatting logic as , including alignment, direction, word wrap, and constraints, /// but does not perform actual drawing to . /// /// Specifies the screen-relative location and maximum size for drawing the text. /// Specifies the screen-relative location and maximum container size. /// A representing the areas where text would be drawn. public Region GetDrawRegion (Rectangle screen, Rectangle maximum = default) { Region drawnRegion = new Region (); // With this check, we protect against subclasses with overrides of Text (like Button) if (string.IsNullOrEmpty (Text)) { return drawnRegion; } List linesFormatted = GetLines (); bool isVertical = IsVerticalDirection (Direction); Rectangle maxScreen = screen; // 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 drawnRegion; } 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 (); // When text is justified, we lost left or right, so we use the direction to align. int x = 0, y = 0; switch (Alignment) { // Horizontal Alignment case Alignment.End when isVertical: { int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, line, linesFormatted.Count - line, TabWidth); x = screen.Right - runesWidth; break; } case Alignment.End: { int runesWidth = StringExtensions.ToString (runes).GetColumns (); x = screen.Right - runesWidth; break; } case Alignment.Start when isVertical: { int runesWidth = line > 0 ? GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth) : 0; x = screen.Left + runesWidth; break; } case Alignment.Start: x = screen.Left; break; case Alignment.Fill when isVertical: { int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth); int prevLineWidth = line > 0 ? GetColumnsRequiredForVerticalText (linesFormatted, line - 1, 1, TabWidth) : 0; int firstLineWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, 1, TabWidth); int lastLineWidth = GetColumnsRequiredForVerticalText (linesFormatted, linesFormatted.Count - 1, 1, TabWidth); var interval = (int)Math.Round ((double)(screen.Width + firstLineWidth + lastLineWidth) / linesFormatted.Count); x = line == 0 ? screen.Left : line < linesFormatted.Count - 1 ? screen.Width - runesWidth <= lastLineWidth ? screen.Left + prevLineWidth : screen.Left + line * interval : screen.Right - lastLineWidth; break; } case Alignment.Fill: x = screen.Left; break; case Alignment.Center when isVertical: { int runesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, linesFormatted.Count, TabWidth); int linesWidth = GetColumnsRequiredForVerticalText (linesFormatted, 0, line, TabWidth); x = screen.Left + linesWidth + (screen.Width - runesWidth) / 2; break; } case Alignment.Center: { int runesWidth = StringExtensions.ToString (runes).GetColumns (); x = screen.Left + (screen.Width - runesWidth) / 2; break; } default: Debug.WriteLine ($"Unsupported Alignment: {nameof (VerticalAlignment)}"); return drawnRegion; } switch (VerticalAlignment) { // Vertical Alignment case Alignment.End when isVertical: y = screen.Bottom - runes.Length; break; case Alignment.End: y = screen.Bottom - linesFormatted.Count + line; break; case Alignment.Start when isVertical: y = screen.Top; break; case Alignment.Start: y = screen.Top + line; break; case Alignment.Fill when isVertical: y = screen.Top; break; case Alignment.Fill: { var interval = (int)Math.Round ((double)(screen.Height + 2) / linesFormatted.Count); y = line == 0 ? screen.Top : line < linesFormatted.Count - 1 ? screen.Height - interval <= 1 ? screen.Top + 1 : screen.Top + line * interval : screen.Bottom - 1; break; } case Alignment.Center when isVertical: { int s = (screen.Height - runes.Length) / 2; y = screen.Top + s; break; } case Alignment.Center: { int s = (screen.Height - linesFormatted.Count) / 2; y = screen.Top + line + s; break; } default: Debug.WriteLine ($"Unsupported Alignment: {nameof (VerticalAlignment)}"); return drawnRegion; } 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; int zeroLengthCount = isVertical ? runes.Sum (r => r.GetColumns () == 0 ? 1 : 0) : 0; int lineX = x, lineY = y, lineWidth = 0, lineHeight = 1; for (int idx = (isVertical ? start - y : start - x) + colOffset; current < start + size + zeroLengthCount; idx++) { if (idx < 0 || (isVertical ? VerticalAlignment != Alignment.End && current < 0 : Alignment != Alignment.End && x + current + colOffset < 0)) { current++; continue; } if (!FillRemaining && idx > runes.Length - 1) { break; } if ((!isVertical && (current - start > maxScreen.Left + maxScreen.Width - screen.X + colOffset || (idx < runes.Length && runes [idx].GetColumns () > screen.Width))) || (isVertical && ((current > start + size + zeroLengthCount && idx > maxScreen.Top + maxScreen.Height - screen.Y) || (idx < runes.Length && runes [idx].GetColumns () > screen.Width)))) { break; } Rune rune = idx >= 0 && idx < runes.Length ? runes [idx] : (Rune)' '; int runeWidth = GetRuneWidth (rune, TabWidth); if (isVertical) { if (runeWidth > 0) { // Update line height for vertical text (each rune is a column) lineHeight = Math.Max (lineHeight, current - y + 1); lineWidth = Math.Max (lineWidth, 1); // Width is 1 per rune in vertical } } else { // Update line width and position for horizontal text lineWidth += runeWidth; } current += isVertical && runeWidth > 0 ? 1 : 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; } } // Add the line's drawn region to the overall region if (lineWidth > 0 && lineHeight > 0) { drawnRegion.Union (new Rectangle (lineX, lineY, lineWidth, lineHeight)); } } return drawnRegion; } #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 (' ', 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 (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. /// instance to access any of his objects. /// A list of word wrapped lines. /// /// This method does not do any alignment. /// 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, TextFormatter? textFormatter = null ) { ArgumentOutOfRangeException.ThrowIfNegative (width, nameof (width)); List lines = new (); if (string.IsNullOrEmpty (text)) { return lines; } List runes = StripCRLF (text).ToRuneList (); int start = Math.Max ( !runes.Contains ((Rune)' ') && textFormatter is { VerticalAlignment: Alignment.End } && IsVerticalDirection (textDirection) ? runes.Count - width : 0, 0); int end; 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, textDirection )) < runes.Count) { while (runes [end].Value != ' ' && end > start) { end--; } if (end == start) { end = start + GetLengthThatFits ( runes.GetRange (end, runes.Count - end), width, tabWidth, textDirection ); } var str = StringExtensions.ToString (runes.GetRange (start, end - start)); int zeroLength = text.EnumerateRunes ().Sum (r => r.GetColumns () == 0 ? 1 : 0); if (end > start && GetRuneWidth (str, tabWidth, textDirection) <= width + zeroLength) { 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. /// instance to access any of his objects. /// Justified and clipped text. public static string ClipAndJustify ( string text, int width, Alignment textAlignment, TextDirection textDirection = TextDirection.LeftRight_TopBottom, int tabWidth = 0, TextFormatter? textFormatter = null ) { return ClipAndJustify (text, width, textAlignment == Alignment.Fill, textDirection, tabWidth, textFormatter); } /// 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. /// instance to access any of his objects. /// Justified and clipped text. public static string ClipAndJustify ( string text, int width, bool justify, TextDirection textDirection = TextDirection.LeftRight_TopBottom, int tabWidth = 0, TextFormatter? textFormatter = null ) { ArgumentOutOfRangeException.ThrowIfNegative (width, nameof (width)); if (string.IsNullOrEmpty (text)) { return text; } text = ReplaceTABWithSpaces (text, tabWidth); List runes = text.ToRuneList (); int zeroLength = runes.Sum (r => r.GetColumns () == 0 ? 1 : 0); if (runes.Count - zeroLength > width) { if (IsHorizontalDirection (textDirection)) { if (textFormatter is { Alignment: Alignment.End }) { return GetRangeThatFits (runes, runes.Count - width, text, width, tabWidth, textDirection); } if (textFormatter is { Alignment: Alignment.Center }) { return GetRangeThatFits (runes, Math.Max ((runes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection); } return GetRangeThatFits (runes, 0, text, width, tabWidth, textDirection); } if (IsVerticalDirection (textDirection)) { if (textFormatter is { VerticalAlignment: Alignment.End }) { return GetRangeThatFits (runes, runes.Count - width, text, width, tabWidth, textDirection); } if (textFormatter is { VerticalAlignment: Alignment.Center }) { return GetRangeThatFits (runes, Math.Max ((runes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection); } return GetRangeThatFits (runes, 0, text, width, tabWidth, textDirection); } return StringExtensions.ToString (runes.GetRange (0, width + zeroLength)); } if (justify) { return Justify (text, width, ' ', textDirection, tabWidth); } if (IsHorizontalDirection (textDirection)) { if (textFormatter is { Alignment: Alignment.End }) { if (GetRuneWidth (text, tabWidth, textDirection) > width) { return GetRangeThatFits (runes, runes.Count - width, text, width, tabWidth, textDirection); } } else if (textFormatter is { Alignment: Alignment.Center }) { return GetRangeThatFits (runes, Math.Max ((runes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection); } else if (GetRuneWidth (text, tabWidth, textDirection) > width) { return GetRangeThatFits (runes, 0, text, width, tabWidth, textDirection); } } if (IsVerticalDirection (textDirection)) { if (textFormatter is { VerticalAlignment: Alignment.End }) { if (runes.Count - zeroLength > width) { return GetRangeThatFits (runes, runes.Count - width, text, width, tabWidth, textDirection); } } else if (textFormatter is { VerticalAlignment: Alignment.Center }) { return GetRangeThatFits (runes, Math.Max ((runes.Count - width - zeroLength) / 2, 0), text, width, tabWidth, textDirection); } else if (runes.Count - zeroLength > width) { return GetRangeThatFits (runes, 0, text, width, tabWidth, textDirection); } } return text; } private static string GetRangeThatFits (List runes, int index, string text, int width, int tabWidth, TextDirection textDirection) { return StringExtensions.ToString ( runes.GetRange ( Math.Max (index, 0), GetLengthThatFits (text, width, tabWidth, textDirection) ) ); } /// /// 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 ) { ArgumentOutOfRangeException.ThrowIfNegative (width, nameof (width)); 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, textDirection)); } else { textCount = words.Sum (arg => arg.GetRuneCount ()) - text.EnumerateRunes ().Sum (r => r.GetColumns () == 0 ? 1 : 0); } 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. /// instance to access any of his objects. /// 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, Alignment textAlignment, bool wordWrap, bool preserveTrailingSpaces = false, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom, bool multiLine = false, TextFormatter? textFormatter = null ) { return Format ( text, width, textAlignment == Alignment.Fill, wordWrap, preserveTrailingSpaces, tabWidth, textDirection, multiLine, textFormatter ); } /// 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. /// instance to access any of his objects. /// 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, TextFormatter? textFormatter = null ) { ArgumentOutOfRangeException.ThrowIfNegative (width, nameof (width)); 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 ( PerformCorrectFormatDirection (textDirection, line), width, justify, textDirection, tabWidth, textFormatter)); } return PerformCorrectFormatDirection (textDirection, lineResult); } text = ReplaceCRLFWithSpace (text); lineResult.Add (ClipAndJustify (PerformCorrectFormatDirection (textDirection, text), width, justify, textDirection, tabWidth, textFormatter)); return PerformCorrectFormatDirection (textDirection, 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 (PerformCorrectFormatDirection (textDirection, runes.GetRange (lp, i - lp))), width, preserveTrailingSpaces, tabWidth, textDirection, textFormatter ); 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 (PerformCorrectFormatDirection (textDirection, runes.GetRange (lp, runeCount - lp))), width, preserveTrailingSpaces, tabWidth, textDirection, textFormatter )) { lineResult.Add (ClipAndJustify (line, width, justify, textDirection, tabWidth)); } return PerformCorrectFormatDirection (textDirection, lineResult); } private static string PerformCorrectFormatDirection (TextDirection textDirection, string line) { return textDirection switch { TextDirection.RightLeft_BottomTop or TextDirection.RightLeft_TopBottom or TextDirection.BottomTop_LeftRight or TextDirection.BottomTop_RightLeft => StringExtensions.ToString (line.EnumerateRunes ().Reverse ()), _ => line }; } private static List PerformCorrectFormatDirection (TextDirection textDirection, List runes) { return PerformCorrectFormatDirection (textDirection, StringExtensions.ToString (runes)).ToRuneList (); } private static List PerformCorrectFormatDirection (TextDirection textDirection, List lines) { return textDirection switch { TextDirection.TopBottom_RightLeft or TextDirection.LeftRight_BottomTop or TextDirection.RightLeft_BottomTop or TextDirection.BottomTop_RightLeft => lines.ToArray ().Reverse ().ToList (), _ => lines }; } /// /// Returns the number of columns required to render oriented vertically. /// /// /// This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The lines. /// The line in the list to start with (any lines before will be ignored). /// /// The number of lines to process (if less than lines.Count, any lines after will be /// ignored). /// /// The number of columns used for a tab. /// The width required. public static int GetColumnsRequiredForVerticalText ( List lines, int startLine = -1, int linesCount = -1, int tabWidth = 0 ) { var max = 0; for (int i = startLine == -1 ? 0 : startLine; i < (linesCount == -1 ? lines.Count : startLine + linesCount); i++) { string runes = lines [i]; if (runes.Length > 0) { max += runes.EnumerateRunes ().Max (r => GetRuneWidth (r, tabWidth)); } } 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 whose 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)); } /// /// 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 whose 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 whose width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The text. /// The width. /// The width used for a tab. /// The text direction. /// The index of the text that fit the width. public static int GetLengthThatFits (string text, int width, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom) { return GetLengthThatFits (text?.ToRuneList () ?? [], width, tabWidth, textDirection); } /// 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 whose width is dependent on surrounding /// glyphs (e.g. Arabic). /// /// The list of runes. /// The width. /// The width used for a tab. /// The text direction. /// The index of the last Rune in that fit in . public static int GetLengthThatFits (List runes, int width, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom) { 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, textDirection); if (runesLength + runeWidth > width) { break; } runesLength += runeWidth; } return runeIdx; } private static int GetRuneWidth (string str, int tabWidth, TextDirection textDirection = TextDirection.LeftRight_TopBottom) { int runesWidth = 0; foreach (Rune rune in str.EnumerateRunes ()) { runesWidth += GetRuneWidth (rune, tabWidth, textDirection); } return runesWidth; } private static int GetRuneWidth (Rune rune, int tabWidth, TextDirection textDirection = TextDirection.LeftRight_TopBottom) { int runeWidth = IsHorizontalDirection (textDirection) ? rune.GetColumns () : rune.GetColumns () == 0 ? 0 : 1; 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 whose 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; } /// 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 ((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.EnumerateRunes ()) { if (c == hotKeySpecifier && i == hotPos) { i++; continue; } start += c; i++; } return start; } #endregion // Static Members }