mirror of
https://github.com/gui-cs/Terminal.Gui.git
synced 2025-12-27 00:07:58 +01:00
2142 lines
77 KiB
C#
2142 lines
77 KiB
C#
#nullable enable
|
|
using System.Diagnostics;
|
|
|
|
namespace Terminal.Gui;
|
|
|
|
/// <summary>
|
|
/// Provides text formatting. Supports <see cref="View.HotKey"/>s, horizontal and vertical alignment, text direction,
|
|
/// multiple lines, and word-based line wrap.
|
|
/// </summary>
|
|
public class TextFormatter
|
|
{
|
|
private Key _hotKey = new ();
|
|
private int _hotKeyPos = -1;
|
|
private List<string> _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;
|
|
|
|
/// <summary>Get or sets the horizontal text alignment.</summary>
|
|
/// <value>The text alignment.</value>
|
|
public Alignment Alignment
|
|
{
|
|
get => _textAlignment;
|
|
set => _textAlignment = EnableNeedsFormat (value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the cursor position of the <see cref="HotKey"/>. If the <see cref="HotKey"/> is defined, the cursor will
|
|
/// be positioned over it.
|
|
/// </summary>
|
|
public int CursorPosition { get; internal set; }
|
|
|
|
/// <summary>Gets or sets the text-direction.</summary>
|
|
/// <value>The text direction.</value>
|
|
public TextDirection Direction
|
|
{
|
|
get => _textDirection;
|
|
set => _textDirection = EnableNeedsFormat (value);
|
|
}
|
|
|
|
/// <summary>Draws the text held by <see cref="TextFormatter"/> to <see cref="ConsoleDriver"/> using the colors specified.</summary>
|
|
/// <remarks>
|
|
/// Causes the text to be formatted (references <see cref="GetLines"/>). Sets <see cref="NeedsFormat"/> to
|
|
/// <c>false</c>.
|
|
/// </remarks>
|
|
/// <param name="screen">Specifies the screen-relative location and maximum size for drawing the text.</param>
|
|
/// <param name="normalColor">The color to use for all text except the hotkey</param>
|
|
/// <param name="hotColor">The color to use to draw the hotkey</param>
|
|
/// <param name="maximum">Specifies the screen-relative location and maximum container size.</param>
|
|
/// <param name="driver">The console driver currently used by the application.</param>
|
|
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
|
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<string> 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<Point?> 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines if the viewport width will be used or only the text width will be used,
|
|
/// If <see langword="true"/> all the viewport area will be filled with whitespaces and the same background color
|
|
/// showing a perfect rectangle.
|
|
/// </summary>
|
|
public bool FillRemaining { get; set; }
|
|
|
|
/// <summary>Returns the formatted text, constrained to <see cref="ConstrainToSize"/>.</summary>
|
|
/// <remarks>
|
|
/// If <see cref="NeedsFormat"/> is <see langword="true"/>, causes a format, resetting <see cref="NeedsFormat"/>
|
|
/// to <see langword="false"/>.
|
|
/// </remarks>
|
|
/// <returns>The formatted text.</returns>
|
|
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 ());
|
|
}
|
|
|
|
/// <summary>Gets the size required to hold the formatted text, given the constraints placed by <see cref="ConstrainToSize"/>.</summary>
|
|
/// <remarks>Causes a format, resetting <see cref="NeedsFormat"/> to <see langword="false"/>.</remarks>
|
|
/// <param name="constrainSize">
|
|
/// If provided, will cause the text to be constrained to the provided size instead of <see cref="ConstrainToWidth"/> and
|
|
/// <see cref="ConstrainToHeight"/>.
|
|
/// </param>
|
|
/// <returns>The size required to hold the formatted text.</returns>
|
|
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<string> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the width or height of the <see cref="TextFormatter.HotKeySpecifier"/> characters
|
|
/// in the <see cref="Text"/> property.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Only the first HotKey specifier found in <see cref="Text"/> is supported.
|
|
/// </remarks>
|
|
/// <param name="isWidth">
|
|
/// If <see langword="true"/> (the default) the width required for the HotKey specifier is returned. Otherwise, the
|
|
/// height is returned.
|
|
/// </param>
|
|
/// <returns>
|
|
/// The number of characters required for the <see cref="TextFormatter.HotKeySpecifier"/>. If the text
|
|
/// direction specified
|
|
/// by <see cref="TextDirection"/> does not match the <paramref name="isWidth"/> parameter, <c>0</c> is returned.
|
|
/// </returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Gets a list of formatted lines, constrained to <see cref="ConstrainToSize"/>.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// If the text needs to be formatted (if <see cref="NeedsFormat"/> is <see langword="true"/>)
|
|
/// <see cref="Format()"/> will be called and upon return
|
|
/// <see cref="NeedsFormat"/> will be <see langword="false"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// If either of the dimensions of <see cref="ConstrainToSize"/> are zero, the text will not be formatted and no lines will
|
|
/// be returned.
|
|
/// </para>
|
|
/// </remarks>
|
|
public List<string> 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;
|
|
|
|
/// <summary>Gets or sets the width <see cref="Text"/> will be constrained to when formatted.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Does not return the width of the formatted text but the width that will be used to constrain the text when
|
|
/// formatted.
|
|
/// </para>
|
|
/// <para>
|
|
/// If <see langword="null"/> the height will be unconstrained. if both <see cref="ConstrainToWidth"/> and <see cref="ConstrainToHeight"/> are <see langword="null"/> the text will be formatted to the size of the text.
|
|
/// </para>
|
|
/// <para>
|
|
/// Use <see cref="FormatAndGetSize"/> to get the size of the formatted text.
|
|
/// </para>
|
|
/// <para>When set, <see cref="NeedsFormat"/> is set to <see langword="true"/>.</para>
|
|
/// </remarks>
|
|
public int? ConstrainToWidth
|
|
{
|
|
get => _constrainToWidth;
|
|
set
|
|
{
|
|
if (_constrainToWidth == value)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ArgumentOutOfRangeException.ThrowIfNegative (value.GetValueOrDefault (), nameof (ConstrainToWidth));
|
|
|
|
_constrainToWidth = EnableNeedsFormat (value);
|
|
}
|
|
}
|
|
|
|
private int? _constrainToHeight;
|
|
|
|
/// <summary>Gets or sets the height <see cref="Text"/> will be constrained to when formatted.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Does not return the height of the formatted text but the height that will be used to constrain the text when
|
|
/// formatted.
|
|
/// </para>
|
|
/// <para>
|
|
/// If <see langword="null"/> the height will be unconstrained. if both <see cref="ConstrainToWidth"/> and <see cref="ConstrainToHeight"/> are <see langword="null"/> the text will be formatted to the size of the text.
|
|
/// </para>
|
|
/// <para>
|
|
/// Use <see cref="FormatAndGetSize"/> to get the size of the formatted text.
|
|
/// </para>
|
|
/// <para>When set, <see cref="NeedsFormat"/> is set to <see langword="true"/>.</para>
|
|
/// </remarks>
|
|
|
|
public int? ConstrainToHeight
|
|
{
|
|
get => _constrainToHeight;
|
|
set
|
|
{
|
|
if (_constrainToHeight == value)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ArgumentOutOfRangeException.ThrowIfNegative (value.GetValueOrDefault (), nameof (ConstrainToHeight));
|
|
|
|
_constrainToHeight = EnableNeedsFormat (value);
|
|
}
|
|
}
|
|
|
|
/// <summary>Gets or sets the width and height <see cref="Text"/> will be constrained to when formatted.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Does not return the size of the formatted text but the size that will be used to constrain the text when
|
|
/// formatted.
|
|
/// </para>
|
|
/// <para>
|
|
/// If <see langword="null"/> both the width and height will be unconstrained and text will be formatted to the size of the text.
|
|
/// </para>
|
|
/// <para>
|
|
/// Setting this property is the same as setting <see cref="ConstrainToWidth"/> and <see cref="ConstrainToHeight"/> separately.
|
|
/// </para>
|
|
/// <para>
|
|
/// Use <see cref="FormatAndGetSize"/> to get the size of the formatted text.
|
|
/// </para>
|
|
/// <para>When set, <see cref="NeedsFormat"/> is set to <see langword="true"/>.</para>
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Gets or sets the hot key. Fires the <see cref="HotKeyChanged"/> event.</summary>
|
|
public Key HotKey
|
|
{
|
|
get => _hotKey;
|
|
internal set
|
|
{
|
|
if (_hotKey != value)
|
|
{
|
|
Key oldKey = _hotKey;
|
|
_hotKey = value;
|
|
HotKeyChanged?.Invoke (this, new (oldKey, value));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Event invoked when the <see cref="HotKey"/> is changed.</summary>
|
|
public event EventHandler<KeyChangedEventArgs>? HotKeyChanged;
|
|
|
|
/// <summary>The position in the text of the hot key. The hot key will be rendered using the hot color.</summary>
|
|
public int HotKeyPos
|
|
{
|
|
get => _hotKeyPos;
|
|
internal set => _hotKeyPos = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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'.
|
|
/// </summary>
|
|
public Rune HotKeySpecifier { get; set; } = (Rune)0xFFFF;
|
|
|
|
/// <summary>Gets or sets a value indicating whether multi line is allowed.</summary>
|
|
/// <remarks>Multi line is ignored if <see cref="WordWrap"/> is <see langword="true"/>.</remarks>
|
|
public bool MultiLine
|
|
{
|
|
get => _multiLine;
|
|
set => _multiLine = EnableNeedsFormat (value);
|
|
}
|
|
|
|
/// <summary>Gets or sets whether the <see cref="TextFormatter"/> needs to format the text.</summary>
|
|
/// <remarks>
|
|
/// <para>If <see langword="false"/> when Draw is called, the Draw call will be faster.</para>
|
|
/// <para>Used by <see cref="Draw"/></para>
|
|
/// <para>Set to <see langword="true"/> when any of the properties of <see cref="TextFormatter"/> are set.</para>
|
|
/// <para>Set to <see langword="false"/> when the text is formatted (if <see cref="GetLines"/> is accessed).</para>
|
|
/// </remarks>
|
|
public bool NeedsFormat { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets whether trailing spaces at the end of word-wrapped lines are preserved or not when
|
|
/// <see cref="TextFormatter.WordWrap"/> is enabled. If <see langword="true"/> trailing spaces at the end of wrapped
|
|
/// lines will be removed when <see cref="Text"/> is formatted for display. The default is <see langword="false"/>.
|
|
/// </summary>
|
|
public bool PreserveTrailingSpaces
|
|
{
|
|
get => _preserveTrailingSpaces;
|
|
set => _preserveTrailingSpaces = EnableNeedsFormat (value);
|
|
}
|
|
|
|
/// <summary>Gets or sets the number of columns used for a tab.</summary>
|
|
public int TabWidth
|
|
{
|
|
get => _tabWidth;
|
|
set => _tabWidth = EnableNeedsFormat (value);
|
|
}
|
|
|
|
/// <summary>The text to be formatted. This string is never modified.</summary>
|
|
public string Text
|
|
{
|
|
get => _text!;
|
|
set => _text = EnableNeedsFormat (value);
|
|
}
|
|
|
|
/// <summary>Gets or sets the vertical text-alignment.</summary>
|
|
/// <value>The text vertical alignment.</value>
|
|
public Alignment VerticalAlignment
|
|
{
|
|
get => _textVerticalAlignment;
|
|
set => _textVerticalAlignment = EnableNeedsFormat (value);
|
|
}
|
|
|
|
/// <summary>Gets or sets whether word wrap will be used to fit <see cref="Text"/> to <see cref="ConstrainToSize"/>.</summary>
|
|
public bool WordWrap
|
|
{
|
|
get => _wordWrap;
|
|
set => _wordWrap = EnableNeedsFormat (value);
|
|
}
|
|
|
|
/// <summary>Sets <see cref="NeedsFormat"/> to <see langword="true"/> and returns the value.</summary>
|
|
/// <typeparam name="T"></typeparam>
|
|
/// <param name="value"></param>
|
|
/// <returns></returns>
|
|
private T EnableNeedsFormat<T> (T value)
|
|
{
|
|
NeedsFormat = true;
|
|
|
|
return value;
|
|
}
|
|
|
|
#region Static Members
|
|
|
|
/// <summary>Check if it is a horizontal direction</summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>Check if it is a vertical direction</summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>Check if it is Left to Right direction</summary>
|
|
public static bool IsLeftToRight (TextDirection textDirection)
|
|
{
|
|
return textDirection switch
|
|
{
|
|
TextDirection.LeftRight_TopBottom => true,
|
|
TextDirection.LeftRight_BottomTop => true,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
/// <summary>Check if it is Top to Bottom direction</summary>
|
|
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<Rune> 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<Rune> 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?
|
|
/// <summary>
|
|
/// Splits all newlines in the <paramref name="text"/> into a list and supports both CRLF and LF, preserving the
|
|
/// ending newline.
|
|
/// </summary>
|
|
/// <param name="text">The text.</param>
|
|
/// <returns>A list of text without the newline characters.</returns>
|
|
public static List<string> SplitNewLine (string text)
|
|
{
|
|
List<Rune> runes = text.ToRuneList ();
|
|
List<string> 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?
|
|
/// <summary>
|
|
/// Adds trailing whitespace or truncates <paramref name="text"/> so that it fits exactly <paramref name="width"/>
|
|
/// columns. Note that some unicode characters take 2+ columns
|
|
/// </summary>
|
|
/// <param name="text"></param>
|
|
/// <param name="width"></param>
|
|
/// <returns></returns>
|
|
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 ());
|
|
}
|
|
|
|
/// <summary>Formats the provided text to fit within the width provided using word wrapping.</summary>
|
|
/// <param name="text">The text to word wrap</param>
|
|
/// <param name="width">The number of columns to constrain the text to</param>
|
|
/// <param name="preserveTrailingSpaces">
|
|
/// If <see langword="true"/> trailing spaces at the end of wrapped lines will be
|
|
/// preserved. If <see langword="false"/> , trailing spaces at the end of wrapped lines will be trimmed.
|
|
/// </param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <param name="textDirection">The text direction.</param>
|
|
/// <param name="textFormatter"><see cref="TextFormatter"/> instance to access any of his objects.</param>
|
|
/// <returns>A list of word wrapped lines.</returns>
|
|
/// <remarks>
|
|
/// <para>This method does not do any alignment.</para>
|
|
/// <para>This method strips Newline ('\n' and '\r\n') sequences before processing.</para>
|
|
/// <para>
|
|
/// If <paramref name="preserveTrailingSpaces"/> is <see langword="false"/> at most one space will be preserved
|
|
/// at the end of the last line.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <returns>A list of lines.</returns>
|
|
public static List<string> 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<string> lines = new ();
|
|
|
|
if (string.IsNullOrEmpty (text))
|
|
{
|
|
return lines;
|
|
}
|
|
|
|
List<Rune> 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;
|
|
}
|
|
|
|
/// <summary>Justifies text within a specified width.</summary>
|
|
/// <param name="text">The text to justify.</param>
|
|
/// <param name="width">
|
|
/// The number of columns to clip the text to. Text longer than <paramref name="width"/> will be
|
|
/// clipped.
|
|
/// </param>
|
|
/// <param name="textAlignment">Alignment.</param>
|
|
/// <param name="textDirection">The text direction.</param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <param name="textFormatter"><see cref="TextFormatter"/> instance to access any of his objects.</param>
|
|
/// <returns>Justified and clipped text.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Justifies text within a specified width.</summary>
|
|
/// <param name="text">The text to justify.</param>
|
|
/// <param name="width">
|
|
/// The number of columns to clip the text to. Text longer than <paramref name="width"/> will be
|
|
/// clipped.
|
|
/// </param>
|
|
/// <param name="justify">Justify.</param>
|
|
/// <param name="textDirection">The text direction.</param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <param name="textFormatter"><see cref="TextFormatter"/> instance to access any of his objects.</param>
|
|
/// <returns>Justified and clipped text.</returns>
|
|
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<Rune> 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) / 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) / 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) / 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) / 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<Rune> 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)
|
|
)
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Justifies the text to fill the width provided. Space will be added between words to make the text just fit
|
|
/// <c>width</c>. Spaces will not be added to the start or end.
|
|
/// </summary>
|
|
/// <param name="text"></param>
|
|
/// <param name="width"></param>
|
|
/// <param name="spaceChar">Character to replace whitespace and pad with. For debugging purposes.</param>
|
|
/// <param name="textDirection">The text direction.</param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <returns>The justified text.</returns>
|
|
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 ());
|
|
}
|
|
|
|
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 ();
|
|
}
|
|
|
|
/// <summary>Formats text into lines, applying text alignment and optionally wrapping text to new lines on word boundaries.</summary>
|
|
/// <param name="text"></param>
|
|
/// <param name="width">The number of columns to constrain the text to for word wrapping and clipping.</param>
|
|
/// <param name="textAlignment">Specifies how the text will be aligned horizontally.</param>
|
|
/// <param name="wordWrap">
|
|
/// If <see langword="true"/>, the text will be wrapped to new lines no longer than
|
|
/// <paramref name="width"/>. If <see langword="false"/>, forces text to fit a single line. Line breaks are converted
|
|
/// to spaces. The text will be clipped to <paramref name="width"/>.
|
|
/// </param>
|
|
/// <param name="preserveTrailingSpaces">
|
|
/// If <see langword="true"/> trailing spaces at the end of wrapped lines will be
|
|
/// preserved. If <see langword="false"/> , trailing spaces at the end of wrapped lines will be trimmed.
|
|
/// </param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <param name="textDirection">The text direction.</param>
|
|
/// <param name="multiLine">If <see langword="true"/> new lines are allowed.</param>
|
|
/// <param name="textFormatter"><see cref="TextFormatter"/> instance to access any of his objects.</param>
|
|
/// <returns>A list of word wrapped lines.</returns>
|
|
/// <remarks>
|
|
/// <para>An empty <paramref name="text"/> string will result in one empty line.</para>
|
|
/// <para>If <paramref name="width"/> is 0, a single, empty line will be returned.</para>
|
|
/// <para>If <paramref name="width"/> is int.MaxValue, the text will be formatted to the maximum width possible.</para>
|
|
/// </remarks>
|
|
public static List<string> 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
|
|
);
|
|
}
|
|
|
|
/// <summary>Formats text into lines, applying text alignment and optionally wrapping text to new lines on word boundaries.</summary>
|
|
/// <param name="text"></param>
|
|
/// <param name="width">The number of columns to constrain the text to for word wrapping and clipping.</param>
|
|
/// <param name="justify">Specifies whether the text should be justified.</param>
|
|
/// <param name="wordWrap">
|
|
/// If <see langword="true"/>, the text will be wrapped to new lines no longer than
|
|
/// <paramref name="width"/>. If <see langword="false"/>, forces text to fit a single line. Line breaks are converted
|
|
/// to spaces. The text will be clipped to <paramref name="width"/>.
|
|
/// </param>
|
|
/// <param name="preserveTrailingSpaces">
|
|
/// If <see langword="true"/> trailing spaces at the end of wrapped lines will be
|
|
/// preserved. If <see langword="false"/> , trailing spaces at the end of wrapped lines will be trimmed.
|
|
/// </param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <param name="textDirection">The text direction.</param>
|
|
/// <param name="multiLine">If <see langword="true"/> new lines are allowed.</param>
|
|
/// <param name="textFormatter"><see cref="TextFormatter"/> instance to access any of his objects.</param>
|
|
/// <returns>A list of word wrapped lines.</returns>
|
|
/// <remarks>
|
|
/// <para>An empty <paramref name="text"/> string will result in one empty line.</para>
|
|
/// <para>If <paramref name="width"/> is 0, a single, empty line will be returned.</para>
|
|
/// <para>If <paramref name="width"/> is int.MaxValue, the text will be formatted to the maximum width possible.</para>
|
|
/// </remarks>
|
|
public static List<string> 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<string> 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<Rune> 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<string> 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<Rune> PerformCorrectFormatDirection (TextDirection textDirection, List<Rune> runes)
|
|
{
|
|
return PerformCorrectFormatDirection (textDirection, StringExtensions.ToString (runes)).ToRuneList ();
|
|
}
|
|
|
|
private static List<string> PerformCorrectFormatDirection (TextDirection textDirection, List<string> lines)
|
|
{
|
|
return textDirection switch
|
|
{
|
|
TextDirection.TopBottom_RightLeft
|
|
or TextDirection.LeftRight_BottomTop
|
|
or TextDirection.RightLeft_BottomTop
|
|
or TextDirection.BottomTop_RightLeft => lines.ToArray ().Reverse ().ToList (),
|
|
_ => lines
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the number of columns required to render <paramref name="lines"/> oriented vertically.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding
|
|
/// glyphs (e.g. Arabic).
|
|
/// </remarks>
|
|
/// <param name="lines">The lines.</param>
|
|
/// <param name="startLine">The line in the list to start with (any lines before will be ignored).</param>
|
|
/// <param name="linesCount">
|
|
/// The number of lines to process (if less than <c>lines.Count</c>, any lines after will be
|
|
/// ignored).
|
|
/// </param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <returns>The width required.</returns>
|
|
public static int GetColumnsRequiredForVerticalText (
|
|
List<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the number of columns in the widest line in the text, without word wrap, accounting for wide-glyphs
|
|
/// (uses <see cref="StringExtensions.GetColumns"/>). <paramref name="text"/> if it contains newlines.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding
|
|
/// glyphs (e.g. Arabic).
|
|
/// </remarks>
|
|
/// <param name="text">Text, may contain newlines.</param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <returns>The length of the longest line.</returns>
|
|
public static int GetWidestLineLength (string text, int tabWidth = 0)
|
|
{
|
|
List<string> result = SplitNewLine (text);
|
|
|
|
return result.Max (x => GetRuneWidth (x, tabWidth));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the maximum number of columns from the text based on the <paramref name="startIndex"/> and the
|
|
/// <paramref name="length"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding
|
|
/// glyphs (e.g. Arabic).
|
|
/// </remarks>
|
|
/// <param name="text">The text.</param>
|
|
/// <param name="startIndex">The start index.</param>
|
|
/// <param name="length">The length.</param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <returns>The maximum characters width.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Gets the number of the Runes in the text that will fit in <paramref name="width"/>.</summary>
|
|
/// <remarks>
|
|
/// This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding
|
|
/// glyphs (e.g. Arabic).
|
|
/// </remarks>
|
|
/// <param name="text">The text.</param>
|
|
/// <param name="width">The width.</param>
|
|
/// <param name="tabWidth">The width used for a tab.</param>
|
|
/// <param name="textDirection">The text direction.</param>
|
|
/// <returns>The index of the text that fit the width.</returns>
|
|
public static int GetLengthThatFits (string text, int width, int tabWidth = 0, TextDirection textDirection = TextDirection.LeftRight_TopBottom)
|
|
{
|
|
return GetLengthThatFits (text?.ToRuneList () ?? [], width, tabWidth, textDirection);
|
|
}
|
|
|
|
/// <summary>Gets the number of the Runes in a list of Runes that will fit in <paramref name="width"/>.</summary>
|
|
/// <remarks>
|
|
/// This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding
|
|
/// glyphs (e.g. Arabic).
|
|
/// </remarks>
|
|
/// <param name="runes">The list of runes.</param>
|
|
/// <param name="width">The width.</param>
|
|
/// <param name="tabWidth">The width used for a tab.</param>
|
|
/// <param name="textDirection">The text direction.</param>
|
|
/// <returns>The index of the last Rune in <paramref name="runes"/> that fit in <paramref name="width"/>.</returns>
|
|
public static int GetLengthThatFits (List<Rune> 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)
|
|
{
|
|
return GetRuneWidth (str.EnumerateRunes ().ToList (), tabWidth, textDirection);
|
|
}
|
|
|
|
private static int GetRuneWidth (List<Rune> runes, int tabWidth, TextDirection textDirection = TextDirection.LeftRight_TopBottom)
|
|
{
|
|
return runes.Sum (r => GetRuneWidth (r, tabWidth, textDirection));
|
|
}
|
|
|
|
private static int GetRuneWidth (Rune rune, int tabWidth, TextDirection textDirection = TextDirection.LeftRight_TopBottom)
|
|
{
|
|
int runeWidth = IsHorizontalDirection (textDirection) ? rune.GetColumns () : 1;
|
|
|
|
if (rune.Value == '\t')
|
|
{
|
|
return tabWidth;
|
|
}
|
|
|
|
if (runeWidth < 0 || runeWidth > 0)
|
|
{
|
|
return Math.Max (runeWidth, 1);
|
|
}
|
|
|
|
return runeWidth;
|
|
}
|
|
|
|
/// <summary>Gets the index position from the list based on the <paramref name="width"/>.</summary>
|
|
/// <remarks>
|
|
/// This API will return incorrect results if the text includes glyphs whose width is dependent on surrounding
|
|
/// glyphs (e.g. Arabic).
|
|
/// </remarks>
|
|
/// <param name="lines">The lines.</param>
|
|
/// <param name="width">The width.</param>
|
|
/// <param name="tabWidth">The number of columns used for a tab.</param>
|
|
/// <returns>The index of the list that fit the width.</returns>
|
|
public static int GetMaxColsForWidth (List<string> lines, int width, int tabWidth = 0)
|
|
{
|
|
var runesLength = 0;
|
|
var lineIdx = 0;
|
|
|
|
for (; lineIdx < lines.Count; lineIdx++)
|
|
{
|
|
List<Rune> 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;
|
|
}
|
|
|
|
/// <summary>Finds the HotKey and its location in text.</summary>
|
|
/// <param name="text">The text to look in.</param>
|
|
/// <param name="hotKeySpecifier">The HotKey specifier (e.g. '_') to look for.</param>
|
|
/// <param name="hotPos">Outputs the Rune index into <c>text</c>.</param>
|
|
/// <param name="hotKey">Outputs the hotKey. <see cref="Key.Empty"/> if not found.</param>
|
|
/// <param name="firstUpperCase">
|
|
/// If <c>true</c> the legacy behavior of identifying the first upper case character as the
|
|
/// HotKey will be enabled. Regardless of the value of this parameter, <c>hotKeySpecifier</c> takes precedence.
|
|
/// Defaults to <see langword="false"/>.
|
|
/// </param>
|
|
/// <returns><c>true</c> if a HotKey was found; <c>false</c> otherwise.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces the Rune at the index specified by the <c>hotPos</c> parameter with a tag identifying it as the
|
|
/// hotkey.
|
|
/// </summary>
|
|
/// <param name="text">The text to tag the hotkey in.</param>
|
|
/// <param name="hotPos">The Rune index of the hotkey in <c>text</c>.</param>
|
|
/// <returns>The text with the hotkey tagged.</returns>
|
|
/// <remarks>The returned string will not render correctly without first un-doing the tag. To undo the tag, search for</remarks>
|
|
public static string ReplaceHotKeyWithTag (string text, int hotPos)
|
|
{
|
|
// Set the high bit
|
|
List<Rune> runes = text.ToRuneList ();
|
|
|
|
if (Rune.IsLetterOrDigit (runes [hotPos]))
|
|
{
|
|
runes [hotPos] = new ((uint)runes [hotPos].Value);
|
|
}
|
|
|
|
return StringExtensions.ToString (runes);
|
|
}
|
|
|
|
/// <summary>Removes the hotkey specifier from text.</summary>
|
|
/// <param name="text">The text to manipulate.</param>
|
|
/// <param name="hotKeySpecifier">The hot-key specifier (e.g. '_') to look for.</param>
|
|
/// <param name="hotPos">Returns the position of the hot-key in the text. -1 if not found.</param>
|
|
/// <returns>The input text with the hotkey specifier ('_') removed.</returns>
|
|
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
|
|
}
|