using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using NStack;
namespace Terminal.Gui {
///
/// Suppports text formatting, including horizontal alignment and word wrap for .
///
public class TextFormatter {
List lines = new List ();
ustring text;
TextAlignment textAlignment;
Attribute textColor = -1;
bool recalcPending = false;
Key hotKey;
///
/// Inititalizes a new object.
///
///
public TextFormatter (View view)
{
recalcPending = true;
}
///
/// The text to be displayed.
///
public virtual ustring Text {
get => text;
set {
text = value;
recalcPending = true;
}
}
// TODO: Add Vertical Text Alignment
///
/// Controls the horizontal text-alignment property.
///
/// The text alignment.
public TextAlignment Alignment {
get => textAlignment;
set {
textAlignment = value;
recalcPending = true;
}
}
///
/// Gets the size of the area the text will be drawn in.
///
public Size Size { get; internal set; }
///
/// The specifier character for the hotkey (e.g. '_'). Set to '\xffff' to disable hotkey support for this View instance. The default is '\xffff'.
///
public Rune HotKeySpecifier { get; set; } = (Rune)0xFFFF;
///
/// The position in the text of the hotkey. The hotkey will be rendered using the hot color.
///
public int HotKeyPos { get => hotKeyPos; set => hotKeyPos = value; }
///
/// Gets the hotkey. Will be an upper case letter or digit.
///
public Key HotKey { get => hotKey; internal set => hotKey = value; }
///
/// Causes the Text to be formatted, based on and .
///
public void ReFormat ()
{
// With this check, we protect against subclasses with overrides of Text
if (ustring.IsNullOrEmpty (Text)) {
return;
}
recalcPending = false;
var shown_text = text;
if (FindHotKey (text, HotKeySpecifier, true, out hotKeyPos, out hotKey)) {
shown_text = RemoveHotKeySpecifier (Text, hotKeyPos, HotKeySpecifier);
shown_text = ReplaceHotKeyWithTag (shown_text, hotKeyPos);
}
Reformat (shown_text, lines, Size.Width, textAlignment, Size.Height > 1);
}
static ustring StripWhiteCRLF (ustring str)
{
var runes = new List ();
foreach (var r in str.ToRunes ()) {
if (r != '\r' && r != '\n') {
runes.Add (r);
}
}
return ustring.Make (runes); ;
}
static ustring ReplaceCRLFWithSpace (ustring str)
{
var runes = new List ();
foreach (var r in str.ToRunes ()) {
if (r == '\r' || r == '\n') {
runes.Add (new Rune (' '));
} else {
runes.Add (r);
}
}
return ustring.Make (runes); ;
}
///
/// Formats the provided text to fit within the width provided using word wrapping.
///
/// The text to word warp
/// The width to contrain the text to
/// Returns a list of lines.
///
/// Newlines ('\n' and '\r\n') sequences are honored.
///
public static List WordWrap (ustring text, int width)
{
if (width < 0) {
throw new ArgumentOutOfRangeException ("Width cannot be negative.");
}
int start = 0, end;
var lines = new List ();
if (ustring.IsNullOrEmpty (text)) {
return lines;
}
text = StripWhiteCRLF (text);
while ((end = start + width) < text.RuneCount) {
while (text [end] != ' ' && end > start)
end -= 1;
if (end == start)
end = start + width;
lines.Add (text [start, end].TrimSpace ());
start = end;
}
if (start < text.RuneCount)
lines.Add (text.Substring (start).TrimSpace ());
return lines;
}
public static ustring ClipAndJustify (ustring text, int width, TextAlignment talign)
{
if (width < 0) {
throw new ArgumentOutOfRangeException ("Width cannot be negative.");
}
if (ustring.IsNullOrEmpty (text)) {
return text;
}
int slen = text.RuneCount;
if (slen > width) {
return text [0, width];
} else {
if (talign == TextAlignment.Justified) {
return Justify (text, width);
}
return text;
}
}
///
/// Justifies the text to fill the width provided. Space will be added between words (demarked by spaces and tabs) to
/// make the text just fit width. Spaces will not be added to the ends.
///
///
///
/// Character to replace whitespace and pad with. For debugging purposes.
/// The justifed text.
public static ustring Justify (ustring text, int width, char spaceChar = ' ')
{
if (width < 0) {
throw new ArgumentOutOfRangeException ("Width cannot be negative.");
}
if (ustring.IsNullOrEmpty (text)) {
return text;
}
// TODO: Use ustring
var words = text.ToString ().Split (whitespace, StringSplitOptions.RemoveEmptyEntries);
int textCount = words.Sum (arg => arg.Length);
var spaces = words.Length > 1 ? (width - textCount) / (words.Length - 1) : 0;
var extras = words.Length > 1 ? (width - textCount) % words.Length : 0;
var s = new System.Text.StringBuilder ();
//s.Append ($"tc={textCount} sp={spaces},x={extras} - ");
for (int w = 0; w < words.Length; w++) {
var x = words [w];
s.Append (x);
if (w + 1 < words.Length)
for (int i = 0; i < spaces; i++)
s.Append (spaceChar);
if (extras > 0) {
//s.Append ('_');
extras--;
}
}
return ustring.Make (s.ToString ());
}
static char [] whitespace = new char [] { ' ', '\t' };
private int hotKeyPos;
///
/// Reformats text into lines, applying text alignment and word wraping.
///
///
///
///
///
/// if false, forces text to fit a single line. Line breaks are converted to spaces.
static void Reformat (ustring textStr, List lineResult, int width, TextAlignment talign, bool wordWrap)
{
lineResult.Clear ();
if (wordWrap == false) {
textStr = ReplaceCRLFWithSpace (textStr);
lineResult.Add (ClipAndJustify (textStr, width, talign));
return;
}
int runeCount = textStr.RuneCount;
int lp = 0;
for (int i = 0; i < runeCount; i++) {
Rune c = textStr [i];
if (c == '\n') {
var wrappedLines = WordWrap (textStr [lp, i], width);
foreach (var line in wrappedLines) {
lineResult.Add (ClipAndJustify (line, width, talign));
}
if (wrappedLines.Count == 0) {
lineResult.Add (ustring.Empty);
}
lp = i + 1;
}
}
foreach (var line in WordWrap (textStr [lp, runeCount], width)) {
lineResult.Add (ClipAndJustify (line, width, talign));
}
}
///
/// Computes the number of lines needed to render the specified text given the width.
///
/// Number of lines.
/// Text, may contain newlines.
/// The minimum width for the text.
public static int MaxLines (ustring text, int width)
{
var result = new List ();
TextFormatter.Reformat (text, result, width, TextAlignment.Left, true);
return result.Count;
}
///
/// Computes the maximum width needed to render the text (single line or multple lines).
///
/// Max width of lines.
/// Text, may contain newlines.
/// The minimum width for the text.
public static int MaxWidth (ustring text, int width)
{
var result = new List ();
TextFormatter.Reformat (text, result, width, TextAlignment.Left, true);
return result.Max (s => s.RuneCount);
}
internal void Draw (Rect bounds, Attribute normalColor, Attribute hotColor)
{
// With this check, we protect against subclasses with overrides of Text
if (ustring.IsNullOrEmpty (text)) {
return;
}
if (recalcPending) {
ReFormat ();
}
Application.Driver.SetAttribute (normalColor);
for (int line = 0; line < lines.Count; line++) {
if (line < (bounds.Height - bounds.Top) || line >= bounds.Height)
continue;
var str = lines [line];
int x;
switch (textAlignment) {
case TextAlignment.Left:
x = bounds.Left;
break;
case TextAlignment.Justified:
x = bounds.Left;
break;
case TextAlignment.Right:
x = bounds.Right - str.RuneCount;
break;
case TextAlignment.Centered:
x = bounds.Left + (bounds.Width - str.RuneCount) / 2;
break;
default:
throw new ArgumentOutOfRangeException ();
}
int col = 0;
foreach (var rune in str) {
Application.Driver.Move (x + col, bounds.Y + line);
if ((rune & 0x100000) == 0x100000) {
Application.Driver.SetAttribute (hotColor);
Application.Driver.AddRune ((Rune)((uint)rune & ~0x100000));
Application.Driver.SetAttribute (normalColor);
} else {
Application.Driver.AddRune (rune);
}
col++;
}
}
}
///
/// Calculates the rectangle requried to hold text, assuming no word wrapping.
///
/// The x location of the rectangle
/// The y location of the rectangle
/// The text to measure
///
public static Rect CalcRect (int x, int y, ustring text)
{
if (ustring.IsNullOrEmpty (text))
return Rect.Empty;
int mw = 0;
int ml = 1;
int cols = 0;
foreach (var rune in text) {
if (rune == '\n') {
ml++;
if (cols > mw)
mw = cols;
cols = 0;
} else {
if (rune != '\r') {
cols++;
}
}
}
if (cols > mw)
mw = cols;
return new Rect (x, y, mw, ml);
}
public static bool FindHotKey (ustring text, Rune hotKeySpecifier, bool firstUpperCase, out int hotPos, out Key hotKey)
{
if (ustring.IsNullOrEmpty (text) || hotKeySpecifier == (Rune)0xFFFF) {
hotPos = -1;
hotKey = Key.Unknown;
return false;
}
Rune hot_key = (Rune)0;
int hot_pos = -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
int i = 0;
foreach (Rune c in text) {
if ((char)c != 0xFFFD) {
if (c == hotKeySpecifier) {
hot_pos = i;
} else if (hot_pos > -1) {
hot_key = c;
break;
}
}
i++;
}
// Legacy support - use first upper case char if the specifier was not found
if (hot_pos == -1 && firstUpperCase) {
i = 0;
foreach (Rune c in text) {
if ((char)c != 0xFFFD) {
if (Rune.IsUpper (c)) {
hot_key = c;
hot_pos = i;
break;
}
}
i++;
}
}
if (hot_key != (Rune)0 && hot_pos != -1) {
hotPos = hot_pos;
if (hot_key.IsValid && char.IsLetterOrDigit ((char)hot_key)) {
hotKey = (Key)char.ToUpperInvariant ((char)hot_key);
return true;
}
}
hotPos = -1;
hotKey = Key.Unknown;
return false;
}
public static ustring ReplaceHotKeyWithTag (ustring text, int hotPos)
{
// Set the high bit
var runes = text.ToRuneList ();
if (Rune.IsLetterOrNumber (runes [hotPos])) {
runes [hotPos] = new Rune ((uint)runes [hotPos] | 0x100000);
}
return ustring.Make (runes);
}
///
/// Removes the hotkey specifier from text.
///
/// The text to manipulate.
/// The hot-key specifier (e.g. '_') to look for.
/// Returns the postion of the hot-key in the text. -1 if not found.
/// The input text with the hotkey specifier ('_') removed.
public static ustring RemoveHotKeySpecifier (ustring text, int hotPos, Rune hotKeySpecifier)
{
if (ustring.IsNullOrEmpty (text)) {
return text;
}
// Scan
ustring start = ustring.Empty;
int i = 0;
foreach (Rune c in text) {
if (c == hotKeySpecifier && i == hotPos) {
i++;
continue;
}
start += ustring.Make (c);
i++;
}
return start;
}
///
/// Formats a single line of text with a hot-key and .
///
/// The text to align.
/// The maximum width for the text.
/// The hot-key position before reformatting.
/// The hot-key position after reformatting.
/// The to align to.
/// The aligned text.
public static ustring GetAlignedText (ustring shown_text, int width, int hot_pos, out int c_hot_pos, TextAlignment textAlignment)
{
int start;
var caption = shown_text;
c_hot_pos = hot_pos;
if (width > shown_text.RuneCount + 1) {
switch (textAlignment) {
case TextAlignment.Left:
caption += new string (' ', width - caption.RuneCount);
break;
case TextAlignment.Right:
start = width - caption.RuneCount;
caption = $"{new string (' ', width - caption.RuneCount)}{caption}";
if (c_hot_pos > -1) {
c_hot_pos += start;
}
break;
case TextAlignment.Centered:
start = width / 2 - caption.RuneCount / 2;
caption = $"{new string (' ', start)}{caption}{new string (' ', width - caption.RuneCount - start)}";
if (c_hot_pos > -1) {
c_hot_pos += start;
}
break;
case TextAlignment.Justified:
var words = caption.Split (" ");
var wLen = GetWordsLength (words, c_hot_pos, out int runeCount, out int w_hot_pos);
var space = (width - runeCount) / (caption.RuneCount - wLen);
caption = "";
for (int i = 0; i < words.Length; i++) {
if (i == words.Length - 1) {
caption += new string (' ', width - caption.RuneCount - 1);
caption += words [i];
} else {
caption += words [i];
}
if (i < words.Length - 1) {
caption += new string (' ', space);
}
}
if (c_hot_pos > -1) {
c_hot_pos += w_hot_pos * space - space - w_hot_pos + 1;
}
break;
}
}
return caption;
}
static int GetWordsLength (ustring [] words, int hotPos, out int runeCount, out int wordHotPos)
{
int length = 0;
int rCount = 0;
int wHotPos = -1;
for (int i = 0; i < words.Length; i++) {
if (wHotPos == -1 && rCount + words [i].RuneCount >= hotPos)
wHotPos = i;
length += words [i].Length;
rCount += words [i].RuneCount;
}
if (wHotPos == -1 && hotPos > -1)
wHotPos = words.Length;
runeCount = rCount;
wordHotPos = wHotPos;
return length;
}
}
}