2
0
Charlie Kindel 5 жил өмнө
parent
commit
49bfe6a6c2

+ 0 - 1
Example/demo.cs

@@ -628,7 +628,6 @@ static class Demo {
 		int count = 0;
 		ml = new Label (new Rect (3, 17, 47, 1), "Mouse: ");
 		Application.RootMouseEvent += delegate (MouseEvent me) {
-			ml.TextColor = Colors.TopLevel.Normal;
 			ml.Text = $"Mouse: ({me.X},{me.Y}) - {me.Flags} {count++}";
 		};
 

+ 1 - 1
Terminal.Gui/Core/ConsoleDriver.cs

@@ -663,7 +663,7 @@ namespace Terminal.Gui {
 			if (!ustring.IsNullOrEmpty (title) && width > 4 && region.Y + paddingTop <= region.Y + paddingBottom) {
 				Move (region.X + 1 + paddingLeft, region.Y + paddingTop);
 				AddRune (' ');
-				var str = title.Length >= width ? title [0, width - 2] : title;
+				var str = title.RuneCount >= width ? title [0, width - 2] : title;
 				AddStr (str);
 				AddRune (' ');
 			}

+ 536 - 0
Terminal.Gui/Core/TextFormatter.cs

@@ -0,0 +1,536 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using NStack;
+
+namespace Terminal.Gui {
+	/// <summary>
+	/// Suppports text formatting, including horizontal alignment and word wrap for <see cref="View"/>.
+	/// </summary>
+	public class TextFormatter {
+		List<ustring> lines = new List<ustring> ();
+		ustring text;
+		TextAlignment textAlignment;
+		Attribute textColor = -1;
+		bool recalcPending = false;
+		Key hotKey;
+
+		/// <summary>
+		///  Inititalizes a new <see cref="TextFormatter"/> object.
+		/// </summary>
+		/// <param name="view"></param>
+		public TextFormatter (View view)
+		{
+			recalcPending = true;
+		}
+
+		/// <summary>
+		///   The text to be displayed.
+		/// </summary>
+		public virtual ustring Text {
+			get => text;
+			set {
+				text = value;
+				recalcPending = true;
+			}
+		}
+
+		// TODO: Add Vertical Text Alignment
+		/// <summary>
+		/// Controls the horizontal text-alignment property. 
+		/// </summary>
+		/// <value>The text alignment.</value>
+		public TextAlignment Alignment {
+			get => textAlignment;
+			set {
+				textAlignment = value;
+				recalcPending = true;
+			}
+		}
+
+		/// <summary>
+		///  Gets the size of the area the text will be drawn in. 
+		/// </summary>
+		public Size Size { get; internal set; }
+
+
+		/// <summary>
+		/// The specifier character for the hotkey (e.g. '_'). Set to '\xffff' to disable hotkey support for this View instance. The default is '\xffff'. 
+		/// </summary>
+		public Rune HotKeySpecifier { get; set; } = (Rune)0xFFFF;
+
+		/// <summary>
+		/// The position in the text of the hotkey. The hotkey will be rendered using the hot color.
+		/// </summary>
+		public int HotKeyPos { get => hotKeyPos; set => hotKeyPos = value; }
+
+		/// <summary>
+		/// Gets the hotkey. Will be an upper case letter or digit.
+		/// </summary>
+		public Key HotKey { get => hotKey; internal set => hotKey = value; }
+
+		/// <summary>
+		/// Causes the Text to be formatted, based on <see cref="Alignment"/> and <see cref="Size"/>.
+		/// </summary>
+		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<Rune> ();
+			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<Rune> ();
+			foreach (var r in str.ToRunes ()) {
+				if (r == '\r' || r == '\n') {
+					runes.Add (new Rune (' '));
+				} else {
+					runes.Add (r);
+				}
+			}
+			return ustring.Make (runes); ;
+		}
+
+		/// <summary>
+		/// Formats the provided text to fit within the width provided using word wrapping.
+		/// </summary>
+		/// <param name="text">The text to word warp</param>
+		/// <param name="width">The width to contrain the text to</param>
+		/// <returns>Returns a list of lines.</returns>
+		/// <remarks>
+		/// Newlines ('\n' and '\r\n') sequences are honored.
+		/// </remarks>
+		public static List<ustring> WordWrap (ustring text, int width)
+		{
+			if (width < 0) {
+				throw new ArgumentOutOfRangeException ("Width cannot be negative.");
+			}
+
+			int start = 0, end;
+			var lines = new List<ustring> ();
+
+			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;
+			}
+		}
+
+		/// <summary>
+		/// 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 <c>width</c>. Spaces will not be added to the ends.
+		/// </summary>
+		/// <param name="text"></param>
+		/// <param name="width"></param>
+		/// <param name="spaceChar">Character to replace whitespace and pad with. For debugging purposes.</param>
+		/// <returns>The justifed text.</returns>
+		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;
+
+		/// <summary>
+		/// Reformats text into lines, applying text alignment and word wraping.
+		/// </summary>
+		/// <param name="textStr"></param>
+		/// <param name="lineResult"></param>
+		/// <param name="width"></param>
+		/// <param name="talign"></param>
+		/// <param name="wordWrap">if <c>false</c>, forces text to fit a single line. Line breaks are converted to spaces.</param>
+		static void Reformat (ustring textStr, List<ustring> 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));
+			}
+		}
+
+		/// <summary>
+		/// Computes the number of lines needed to render the specified text given the width.
+		/// </summary>
+		/// <returns>Number of lines.</returns>
+		/// <param name="text">Text, may contain newlines.</param>
+		/// <param name="width">The minimum width for the text.</param>
+		public static int MaxLines (ustring text, int width)
+		{
+			var result = new List<ustring> ();
+			TextFormatter.Reformat (text, result, width, TextAlignment.Left, true);
+			return result.Count;
+		}
+
+		/// <summary>
+		/// Computes the maximum width needed to render the text (single line or multple lines).
+		/// </summary>
+		/// <returns>Max width of lines.</returns>
+		/// <param name="text">Text, may contain newlines.</param>
+		/// <param name="width">The minimum width for the text.</param>
+		public static int MaxWidth (ustring text, int width)
+		{
+			var result = new List<ustring> ();
+			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++;
+				}
+			}
+		}
+
+		/// <summary>
+		///  Calculates the rectangle requried to hold text, assuming no word wrapping.
+		/// </summary>
+		/// <param name="x">The x location of the rectangle</param>
+		/// <param name="y">The y location of the rectangle</param>
+		/// <param name="text">The text to measure</param>
+		/// <returns></returns>
+		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);
+		}
+
+		/// <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 postion of the hot-key in the text. -1 if not found.</param>
+		/// <returns>The input text with the hotkey specifier ('_') removed.</returns>
+		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;
+		}
+
+		/// <summary>
+		/// Formats a single line of text with a hot-key and <see cref="Alignment"/>.
+		/// </summary>
+		/// <param name="shown_text">The text to align.</param>
+		/// <param name="width">The maximum width for the text.</param>
+		/// <param name="hot_pos">The hot-key position before reformatting.</param>
+		/// <param name="c_hot_pos">The hot-key position after reformatting.</param>
+		/// <param name="textAlignment">The <see cref="Alignment"/> to align to.</param>
+		/// <returns>The aligned text.</returns>
+		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;
+		}
+	}
+}

+ 21 - 506
Terminal.Gui/Core/View.cs

@@ -132,482 +132,7 @@ namespace Terminal.Gui {
 	///    frames for the vies that use <see cref="LayoutStyle.Computed"/>.
 	/// </para>
 	/// </remarks>
-	public class View : Responder, IEnumerable {
-		/// <summary>
-		/// Suppports text formatting, including horizontal alignment and word wrap for <see cref="View"/>.
-		/// </summary>
-		public class ViewText {
-			List<ustring> lines = new List<ustring> ();
-			ustring text;
-			TextAlignment textAlignment;
-			Attribute textColor = -1;
-			View view;
-
-			/// <summary>
-			///  Inititalizes a new <see cref="ViewText"/> object.
-			/// </summary>
-			/// <param name="view"></param>
-			public ViewText (View view)
-			{
-				this.view = view;
-				recalcPending = true;
-			}
-
-			/// <summary>
-			///   The text to be displayed.
-			/// </summary>
-			public virtual ustring Text {
-				get => text;
-				set {
-					text = value;
-					recalcPending = true;
-					view.SetNeedsDisplay ();
-				}
-			}
-
-			// TODO: Add Vertical Text Alignment
-			/// <summary>
-			/// Controls the horizontal text-alignment property. 
-			/// </summary>
-			/// <value>The text alignment.</value>
-			public TextAlignment TextAlignment {
-				get => textAlignment;
-				set {
-					textAlignment = value;
-					recalcPending = true;
-					view.SetNeedsDisplay ();
-				}
-			}
-
-			/// <summary>
-			///   The color used for the drawing of the <see cref="Text"/>.
-			/// </summary>
-			public Attribute TextColor {
-				get => textColor;
-				set {
-					textColor = value;
-					recalcPending = true;
-					view.SetNeedsDisplay ();
-				}
-			}
-
-			/// <summary>
-			///  Gets the size of the area the text will be drawn in. 
-			/// </summary>
-			public Size TextSize { get; internal set; }
-
-			bool recalcPending = false;
-
-			public int HotKeyPos { get => hotKeyPos; set => hotKeyPos = value; }
-			public Rune HotKey { get => hotKey; set => hotKey = value; }
-			Rune hotKey;
-
-			/// <summary>
-			/// The specifier character for the hotkey (e.g. '_'). Set to '\xffff' to disable hotkey support for this View instance. The default is '\xffff'. 
-			/// </summary>
-			public Rune HotKeySpecifier { get; set; } = (Rune)0xFFFF;
-
-			/// <summary>
-			/// Causes the Text to be formatted, based on <see cref="TextAlignment"/> and <see cref="TextSize"/>.
-			/// </summary>
-			public void ReFormat ()
-			{
-				// With this check, we protect against subclasses with overrides of Text
-				if (ustring.IsNullOrEmpty (Text)) {
-					return;
-				}
-				recalcPending = false;
-				var shown_text = ProcessHotKeyText (text, HotKeySpecifier, false, out hotKeyPos, out hotKey);
-				Reformat (shown_text, lines, TextSize.Width, textAlignment, TextSize.Height > 1);
-			}
-
-			static ustring StripCRLF (ustring str)
-			{
-				var runes = new List<Rune> ();
-				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<Rune> ();
-				foreach (var r in str.ToRunes ()) {
-					if (r == '\r' || r == '\n') {
-						runes.Add (new Rune (' ')); // r + 0x2400));         // U+25A1 □ WHITE SQUARE
-					} else {
-						runes.Add (r);
-					}
-				}
-				return ustring.Make (runes); ;
-			}
-
-			static List<ustring> WordWrap (ustring text, int margin)
-			{
-				int start = 0, end;
-				var lines = new List<ustring> ();
-
-				text = StripCRLF (text);
-
-				while ((end = start + margin) < text.Length) {
-					while (text [end] != ' ' && end > start)
-						end -= 1;
-					if (end == start)
-						end = start + margin;
-
-					lines.Add (text [start, end]);
-					start = end + 1;
-				}
-
-				if (start < text.Length)
-					lines.Add (text.Substring (start));
-
-				return lines;
-			}
-
-			static ustring ClipAndJustify (ustring str, int width, TextAlignment talign)
-			{
-				int slen = str.RuneCount;
-				if (slen > width) {
-					var uints = str.ToRunes (width);
-					var runes = new Rune [uints.Length];
-					for (int i = 0; i < uints.Length; i++)
-						runes [i] = uints [i];
-					return ustring.Make (runes);
-				} else {
-					if (talign == TextAlignment.Justified) {
-						// TODO: ustring needs this
-						var words = str.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 (' ');
-							if (extras > 0) {
-								//s.Append ('_');
-								extras--;
-							}
-						}
-						return ustring.Make (s.ToString ());
-					}
-					return str;
-				}
-			}
-
-			static char [] whitespace = new char [] { ' ', '\t' };
-			private int hotKeyPos;
-
-			/// <summary>
-			/// Reformats text into lines, applying text alignment and word wraping.
-			/// </summary>
-			/// <param name="textStr"></param>
-			/// <param name="lineResult"></param>
-			/// <param name="width"></param>
-			/// <param name="talign"></param>
-			/// <param name="wordWrap">if <c>false</c>, forces text to fit a single line. Line breaks are converted to spaces.</param>
-			static void Reformat (ustring textStr, List<ustring> lineResult, int width, TextAlignment talign, bool wordWrap)
-			{
-				lineResult.Clear ();
-
-				if (wordWrap == false) {
-					textStr = ReplaceCRLFWithSpace (textStr);
-					lineResult.Add (ClipAndJustify (textStr, width, talign));
-					return;
-				}
-
-				int textLen = textStr.Length;
-				int lp = 0;
-				for (int i = 0; i < textLen; 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, textLen], width)) {
-					lineResult.Add (ClipAndJustify (line, width, talign));
-				}
-			}
-
-			/// <summary>
-			/// Computes the number of lines needed to render the specified text given the width.
-			/// </summary>
-			/// <returns>Number of lines.</returns>
-			/// <param name="text">Text, may contain newlines.</param>
-			/// <param name="width">The minimum width for the text.</param>
-			public static int MaxLines (ustring text, int width)
-			{
-				var result = new List<ustring> ();
-				ViewText.Reformat (text, result, width, TextAlignment.Left, true);
-				return result.Count;
-			}
-
-			/// <summary>
-			/// Computes the maximum width needed to render the text (single line or multple lines).
-			/// </summary>
-			/// <returns>Max width of lines.</returns>
-			/// <param name="text">Text, may contain newlines.</param>
-			/// <param name="width">The minimum width for the text.</param>
-			public static int MaxWidth (ustring text, int width)
-			{
-				var result = new List<ustring> ();
-				ViewText.Reformat (text, result, width, TextAlignment.Left, true);
-				return result.Max (s => s.RuneCount);
-			}
-
-			internal void Draw (Rect bounds)
-			{
-				// With this check, we protect against subclasses with overrides of Text
-				if (ustring.IsNullOrEmpty (text)) {
-					return;
-				}
-
-				if (recalcPending) {
-					ReFormat ();
-				}
-
-				if (TextColor != -1)
-					Driver.SetAttribute (TextColor);
-				else
-					Driver.SetAttribute (view.ColorScheme.Normal);
-
-				view.Clear ();
-				for (int line = 0; line < lines.Count; line++) {
-					if (line < bounds.Top || line >= bounds.Bottom)
-						continue;
-					var str = lines [line];
-					int x;
-					switch (textAlignment) {
-					case TextAlignment.Left:
-						x = 0;
-						break;
-					case TextAlignment.Justified:
-						x = bounds.Left;
-						break;
-					case TextAlignment.Right:
-						x = bounds.Right - str.Length;
-						break;
-					case TextAlignment.Centered:
-						x = bounds.Left + (bounds.Width - str.Length) / 2;
-						break;
-					default:
-						throw new ArgumentOutOfRangeException ();
-					}
-					view.Move (x, line);
-					Driver.AddStr (str);
-				}
-
-				if (HotKeyPos != -1) {
-					_ = GetAlignedText (lines [0], TextSize.Width, hotKeyPos, out hotKeyPos, textAlignment);
-
-					view.Move (HotKeyPos, 0);
-					Driver.SetAttribute (view.HasFocus ? view.ColorScheme.HotFocus : view.ColorScheme.HotNormal);
-					Driver.AddRune (hotKey);
-				}
-			}
-
-			/// <summary>
-			///  Calculates the rectangle requried to hold text, assuming no word wrapping.
-			/// </summary>
-			/// <param name="x">The x location of the rectangle</param>
-			/// <param name="y">The y location of the rectangle</param>
-			/// <param name="text">The text to measure</param>
-			/// <returns></returns>
-			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
-						cols++;
-				}
-				if (cols > mw)
-					mw = cols;
-
-				return new Rect (x, y, mw, ml);
-			}
-
-
-			/// <summary>
-			/// Gets the position and Rune for the hotkey in text and removes the hotkey specifier.
-			/// </summary>
-			/// <param name="text">The text to manipulate.</param>
-			/// <param name="hotKeySpecifier">The hot-key specifier (e.g. '_') to look for.</param>
-			/// <param name="firstUpperCase">If <c>true</c> and no hotkey is found via the hotkey specifier, the first upper case char found will be the hotkey.</param>
-			/// <param name="hotPos">Returns the postion of the hot-key in the text. -1 if not found.</param>
-			/// <param name="showHotKey">Returns the Rune immediately to the right of the hot-key position</param>
-			/// <returns>The input text with the hotkey specifier ('_') removed.</returns>
-			public static ustring ProcessHotKeyText (ustring text, Rune hotKeySpecifier, bool firstUpperCase, out int hotPos, out Rune showHotKey)
-			{
-				if (hotKeySpecifier == (Rune)0xFFFF) {
-					hotPos = -1;
-					showHotKey = (Rune)0xFFFF;
-					return text;
-				}
-				Rune hot_key = (Rune)0;
-				int hot_pos = -1;
-				ustring shown_text = text;
-
-				// Use first hot_key char passed into 'hotKey'.
-				// TODO: Ignore hot_key of two are provided
-				int i = 0;
-				foreach (Rune c in shown_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 shown_text) {
-						if ((char)c != 0xFFFD) {
-							if (Rune.IsUpper (c)) {
-								hot_key = c;
-								hot_pos = i;
-								break;
-							}
-						}
-						i++;
-					}
-				}
-				else {
-					if (hot_pos != -1) {
-						// Use char after 'hotKey'
-						ustring start = "";
-						i = 0;
-						foreach (Rune c in shown_text) {
-							start += ustring.Make (c);
-							i++;
-							if (i == hot_pos)
-								break;
-						}
-						var st = shown_text;
-						shown_text = start;
-						i = 0;
-						foreach (Rune c in st) {
-							i++;
-							if (i > hot_pos + 1) {
-								shown_text += ustring.Make (c);
-							}
-						}
-					}
-				}
-				hotPos = hot_pos;
-				showHotKey = hot_key;
-				return shown_text;
-			}
-
-			/// <summary>
-			/// Formats a single line of text with a hot-key and <see cref="TextAlignment"/>.
-			/// </summary>
-			/// <param name="shown_text">The text to align.</param>
-			/// <param name="width">The maximum width for the text.</param>
-			/// <param name="hot_pos">The hot-key position before reformatting.</param>
-			/// <param name="c_hot_pos">The hot-key position after reformatting.</param>
-			/// <param name="textAlignment">The <see cref="TextAlignment"/> to align to.</param>
-			/// <returns>The aligned text.</returns>
-			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.Length + 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.Length - 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;
-			}
-		}
+	public partial class View : Responder, IEnumerable {
 
 		internal enum Direction {
 			Forward,
@@ -619,7 +144,7 @@ namespace Terminal.Gui {
 		View focused = null;
 		Direction focusDirection;
 
-		ViewText viewText;
+		TextFormatter viewText;
 
 		/// <summary>
 		/// Event fired when the view gets focus.
@@ -649,7 +174,7 @@ namespace Terminal.Gui {
 		/// <summary>
 		/// The HotKey defined for this view. A user pressing HotKey on the keyboard while this view has focus will cause the Clicked event to fire.
 		/// </summary>
-		public Rune HotKey { get => viewText.HotKey; set => viewText.HotKey = value; }
+		public Key HotKey { get => viewText.HotKey; set => viewText.HotKey = value; }
 
 		/// <summary>
 		/// 
@@ -889,7 +414,7 @@ namespace Terminal.Gui {
 		/// </remarks>
 		public View (Rect frame)
 		{
-			viewText = new ViewText (this);
+			viewText = new TextFormatter (this);
 			this.Text = ustring.Empty;
 
 			this.Frame = frame;
@@ -933,7 +458,7 @@ namespace Terminal.Gui {
 		/// <param name="x">column to locate the Label.</param>
 		/// <param name="y">row to locate the Label.</param>
 		/// <param name="text">text to initialize the <see cref="Text"/> property with.</param>
-		public View (int x, int y, ustring text) : this (ViewText.CalcRect (x, y, text), text) { }
+		public View (int x, int y, ustring text) : this (TextFormatter.CalcRect (x, y, text), text) { }
 
 		/// <summary>
 		///   Initializes a new instance of <see cref="View"/> using <see cref="LayoutStyle.Absolute"/> layout.
@@ -952,7 +477,7 @@ namespace Terminal.Gui {
 		/// <param name="text">text to initialize the <see cref="Text"/> property with.</param>
 		public View (Rect rect, ustring text) : this (rect)
 		{
-			viewText = new ViewText (this);
+			viewText = new TextFormatter (this);
 			this.Text = text;
 		}
 
@@ -972,12 +497,13 @@ namespace Terminal.Gui {
 		/// <param name="text">text to initialize the <see cref="Text"/> property with.</param>
 		public View (ustring text) : base ()
 		{
-			viewText = new ViewText (this);
+			viewText = new TextFormatter (this);
 			this.Text = text;
 
 			CanFocus = false;
 			LayoutStyle = LayoutStyle.Computed;
-			var r = ViewText.CalcRect (0, 0, text);
+			// BUGBUG: CalcRect doesn't account for line wrapping
+			var r = TextFormatter.CalcRect (0, 0, text);
 			x = Pos.At (0);
 			y = Pos.At (0);
 			Width = r.Width;
@@ -1549,10 +1075,13 @@ namespace Terminal.Gui {
 
 			Driver.SetAttribute (HasFocus ? ColorScheme.Focus : ColorScheme.Normal);
 
-			// Draw any Text
-			// TODO: Figure out if this should go here or after OnDrawContent
-			viewText?.ReFormat ();
-			viewText?.Draw (bounds);
+			if (!ustring.IsNullOrEmpty (Text)) {
+				Clear ();
+				// Draw any Text
+				// TODO: Figure out if this should go here or after OnDrawContent
+				viewText?.ReFormat ();
+				viewText?.Draw (ViewToScreen (Bounds), ColorScheme.Normal, ColorScheme.HotNormal);
+			}
 
 			// Invoke DrawContentEvent
 			OnDrawContent (bounds);
@@ -1561,8 +1090,6 @@ namespace Terminal.Gui {
 				foreach (var view in subviews) {
 					if (view.NeedDisplay != null && (!view.NeedDisplay.IsEmpty || view.childNeedsDisplay)) {
 						if (view.Frame.IntersectsWith (clipRect) && (view.Frame.IntersectsWith (bounds) || bounds.X < 0 || bounds.Y < 0)) {
-
-							// FIXED: optimize this by computing the intersection of region and view.Bounds
 							if (view.layoutNeeded)
 								view.LayoutSubviews ();
 							Application.CurrentView = view;
@@ -1570,7 +1097,6 @@ namespace Terminal.Gui {
 							// Draw the subview
 							// Use the view's bounds (view-relative; Location will always be (0,0) because
 							view.Redraw (view.Bounds);
-
 						}
 						view.NeedDisplay = Rect.Empty;
 						view.childNeedsDisplay = false;
@@ -1674,7 +1200,7 @@ namespace Terminal.Gui {
 				return true;
 
 			var c = keyEvent.KeyValue;
-			if (c == '\n' || c == ' ' || Rune.ToUpper ((uint)c) == HotKey) {
+			if (c == '\n' || c == ' ' || keyEvent.Key == HotKey) {
 				Clicked?.Invoke ();
 				return true;
 			}
@@ -2018,7 +1544,7 @@ namespace Terminal.Gui {
 			if (!layoutNeeded)
 				return;
 
-			viewText.TextSize = Bounds.Size;
+			viewText.Size = Bounds.Size;
 			viewText.ReFormat ();
 
 			Rect oldBounds = Bounds;
@@ -2080,20 +1606,9 @@ namespace Terminal.Gui {
 		/// </summary>
 		/// <value>The text alignment.</value>
 		public virtual TextAlignment TextAlignment {
-			get => viewText.TextAlignment;
-			set {
-				viewText.TextAlignment = value;
-				SetNeedsDisplay ();
-			}
-		}
-
-		/// <summary>
-		///   The color used for the <see cref="Label"/>.
-		/// </summary>
-		public virtual Attribute TextColor {
-			get => viewText.TextColor;
+			get => viewText.Alignment;
 			set {
-				viewText.TextColor = value;
+				viewText.Alignment = value;
 				SetNeedsDisplay ();
 			}
 		}
@@ -2169,7 +1684,7 @@ namespace Terminal.Gui {
 
 
 			if (mouseEvent.Flags == MouseFlags.Button1Clicked) {
-				if (!HasFocus) {
+				if (!HasFocus && SuperView != null) {
 					SuperView.SetFocus (this);
 					SetNeedsDisplay ();
 				}

+ 0 - 10
Terminal.Gui/Core/Window.cs

@@ -278,15 +278,5 @@ namespace Terminal.Gui {
 				base.TextAlignment = contentView.TextAlignment = value;
 			}
 		}
-
-		/// <summary>
-		///   The color used for the <see cref="Label"/>.
-		/// </summary>
-		public override Attribute TextColor {
-			get => contentView.TextColor;
-			set {
-				base.TextColor = contentView.TextColor = value;
-			}
-		}
 	}
 }

+ 31 - 28
Terminal.Gui/Views/Button.cs

@@ -28,19 +28,6 @@ namespace Terminal.Gui {
 		ustring text;
 		bool is_default;
 
-		/// <summary>
-		/// Gets or sets whether the <see cref="Button"/> is the default action to activate in a dialog.
-		/// </summary>
-		/// <value><c>true</c> if is default; otherwise, <c>false</c>.</value>
-		public bool IsDefault {
-			get => is_default;
-			set {
-				is_default = value;
-				SetWidthHeight (Text, is_default);
-				Update ();
-			}
-		}
-
 		/// <summary>
 		///   Initializes a new instance of <see cref="Button"/> using <see cref="LayoutStyle.Computed"/> layout.
 		/// </summary>
@@ -94,7 +81,7 @@ namespace Terminal.Gui {
 		///   in a <see cref="Dialog"/> will implicitly activate this button.
 		/// </param>
 		public Button (int x, int y, ustring text, bool is_default)
-		    : base (new Rect (x, y, text.Length + 4 + (is_default ? 2 : 0), 1))
+		    : base (new Rect (x, y, text.RuneCount + 4 + (is_default ? 2 : 0), 1))
 		{
 			Init (text, is_default);
 		}
@@ -114,20 +101,20 @@ namespace Terminal.Gui {
 			_rightDefault = new Rune (Driver != null ? Driver.RightDefaultIndicator : '>');
 
 			CanFocus = true;
-			Text = text ?? string.Empty;
 			this.IsDefault = is_default;
-			int w = SetWidthHeight (text, is_default);
-			Frame = new Rect (Frame.Location, new Size (w, 1));
+			Text = text ?? string.Empty;
+			//int w = SetWidthHeight (text, is_default);
+			//Frame = new Rect (Frame.Location, new Size (w, 1));
 		}
 
-		int SetWidthHeight (ustring text, bool is_default)
-		{
-			int w = text.Length + 4 + (is_default ? 2 : 0);
-			Width = w;
-			Height = 1;
-			Frame = new Rect (Frame.Location, new Size (w, 1));
-			return w;
-		}
+		//int SetWidthHeight (ustring text, bool is_default)
+		//{
+		//	int w = text.RuneCount;// + 4 + (is_default ? 2 : 0);
+		//	Width = w;
+		//	Height = 1;
+		//	Frame = new Rect (Frame.Location, new Size (w, 1));
+		//	return w;
+		//}
 
 		/// <summary>
 		///   The text displayed by this <see cref="Button"/>.
@@ -138,12 +125,23 @@ namespace Terminal.Gui {
 			}
 
 			set {
-				SetWidthHeight (value, is_default);
 				text = value;
 				Update ();
 			}
 		}
 
+		/// <summary>
+		/// Gets or sets whether the <see cref="Button"/> is the default action to activate in a dialog.
+		/// </summary>
+		/// <value><c>true</c> if is default; otherwise, <c>false</c>.</value>
+		public bool IsDefault {
+			get => is_default;
+			set {
+				is_default = value;
+				Update ();
+			}
+		}
+
 		internal void Update ()
 		{
 			if (IsDefault)
@@ -151,12 +149,17 @@ namespace Terminal.Gui {
 			else
 				base.Text = ustring.Make (_leftBracket) + " " + text + " " + ustring.Make (_rightBracket);
 
+			int w = base.Text.RuneCount - (base.Text.Contains (HotKeySpecifier) ? 1 : 0);
+			Width = w;
+			Height = 1;
+			Frame = new Rect (Frame.Location, new Size (w, 1));
+
 			SetNeedsDisplay ();
 		}
 
 		bool CheckKey (KeyEvent key)
 		{
-			if ((char)key.KeyValue == HotKey) {
+			if (key.Key == HotKey) {
 				this.SuperView.SetFocus (this);
 				Clicked?.Invoke ();
 				return true;
@@ -187,7 +190,7 @@ namespace Terminal.Gui {
 		public override bool ProcessKey (KeyEvent kb)
 		{
 			var c = kb.KeyValue;
-			if (c == '\n' || c == ' ' || Rune.ToUpper ((uint)c) == HotKey) {
+			if (c == '\n' || c == ' ' || kb.Key == HotKey) {
 				Clicked?.Invoke ();
 				return true;
 			}

+ 1 - 1
Terminal.Gui/Views/Checkbox.cs

@@ -51,7 +51,7 @@ namespace Terminal.Gui {
 			Text = s;
 			CanFocus = true;
 			Height = 1;
-			Width = s.Length + 4;
+			Width = s.RuneCount + 4;
 		}
 
 		/// <summary>

+ 3 - 3
Terminal.Gui/Views/ComboBox.cs

@@ -189,7 +189,7 @@ namespace Terminal.Gui {
 			if (!search.HasFocus)
 				this.SetFocus (search);
 
-			search.CursorPosition = search.Text.Length;
+			search.CursorPosition = search.Text.RuneCount;
 
 			return true;
 		}
@@ -222,7 +222,7 @@ namespace Terminal.Gui {
 				}
 
 				SetValue((string)searchset [listview.SelectedItem]);
-				search.CursorPosition = search.Text.Length;
+				search.CursorPosition = search.Text.RuneCount;
 				Search_Changed (search.Text);
 				OnSelectedChanged ();
 
@@ -245,7 +245,7 @@ namespace Terminal.Gui {
 
 			if (e.Key == Key.CursorUp && listview.HasFocus && listview.SelectedItem == 0 && searchset.Count > 0) // jump back to search
 			{
-				search.CursorPosition = search.Text.Length;
+				search.CursorPosition = search.Text.RuneCount;
 				this.SetFocus (search);
 				return true;
 			}

+ 4 - 4
Terminal.Gui/Views/DateField.cs

@@ -99,11 +99,11 @@ namespace Terminal.Gui {
 		{
 			ustring [] frm = ustring.Make (lf).Split (ustring.Make (sepChar));
 			for (int i = 0; i < frm.Length; i++) {
-				if (frm [i].Contains ("M") && frm [i].Length < 2)
+				if (frm [i].Contains ("M") && frm [i].RuneCount < 2)
 					lf = lf.Replace ("M", "MM");
-				if (frm [i].Contains ("d") && frm [i].Length < 2)
+				if (frm [i].Contains ("d") && frm [i].RuneCount < 2)
 					lf = lf.Replace ("d", "dd");
-				if (frm [i].Contains ("y") && frm [i].Length < 4)
+				if (frm [i].Contains ("y") && frm [i].RuneCount < 4)
 					lf = lf.Replace ("yy", "yyyy");
 			}
 			return $" {lf}";
@@ -248,7 +248,7 @@ namespace Terminal.Gui {
 					date [1] = vals [i].TrimSpace ();
 				} else {
 					var year = vals [i].TrimSpace ();
-					if (year.Length == 2) {
+					if (year.RuneCount == 2) {
 						var y = DateTime.Now.Year.ToString ();
 						date [2] = y.Substring (0, 2) + year.ToString ();
 					} else {

+ 0 - 11
Terminal.Gui/Views/FrameView.cs

@@ -190,16 +190,5 @@ namespace Terminal.Gui {
 				base.TextAlignment = contentView.TextAlignment = value;
 			}
 		}
-
-		/// <summary>
-		///   The color used for the <see cref="Label"/>.
-		/// </summary>
-		public override Attribute TextColor {
-			get => contentView.TextColor;
-			set {
-				base.TextColor = contentView.TextColor = value;
-			}
-		}
-
 	}
 }

+ 2 - 2
Terminal.Gui/Views/Menu.cs

@@ -132,7 +132,7 @@ namespace Terminal.Gui {
 			return CanExecute == null ? true : CanExecute ();
 		}
 
-		internal int Width => Title.Length + Help.Length + 1 + 2 +
+		internal int Width => Title.RuneCount + Help.Length + 1 + 2 +
 			(Checked || CheckType.HasFlag (MenuItemCheckStyle.Checked) || CheckType.HasFlag (MenuItemCheckStyle.Radio) ? 2 : 0);
 
 		/// <summary>
@@ -367,7 +367,7 @@ namespace Terminal.Gui {
 					       i == current ? ColorScheme.Focus : ColorScheme.Normal);
 
 				// The help string
-				var l = item.Help.Length;
+				var l = item.Help.RuneCount;
 				Move (Frame.Width - l - 2, 1 + i);
 				Driver.AddStr (item.Help);
 			}

+ 2 - 2
Terminal.Gui/Views/RadioGroup.cs

@@ -95,7 +95,7 @@ namespace Terminal.Gui {
 			}
 
 			foreach (var s in radioLabels)
-				width = Math.Max (s.Length + 3, width);
+				width = Math.Max (s.RuneCount + 3, width);
 			return new Rect (x, y, width, radioLabels.Count);
 		}
 
@@ -126,7 +126,7 @@ namespace Terminal.Gui {
 		//	for (int i = 0; i < radioLabels.Count; i++) {
 		//		Move(0, i);
 		//		Driver.SetAttribute(ColorScheme.Normal);
-		//		Driver.AddStr(ustring.Make(new string (' ', radioLabels[i].Length + 4)));
+		//		Driver.AddStr(ustring.Make(new string (' ', radioLabels[i].RuneCount + 4)));
 		//	}
 		//	if (newRadioLabels.Count != radioLabels.Count) {
 		//		SetWidthHeight(newRadioLabels);

+ 11 - 11
Terminal.Gui/Views/TextField.cs

@@ -64,7 +64,7 @@ namespace Terminal.Gui {
 		public TextField (ustring text)
 		{
 			Initialize (text, 0);
-			Width = text.Length + 1;
+			Width = text.RuneCount + 1;
 		}
 
 		/// <summary>
@@ -85,7 +85,7 @@ namespace Terminal.Gui {
 				text = "";
 
 			this.text = TextModel.ToRunes (text);
-			point = text.Length;
+			point = text.RuneCount;
 			first = point > w ? point - w : 0;
 			CanFocus = true;
 			Used = true;
@@ -771,13 +771,13 @@ namespace Terminal.Gui {
 
 		void DeleteSelectedText ()
 		{
-			string actualText = Text.ToString ();
+			ustring actualText = Text;
 			int selStart = SelectedLength < 0 ? SelectedLength + SelectedStart : SelectedStart;
 			int selLength = Math.Abs (SelectedLength);
-			Text = actualText.Substring (0, selStart) +
-				actualText.Substring (selStart + selLength, actualText.Length - selStart - selLength);
+			Text = actualText[0, selStart] +
+				actualText[selStart + selLength, actualText.RuneCount - selLength];
 			ClearAllSelection ();
-			CursorPosition = selStart >= Text.Length ? Text.Length : selStart;
+			CursorPosition = selStart >= Text.RuneCount ? Text.RuneCount : selStart;
 			SetNeedsDisplay ();
 		}
 
@@ -789,13 +789,13 @@ namespace Terminal.Gui {
 			if (ReadOnly)
 				return;
 
-			string actualText = Text.ToString ();
+			ustring actualText = Text;
 			int start = SelectedStart == -1 ? CursorPosition : SelectedStart;
-			ustring cbTxt = Clipboard.Contents?.ToString () ?? "";
-			Text = actualText.Substring (0, start) +
+			ustring cbTxt = Clipboard.Contents ?? "";
+			Text = actualText[0, start] +
 				cbTxt +
-				actualText.Substring (start + SelectedLength, actualText.Length - start - SelectedLength);
-			point = start + cbTxt.Length;
+				actualText[start + SelectedLength, actualText.RuneCount - SelectedLength];
+			point = start + cbTxt.RuneCount;
 			SelectedLength = 0;
 			ClearAllSelection ();
 			SetNeedsDisplay ();

+ 2 - 0
Terminal.Gui/Views/TextView.cs

@@ -77,6 +77,8 @@ namespace Terminal.Gui {
 		{
 			var lines = new List<List<Rune>> ();
 			int start = 0, i = 0;
+			// BUGBUG: I think this is buggy w.r.t Unicode. content.Length is bytes, and content[i] is bytes
+			// and content[i] == 10 may be the middle of a Rune.
 			for (; i < content.Length; i++) {
 				if (content [i] == 10) {
 					if (i - start > 0)

+ 2 - 2
Terminal.Gui/Windows/FileDialog.cs

@@ -437,10 +437,10 @@ namespace Terminal.Gui {
 		/// <param name="prompt">The prompt.</param>
 		/// <param name="nameFieldLabel">The name field label.</param>
 		/// <param name="message">The message.</param>
-		public FileDialog (ustring title, ustring prompt, ustring nameFieldLabel, ustring message) : base (title, Driver.Cols - 20, Driver.Rows - 5, null)
+		public FileDialog (ustring title, ustring prompt, ustring nameFieldLabel, ustring message) : base (title)//, Driver.Cols - 20, Driver.Rows - 5, null)
 		{
 			this.message = new Label (Rect.Empty, "MESSAGE" + message);
-			var msgLines = ViewText.MaxLines (message, Driver.Cols - 20);
+			var msgLines = TextFormatter.MaxLines (message, Driver.Cols - 20);
 
 			dirLabel = new Label ("Directory: ") {
 				X = 1,

+ 3 - 3
Terminal.Gui/Windows/MessageBox.cs

@@ -94,8 +94,8 @@ namespace Terminal.Gui {
 		static int QueryFull (bool useErrorColors, int width, int height, ustring title, ustring message, params ustring [] buttons)
 		{
 			const int defaultWidth = 50;
-			int textWidth = View.ViewText.MaxWidth (message, width == 0 ? defaultWidth : width);
-			int textHeight = View.ViewText.MaxLines (message, textWidth); // message.Count (ustring.Make ('\n')) + 1;
+			int textWidth = TextFormatter.MaxWidth (message, width == 0 ? defaultWidth : width);
+			int textHeight = TextFormatter.MaxLines (message, textWidth); // message.Count (ustring.Make ('\n')) + 1;
 			int msgboxHeight = Math.Max (1, textHeight) + 3; // textHeight + (top + top padding + buttons + bottom)
 
 			// Create button array for Dialog
@@ -135,7 +135,7 @@ namespace Terminal.Gui {
 			}
 
 			// Dynamically size Width
-			int msgboxWidth = Math.Max (defaultWidth, Math.Max (title.Length + 8, Math.Max (textWidth + 4, d.GetButtonsWidth ()) + 8)); // textWidth + (left + padding + padding + right)
+			int msgboxWidth = Math.Max (defaultWidth, Math.Max (title.RuneCount + 8, Math.Max (textWidth + 4, d.GetButtonsWidth ()) + 8)); // textWidth + (left + padding + padding + right)
 			d.Width = msgboxWidth;
 
 			// Setup actions

+ 12 - 0
Terminal.GuiTests/Core/TextFormatterTests.cs

@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Terminal.GuiTests.Core
+{
+    class TextFormatterTests
+    {
+    }
+}

+ 20 - 0
Terminal.GuiTests/Terminal.GuiTests.csproj

@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net472</TargetFramework>
+
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
+    <PackageReference Include="MSTest.TestAdapter" Version="2.1.0" />
+    <PackageReference Include="MSTest.TestFramework" Version="2.1.0" />
+    <PackageReference Include="coverlet.collector" Version="1.2.0" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\Terminal.Gui\Terminal.Gui.csproj" />
+  </ItemGroup>
+
+</Project>

+ 1 - 1
Terminal.sln

@@ -10,7 +10,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Designer", "Designer\Design
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UICatalog", "UICatalog\UICatalog.csproj", "{88979F89-9A42-448F-AE3E-3060145F6375}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\UnitTests.csproj", "{8B901EDE-8974-4820-B100-5226917E2990}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests", "UnitTests\UnitTests.csproj", "{8B901EDE-8974-4820-B100-5226917E2990}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution

+ 5 - 5
UICatalog/Scenarios/Buttons.cs

@@ -191,17 +191,17 @@ namespace UICatalog {
 				ustring start = "";
 				if (i > -1)
 					start = txt [0, i];
-				txt = start + txt [i + 1, txt.Length];
+				txt = start + txt [i + 1, txt.RuneCount];
 
 				// Move over one or go to start
 				i++;
-				if (i >= txt.Length) {
+				if (i >= txt.RuneCount) {
 					i = 0;
 				}
 
 				// Slip in the '_'
 				start = txt [0, i];
-				txt = start + ustring.Make ('_') + txt [i, txt.Length];
+				txt = start + ustring.Make ('_') + txt [i, txt.RuneCount];
 
 				return txt;
 			}
@@ -218,11 +218,11 @@ namespace UICatalog {
 			};
 			Win.Add (moveHotKeyBtn);
 
-			var muhkb = " ~  s  gui.cs   master ↑10 = Сохранить";
+			var muhkb = ustring.Make(" ~  s  gui.cs   master ↑10 = Сохранить");
 			var moveUnicodeHotKeyBtn = new Button (muhkb) {
 				X = Pos.Left (absoluteFrame) + 1,
 				Y = Pos.Bottom (radioGroup) + 1,
-				Width = muhkb.Length + 30,
+				Width = muhkb.RuneCount + 30,
 				ColorScheme = Colors.TopLevel,
 			};
 			moveUnicodeHotKeyBtn.Clicked = () => {

+ 5 - 4
UICatalog/Scenarios/LabelsAsButtons.cs

@@ -133,6 +133,7 @@ namespace UICatalog {
 
 			// Demonstrates how changing the View.Frame property can SIZE Views (#583)
 			var sizeBtn = new Label ("Size This \u263a Button _via Pos") {
+			//var sizeBtn = new Label ("Size This x Button _via Pos") {
 				X = 0,
 				Y = Pos.Center () + 1,
 				Width = 30,
@@ -191,17 +192,17 @@ namespace UICatalog {
 				ustring start = "";
 				if (i > -1)
 					start = txt [0, i];
-				txt = start + txt [i + 1, txt.Length];
+				txt = start + txt [i + 1, txt.RuneCount];
 
 				// Move over one or go to start
 				i++;
-				if (i >= txt.Length) {
+				if (i >= txt.RuneCount) {
 					i = 0;
 				}
 
 				// Slip in the '_'
 				start = txt [0, i];
-				txt = start + ustring.Make ('_') + txt [i, txt.Length];
+				txt = start + ustring.Make ('_') + txt [i, txt.RuneCount];
 
 				return txt;
 			}
@@ -218,7 +219,7 @@ namespace UICatalog {
 			};
 			Win.Add (moveHotKeyBtn);
 
-			var muhkb = " ~  s  gui.cs   master ↑10 = Сохранить";
+			ustring muhkb = " ~  s  gui.cs   master ↑10 = Сохранить";
 			var moveUnicodeHotKeyBtn = new Label (muhkb) {
 				X = Pos.Left (absoluteFrame) + 1,
 				Y = Pos.Bottom (radioGroup) + 1,

+ 0 - 1
UICatalog/Scenarios/Mouse.cs

@@ -28,7 +28,6 @@ namespace UICatalog {
 			Win.Add (rmeList);
 
 			Application.RootMouseEvent += delegate (MouseEvent me) {
-				ml.TextColor = Colors.TopLevel.Normal;
 				ml.Text = $"Mouse: ({me.X},{me.Y}) - {me.Flags} {count}";
 				rme.Add ($"({me.X},{me.Y}) - {me.Flags} {count++}");
 				rmeList.MoveDown ();

+ 0 - 1
UICatalog/Scenarios/Scrolling.cs

@@ -243,7 +243,6 @@ namespace UICatalog {
 			mousePos.Y = Pos.AnchorEnd (1);
 			mousePos.Width = 50;
 			Application.RootMouseEvent += delegate (MouseEvent me) {
-				mousePos.TextColor = Colors.TopLevel.Normal;
 				mousePos.Text = $"Mouse: ({me.X},{me.Y}) - {me.Flags} {count++}";
 			};
 

+ 62 - 9
UICatalog/Scenarios/TextAlignments.cs

@@ -9,19 +9,71 @@ namespace UICatalog {
 	class TextAlignments : Scenario {
 		public override void Setup ()
 		{
-#if true
 			string txt = "Hello world, how are you today? Pretty neat!";
-#else
-			string txt = "Hello world, how are you today? Unicode:  ~  gui.cs  . Neat?";
-#endif
+			string unicodeSampleText = "A Unicode sentence (пÑивеÑ) has words.";
+
 			var alignments = Enum.GetValues (typeof (Terminal.Gui.TextAlignment)).Cast<Terminal.Gui.TextAlignment> ().ToList ();
-			var label = new Label ($"Demonstrating single-line (should clip!):") { Y = 0 };
+			var singleLines = new Label [alignments.Count];
+			var multipleLines = new Label [alignments.Count];
+
+			var multiLineHeight = 5;
+
+			foreach (var alignment in alignments) {
+				singleLines[(int)alignment] = new Label (txt) { TextAlignment = alignment, Width = Dim.Fill (), Height = 1, ColorScheme = Colors.Dialog };
+				multipleLines [(int)alignment] = new Label (txt) { TextAlignment = alignment, Width = Dim.Fill (), Height = multiLineHeight, ColorScheme = Colors.Dialog };
+			}
+
+			// Add a label & text field so we can demo IsDefault
+			var editLabel = new Label ("Text:") {
+				X = 0,
+				Y = 0,
+			};
+			Win.Add (editLabel);
+			var edit = new TextView () {
+				X = Pos.Right (editLabel) + 1,
+				Y = Pos.Y (editLabel),
+				Width = Dim.Fill("Text:".Length + "  Unicode Sample".Length + 2),
+				Height = 4,
+				ColorScheme = Colors.TopLevel,
+				Text = txt,
+			};
+			edit.TextChanged = () => {
+				foreach (var alignment in alignments) {
+					singleLines [(int)alignment].Text = edit.Text;
+					multipleLines [(int)alignment].Text = edit.Text;
+				}
+			};
+			Win.Add (edit);
+
+			var unicodeSample = new Button ("Unicode Sample") {
+				X = Pos.Right (edit) + 1,
+				Y = 0,
+				Clicked = () => {
+					edit.Text = unicodeSampleText;
+				}
+			};
+			Win.Add (unicodeSample);
+
+			var update = new Button ("_Update", is_default: true) {
+				X = Pos.Right (edit) + 1,
+				Y = Pos.Bottom (edit) - 1,
+				Clicked = () => {
+					foreach (var alignment in alignments) {
+						singleLines [(int)alignment].Text = edit.Text;
+						multipleLines [(int)alignment].Text = edit.Text;
+					}
+				}
+			};
+			Win.Add (update);
+
+			var label = new Label ($"Demonstrating single-line (should clip):") { Y = Pos.Bottom (edit) + 1 };
 			Win.Add (label);
 			foreach (var alignment in alignments) {
 				label = new Label ($"{alignment}:") { Y = Pos.Bottom (label) };
 				Win.Add (label);
-				label = new Label (txt) { TextAlignment = alignment, Y = Pos.Bottom (label), Width = Dim.Fill (), Height = 1, ColorScheme = Colors.Dialog };
-				Win.Add (label);
+				singleLines [(int)alignment].Y = Pos.Bottom (label);
+				Win.Add (singleLines [(int)alignment]);
+				label = singleLines [(int)alignment];
 			}
 
 			txt += "\nSecond line\n\nFourth Line.";
@@ -30,8 +82,9 @@ namespace UICatalog {
 			foreach (var alignment in alignments) {
 				label = new Label ($"{alignment}:") { Y = Pos.Bottom (label) };
 				Win.Add (label);
-				label = new Label (txt) { TextAlignment = alignment, Width = Dim.Fill (), Height = 6, ColorScheme = Colors.Dialog, Y = Pos.Bottom (label) };
-				Win.Add (label);
+				multipleLines [(int)alignment].Y = Pos.Bottom (label);
+				Win.Add (multipleLines [(int)alignment]);
+				label = multipleLines [(int)alignment];
 			}
 		}
 	}

+ 0 - 2
UICatalog/Scenarios/ViewWithText.cs

@@ -13,7 +13,6 @@ namespace UICatalog {
 			Win.Text = "This is the Te_xt for the host Win object. TextAlignment.Centered was specified. It is intentionally very long to illustrate word wrap.\n" +
 				"<-- There is a new line here to show a hard line break. You should see this text bleed underneath the subviews, which start at Y = 3.";
 			Win.TextAlignment = TextAlignment.Centered;
-			Win.TextColor = Application.Driver.MakeAttribute (Color.BrightGreen, Color.Black);
 #if true
 			string txt = "Hello world, how are you today? Pretty neat!";
 #else
@@ -31,7 +30,6 @@ namespace UICatalog {
 					Width = Dim.Fill (),
 					Height = 1,
 					ColorScheme = Colors.Dialog,
-					TextColor = Application.Driver.MakeAttribute (Color.BrightRed, Color.White),
 				};
 				Win.Add (label);
 			}

+ 1 - 1
UICatalog/UICatalog.cs

@@ -209,7 +209,7 @@ namespace UICatalog {
 				}),
 				new MenuBarItem ("_Color Scheme", CreateColorSchemeMenuItems()),
 				new MenuBarItem ("_Diagostics", CreateDiagnosticMenuItems()),
-				new MenuBarItem ("_About...", "About this app", () =>  MessageBox.Query ("About UI Catalog", aboutMessage.ToString(), "Ok")),
+				new MenuBarItem ("_About...", "About this app", () =>  MessageBox.Query ("About UI Catalog", aboutMessage.ToString(), "_Ok")),
 			});
 
 			_leftPane = new FrameView ("Categories") {

+ 1017 - 0
UnitTests/TextFormatterTests.cs

@@ -0,0 +1,1017 @@
+using NStack;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using Terminal.Gui;
+using Xunit;
+
+// Alais Console to MockConsole so we don't accidentally use Console
+using Console = Terminal.Gui.FakeConsole;
+
+namespace Terminal.Gui {
+	public class TextFormatterTests {
+		[Fact]
+		public void FindHotKey_Invalid_ReturnsFalse ()
+		{
+			var text = ustring.Empty;
+			Rune hotKeySpecifier = '_';
+			bool supportFirstUpperCase = false;
+			int hotPos = 0;
+			Key hotKey = Key.Unknown;
+			bool result = false;
+
+			text = null;
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "no hotkey";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "No hotkey, Upper Case";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "Non-english: Сохранить";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+		}
+
+		[Fact]
+		public void FindHotKey_AlphaUpperCase_Succeeds ()
+		{
+			var text = ustring.Empty;
+			Rune hotKeySpecifier = '_';
+			bool supportFirstUpperCase = false;
+			int hotPos = 0;
+			Key hotKey = Key.Unknown;
+			bool result = false;
+
+			text = "_K Before";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (0, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "a_K Second";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (1, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "Last _K";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (5, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "After K_";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "Multiple _K and _R";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (9, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			// Cryllic K (К)
+			text = "Non-english: _Кдать";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (13, hotPos);
+			Assert.Equal ((Key)'К', hotKey);
+
+			// Turn on FirstUpperCase and verify same results
+			supportFirstUpperCase = true;
+			text = "_K Before";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (0, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "a_K Second";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (1, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "Last _K";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (5, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "After K_";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "Multiple _K and _R";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (9, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			// Cryllic K (К)
+			text = "Non-english: _Кдать";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (13, hotPos);
+			Assert.Equal ((Key)'К', hotKey);
+		}
+		[Fact]
+		public void FindHotKey_AlphaLowerCase_Succeeds ()
+		{
+			var text = ustring.Empty;
+			Rune hotKeySpecifier = '_';
+			bool supportFirstUpperCase = false;
+			int hotPos = 0;
+			Key hotKey = Key.Unknown;
+			bool result = false;
+
+			// lower case should return uppercase Hotkey
+			text = "_k Before";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (0, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "a_k Second";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (1, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "Last _k";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (5, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "After k_";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "Multiple _k and _R";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (9, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			// Lower case Cryllic K (к)
+			text = "Non-english: _кдать";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (13, hotPos);
+			Assert.Equal ((Key)'К', hotKey);
+
+			// Turn on FirstUpperCase and verify same results
+			supportFirstUpperCase = true;
+			text = "_k Before";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (0, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "a_k Second";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (1, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "Last _k";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (5, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "After k_";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "Multiple _k and _R";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (9, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			// Lower case Cryllic K (к)
+			text = "Non-english: _кдать";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (13, hotPos);
+			Assert.Equal ((Key)'К', hotKey);
+		}
+
+		[Fact]
+		public void FindHotKey_Numeric_Succeeds ()
+		{
+			var text = ustring.Empty;
+			Rune hotKeySpecifier = '_';
+			bool supportFirstUpperCase = false;
+			int hotPos = 0;
+			Key hotKey = Key.Unknown;
+			bool result = false;
+			// Digits 
+			text = "_1 Before";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (0, hotPos);
+			Assert.Equal ((Key)'1', hotKey);
+
+			text = "a_1 Second";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (1, hotPos);
+			Assert.Equal ((Key)'1', hotKey);
+
+			text = "Last _1";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (5, hotPos);
+			Assert.Equal ((Key)'1', hotKey);
+
+			text = "After 1_";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "Multiple _1 and _2";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (9, hotPos);
+			Assert.Equal ((Key)'1', hotKey);
+
+			// Turn on FirstUpperCase and verify same results
+			supportFirstUpperCase = true;
+			text = "_1 Before";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (0, hotPos);
+			Assert.Equal ((Key)'1', hotKey);
+
+			text = "a_1 Second";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (1, hotPos);
+			Assert.Equal ((Key)'1', hotKey);
+
+			text = "Last _1";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (5, hotPos);
+			Assert.Equal ((Key)'1', hotKey);
+
+			text = "After 1_";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "Multiple _1 and _2";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (9, hotPos);
+			Assert.Equal ((Key)'1', hotKey);
+		}
+
+		[Fact]
+		public void FindHotKey_Legacy_FirstUpperCase_Succeeds ()
+		{
+			bool supportFirstUpperCase = true;
+
+			var text = ustring.Empty;
+			Rune hotKeySpecifier = (Rune)0;
+			int hotPos = 0;
+			Key hotKey = Key.Unknown;
+			bool result = false;
+
+			text = "K Before";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (0, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "aK Second";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (1, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "last K";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (5, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			text = "multiple K and R";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (9, hotPos);
+			Assert.Equal ((Key)'K', hotKey);
+
+			// Cryllic K (К)
+			text = "non-english: Кдать";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.True (result);
+			Assert.Equal (13, hotPos);
+			Assert.Equal ((Key)'К', hotKey);
+		}
+
+		[Fact]
+		public void FindHotKey_Legacy_FirstUpperCase_NotFound_Returns_False ()
+		{
+			bool supportFirstUpperCase = true;
+
+			var text = ustring.Empty;
+			Rune hotKeySpecifier = (Rune)0;
+			int hotPos = 0;
+			Key hotKey = Key.Unknown;
+			bool result = false;
+
+			text = "k before";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "ak second";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "last k";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "multiple k and r";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			text = "12345";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			// punctuation
+			text = "`~!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			// ~IsLetterOrDigit + Unicode
+			text = " ~  s  gui.cs   master ↑10";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+
+			// Lower case Cryllic K (к)
+			text = "non-english: кдать";
+			result = TextFormatter.FindHotKey (text, hotKeySpecifier, supportFirstUpperCase, out hotPos, out hotKey);
+			Assert.False (result);
+			Assert.Equal (-1, hotPos);
+			Assert.Equal (Key.Unknown, hotKey);
+		}
+
+		static ustring testHotKeyAtStart = "_K Before";
+		static ustring testHotKeyAtSecondPos = "a_K Second";
+		static ustring testHotKeyAtLastPos = "Last _K";
+		static ustring testHotKeyAfterLastChar = "After K_";
+		static ustring testMultiHotKeys = "Multiple _K and _R";
+		static ustring testNonEnglish = "Non-english: _Кдать";
+
+		[Fact]
+		public void RemoveHotKeySpecifier_InValid_ReturnsOriginal ()
+		{
+			Rune hotKeySpecifier = '_';
+
+			Assert.Null (TextFormatter.RemoveHotKeySpecifier (null, 0, hotKeySpecifier));
+			Assert.Equal ("", TextFormatter.RemoveHotKeySpecifier ("", 0, hotKeySpecifier));
+			Assert.Equal ("", TextFormatter.RemoveHotKeySpecifier ("", -1, hotKeySpecifier));
+			Assert.Equal ("", TextFormatter.RemoveHotKeySpecifier ("", 100, hotKeySpecifier));
+
+			Assert.Equal ("a", TextFormatter.RemoveHotKeySpecifier ("a", -1, hotKeySpecifier));
+			Assert.Equal ("a", TextFormatter.RemoveHotKeySpecifier ("a", 100, hotKeySpecifier));
+		}
+
+		[Fact]
+		public void RemoveHotKeySpecifier_Valid_ReturnsStripped ()
+		{
+			Rune hotKeySpecifier = '_';
+
+			Assert.Equal ("K Before", TextFormatter.RemoveHotKeySpecifier ("_K Before", 0, hotKeySpecifier));
+			Assert.Equal ("aK Second", TextFormatter.RemoveHotKeySpecifier ("a_K Second", 1, hotKeySpecifier));
+			Assert.Equal ("Last K", TextFormatter.RemoveHotKeySpecifier ("Last _K", 5, hotKeySpecifier));
+			Assert.Equal ("After K", TextFormatter.RemoveHotKeySpecifier ("After K_", 7, hotKeySpecifier));
+			Assert.Equal ("Multiple K and _R", TextFormatter.RemoveHotKeySpecifier ("Multiple _K and _R", 9, hotKeySpecifier));
+			Assert.Equal ("Non-english: Кдать", TextFormatter.RemoveHotKeySpecifier ("Non-english: _Кдать", 13, hotKeySpecifier));
+		}
+
+		[Fact]
+		public void RemoveHotKeySpecifier_Valid_Legacy_ReturnsOriginal ()
+		{
+			Rune hotKeySpecifier = '_';
+
+			Assert.Equal ("all lower case", TextFormatter.RemoveHotKeySpecifier ("all lower case", 0, hotKeySpecifier));
+			Assert.Equal ("K Before", TextFormatter.RemoveHotKeySpecifier ("K Before", 0, hotKeySpecifier));
+			Assert.Equal ("aK Second", TextFormatter.RemoveHotKeySpecifier ("aK Second", 1, hotKeySpecifier));
+			Assert.Equal ("Last K", TextFormatter.RemoveHotKeySpecifier ("Last K", 5, hotKeySpecifier));
+			Assert.Equal ("After K", TextFormatter.RemoveHotKeySpecifier ("After K", 7, hotKeySpecifier));
+			Assert.Equal ("Multiple K and R", TextFormatter.RemoveHotKeySpecifier ("Multiple K and R", 9, hotKeySpecifier));
+			Assert.Equal ("Non-english: Кдать", TextFormatter.RemoveHotKeySpecifier ("Non-english: Кдать", 13, hotKeySpecifier));
+		}
+
+		[Fact]
+		public void CalcRect_Invalid_Returns_Empty ()
+		{
+			Assert.Equal (Rect.Empty, TextFormatter.CalcRect (0, 0, null));
+			Assert.Equal (Rect.Empty, TextFormatter.CalcRect (0, 0, ""));
+			Assert.Equal (Rect.Empty, TextFormatter.CalcRect (1, 2, ""));
+			Assert.Equal (Rect.Empty, TextFormatter.CalcRect (-1, -2, ""));
+		}
+
+		[Fact]
+		public void CalcRect_SingleLine_Returns_1High ()
+		{
+			var text = ustring.Empty;
+
+			text = "test";
+			Assert.Equal (new Rect (0, 0, text.RuneCount, 1), TextFormatter.CalcRect (0, 0, text));
+		}
+
+		[Fact]
+		public void CalcRect_MultiLine_Returns_nHigh ()
+		{
+			var text = ustring.Empty;
+			var lines = 0;
+
+			text = "line1\nline2";
+			lines = 2;
+			Assert.Equal (new Rect (0, 0, 5, lines), TextFormatter.CalcRect (0, 0, text));
+
+			text = "line1\nline2\nline3long!";
+			lines = 3;
+			Assert.Equal (new Rect (0, 0, 10, lines), TextFormatter.CalcRect (0, 0, text));
+
+			text = "line1\nline2\n\n";
+			lines = 4;
+			Assert.Equal (new Rect (0, 0, 5, lines), TextFormatter.CalcRect (0, 0, text));
+
+			text = "line1\r\nline2";
+			lines = 2;
+			Assert.Equal (new Rect (0, 0, 5, lines), TextFormatter.CalcRect (0, 0, text));
+		}
+
+		[Fact]
+		public void ClipAndJustify_Invalid_Returns_Original ()
+		{
+			var text = ustring.Empty;
+
+			Assert.Equal (text, TextFormatter.ClipAndJustify (text, 0, TextAlignment.Left));
+
+			text = null;
+			Assert.Equal (text, TextFormatter.ClipAndJustify (text, 0, TextAlignment.Left));
+
+			text = "test";
+			Assert.Throws<ArgumentOutOfRangeException> (() => TextFormatter.ClipAndJustify (text, -1, TextAlignment.Left));
+		}
+
+		[Fact]
+		public void ClipAndJustify_Valid_Left ()
+		{
+			var align = TextAlignment.Left;
+
+			var text = ustring.Empty;
+			var justifiedText = ustring.Empty;
+			int width = 0;
+
+			text = "test";
+			width = 0;
+			Assert.Equal (ustring.Empty, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+
+			text = "test";
+			width = 2;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+
+			text = "test";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A\tsentence\thas\twords.";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A\tsentence\thas\twords.";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "line1\nline2\nline3long!";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "line1\nline2\nline3long!";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = " ~  s  gui.cs   master ↑10";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+		}
+
+		[Fact]
+		public void ClipAndJustify_Valid_Right ()
+		{
+			var align = TextAlignment.Right;
+
+			var text = ustring.Empty;
+			var justifiedText = ustring.Empty;
+			int width = 0;
+
+			text = "test";
+			width = 0;
+			Assert.Equal (ustring.Empty, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+
+			text = "test";
+			width = 2;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+
+			text = "test";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A\tsentence\thas\twords.";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A\tsentence\thas\twords.";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "line1\nline2\nline3long!";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "line1\nline2\nline3long!";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = " ~  s  gui.cs   master ↑10";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+		}
+
+		[Fact]
+		public void ClipAndJustify_Valid_Centered ()
+		{
+			var align = TextAlignment.Centered;
+
+			var text = ustring.Empty;
+			var justifiedText = ustring.Empty;
+			int width = 0;
+
+			text = "test";
+			width = 0;
+			Assert.Equal (ustring.Empty, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+
+			text = "test";
+			width = 2;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+
+			text = "test";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A\tsentence\thas\twords.";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A\tsentence\thas\twords.";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "line1\nline2\nline3long!";
+			width = int.MaxValue;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "line1\nline2\nline3long!";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = " ~  s  gui.cs   master ↑10";
+			width = 10;
+			Assert.Equal (text [0, width], justifiedText = TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "";
+			width = text.RuneCount;
+			Assert.Equal (text, justifiedText = TextFormatter.ClipAndJustify (text, width, align)); ;
+			Assert.True (justifiedText.RuneCount <= width);
+		}
+
+
+		[Fact]
+		public void ClipAndJustify_Valid_Justified ()
+		{
+			var text = ustring.Empty;
+			int width = 0;
+			var align = TextAlignment.Justified;
+
+			text = "test";
+			width = 0;
+			Assert.Equal (ustring.Empty, TextFormatter.ClipAndJustify (text, width, align));
+
+			text = "test";
+			width = 2;
+			Assert.Equal (text [0, width], TextFormatter.ClipAndJustify (text, width, align));
+
+			text = "test";
+			width = int.MaxValue;
+			Assert.Equal (text, TextFormatter.ClipAndJustify (text, width, align));
+			Assert.True (text.RuneCount <= width);
+
+			// see Justify_ tests below
+
+		}
+
+		[Fact]
+		public void Justify_Invalid ()
+		{
+			var text = ustring.Empty;
+			Assert.Equal (text, TextFormatter.Justify (text, 0));
+
+			text = null;
+			Assert.Equal (text, TextFormatter.Justify (text, 0));
+
+			text = "test";
+			Assert.Throws<ArgumentOutOfRangeException> (() => TextFormatter.Justify (text, -1));
+		}
+
+		[Fact]
+		public void Justify_SingleWord ()
+		{
+			var text = ustring.Empty;
+			var justifiedText = ustring.Empty;
+			int width = 0;
+			char fillChar = '+';
+
+			text = "word";
+			justifiedText = "word";
+			width = text.RuneCount;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+
+			text = "word";
+			justifiedText = "word";
+			width = text.RuneCount + 1;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+
+			text = "word";
+			justifiedText = "word";
+			width = text.RuneCount + 2;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+
+			text = "word";
+			justifiedText = "word";
+			width = text.RuneCount + 10;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+
+			text = "word";
+			justifiedText = "word";
+			width = text.RuneCount + 11;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+		}
+
+		[Fact]
+		public void Justify_Sentence ()
+		{
+			var text = ustring.Empty;
+			var justifiedText = ustring.Empty;
+			int width = 0;
+			char fillChar = '+';
+
+			text = "A sentence has words.";
+			justifiedText = "A+sentence+has+words.";
+			width = text.RuneCount;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			justifiedText = "A+sentence+has+words.";
+			width = text.RuneCount + 1;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			justifiedText = "A+sentence+has+words.";
+			width = text.RuneCount + 2;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			justifiedText = "A++sentence++has++words.";
+			width = text.RuneCount + 3;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			justifiedText = "A++sentence++has++words.";
+			width = text.RuneCount + 4;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			justifiedText = "A++sentence++has++words.";
+			width = text.RuneCount + 5;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			justifiedText = "A+++sentence+++has+++words.";
+			width = text.RuneCount + 6;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			justifiedText = "A+++++++sentence+++++++has+++++++words.";
+			width = text.RuneCount + 20;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			text = "A sentence has words.";
+			justifiedText = "A++++++++sentence++++++++has++++++++words.";
+			width = text.RuneCount + 23;
+			Assert.Equal (justifiedText.ToString (), TextFormatter.Justify (text, width, fillChar).ToString ());
+			Assert.True (justifiedText.RuneCount <= width);
+
+			//TODO: Unicode
+		}
+
+		[Fact]
+		public void WordWrap_Invalid ()
+		{
+			var text = ustring.Empty;
+			int width = 0;
+
+			Assert.Empty (TextFormatter.WordWrap (null, width));
+			Assert.Empty (TextFormatter.WordWrap (text, width));
+			Assert.Throws<ArgumentOutOfRangeException> (() => TextFormatter.WordWrap (text, -1));
+		}
+
+		[Fact]
+		public void WordWrap_SingleWordLine ()
+		{
+			var text = ustring.Empty;
+			int width = 0;
+			List<ustring> wrappedLines;
+
+			text = "Constantinople";
+			width = text.RuneCount;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.True (wrappedLines.Count == 1);
+
+			width = text.RuneCount - 1;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (2, wrappedLines.Count);
+			Assert.Equal (text [0, text.RuneCount - 1].ToString (), wrappedLines [0].ToString ());
+			Assert.Equal ("e", wrappedLines [1].ToString ());
+
+			width = text.RuneCount - 2;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (2, wrappedLines.Count);
+			Assert.Equal (text [0, text.RuneCount - 2].ToString (), wrappedLines [0].ToString ());
+
+			width = text.RuneCount - 5;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (2, wrappedLines.Count);
+
+			width = (int)Math.Ceiling ((double)(text.RuneCount / 2F));
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (2, wrappedLines.Count);
+			Assert.Equal ("Constan", wrappedLines [0].ToString ());
+			Assert.Equal ("tinople", wrappedLines [1].ToString ());
+
+			width = (int)Math.Ceiling ((double)(text.RuneCount / 3F));
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (3, wrappedLines.Count);
+			Assert.Equal ("Const", wrappedLines [0].ToString ());
+			Assert.Equal ("antin", wrappedLines [1].ToString ());
+			Assert.Equal ("ople", wrappedLines [2].ToString ());
+
+			width = (int)Math.Ceiling ((double)(text.RuneCount / 4F));
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (4, wrappedLines.Count);
+
+			width = (int)Math.Ceiling ((double)text.RuneCount / text.RuneCount);
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (text.RuneCount, wrappedLines.Count);
+			Assert.Equal ("C", wrappedLines [0].ToString ());
+			Assert.Equal ("o", wrappedLines [1].ToString ());
+			Assert.Equal ("n", wrappedLines [2].ToString ());
+			Assert.Equal ("s", wrappedLines [3].ToString ());
+			Assert.Equal ("t", wrappedLines [4].ToString ());
+			Assert.Equal ("a", wrappedLines [5].ToString ());
+			Assert.Equal ("n", wrappedLines [6].ToString ());
+			Assert.Equal ("t", wrappedLines [7].ToString ());
+			Assert.Equal ("i", wrappedLines [8].ToString ());
+			Assert.Equal ("n", wrappedLines [9].ToString ());
+			Assert.Equal ("o", wrappedLines [10].ToString ());
+			Assert.Equal ("p", wrappedLines [11].ToString ());
+			Assert.Equal ("l", wrappedLines [12].ToString ());
+			Assert.Equal ("e", wrappedLines [13].ToString ());
+		}
+
+		[Fact]
+		public void WordWrap_NoNewLines ()
+		{
+			var text = ustring.Empty;
+			int width = 0;
+			List<ustring> wrappedLines;
+
+			text = "A sentence has words.";
+			width = text.RuneCount;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.True (wrappedLines.Count == 1);
+
+			width = text.RuneCount - 1;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (2, wrappedLines.Count);
+			Assert.Equal ("A sentence has", wrappedLines [0].ToString ());
+			Assert.Equal ("words.", wrappedLines [1].ToString ());
+
+			width = text.RuneCount - "words.".Length;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (2, wrappedLines.Count);
+			Assert.Equal ("A sentence has", wrappedLines [0].ToString ());
+			Assert.Equal ("words.", wrappedLines [1].ToString ());
+
+			width = text.RuneCount - " words.".Length;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (2, wrappedLines.Count);
+			Assert.Equal ("A sentence has", wrappedLines [0].ToString ());
+			Assert.Equal ("words.", wrappedLines [1].ToString ());
+
+			width = text.RuneCount - "s words.".Length;
+			wrappedLines = TextFormatter.WordWrap (text, width);
+			Assert.Equal (2, wrappedLines.Count);
+			Assert.Equal ("A sentence", wrappedLines [0].ToString ());
+			Assert.Equal ("has words.", wrappedLines [1].ToString ());
+
+			// Unicode 
+			// TODO: Lots of bugs
+			//text = "A Unicode sentence (пÑивеÑ) has words.";
+			//width = text.RuneCount;
+			//wrappedLines = TextFormatter.WordWrap (text, width);
+			//Assert.True (wrappedLines.Count == 1);
+
+			//width = text.RuneCount - 1;
+			//wrappedLines = TextFormatter.WordWrap (text, width);
+			//Assert.Equal (2, wrappedLines.Count);
+			//Assert.Equal ("A Unicode sentence (пÑивеÑ) has", wrappedLines [0].ToString ());
+			//Assert.Equal ("words.", wrappedLines [1].ToString ());
+
+			//width = text.RuneCount - "words.".Length;
+			//wrappedLines = TextFormatter.WordWrap (text, width);
+			//Assert.Equal (2, wrappedLines.Count);
+			//Assert.Equal ("A Unicode sentence (пÑивеÑ) has", wrappedLines [0].ToString ());
+			//Assert.Equal ("words.", wrappedLines [1].ToString ());
+
+			//width = text.RuneCount - " words.".Length;
+			//wrappedLines = TextFormatter.WordWrap (text, width);
+			//Assert.Equal (2, wrappedLines.Count);
+			//Assert.Equal ("A Unicode sentence (пÑивеÑ) has", wrappedLines [0].ToString ());
+			//Assert.Equal ("words.", wrappedLines [1].ToString ());
+
+			//width = text.RuneCount - "s words.".Length;
+			//wrappedLines = TextFormatter.WordWrap (text, width);
+			//Assert.Equal (2, wrappedLines.Count);
+			//Assert.Equal ("A Unicode sentence (пÑивеÑ)", wrappedLines [0].ToString ());
+			//Assert.Equal ("has words.", wrappedLines [1].ToString ());
+
+			//width = text.RuneCount - "веÑ) has words.".Length;
+			//wrappedLines = TextFormatter.WordWrap (text, width);
+			//Assert.Equal (2, wrappedLines.Count);
+			//Assert.Equal ("A Unicode sentence", wrappedLines [0].ToString ());
+			//Assert.Equal ("(пÑивеÑ) has words.", wrappedLines [1].ToString ());
+
+		}
+
+		[Fact]
+		public void ReplaceHotKeyWithTag ()
+		{
+			ustring text = "test";
+			int hotPos = 0;
+			uint tag = 0x100000 | 't';
+
+			Assert.Equal (ustring.Make (new Rune [] { tag, 'e', 's', 't' }), TextFormatter.ReplaceHotKeyWithTag (text, hotPos));
+
+			tag = 0x100000 | 'e';
+			hotPos = 1;
+			Assert.Equal (ustring.Make (new Rune [] { 't', tag, 's', 't' }), TextFormatter.ReplaceHotKeyWithTag (text, hotPos));
+
+			var result = TextFormatter.ReplaceHotKeyWithTag (text, hotPos);
+			Assert.Equal ('e', (uint)(result.ToRunes () [1] & ~0x100000));
+
+			text = "Ok";
+			tag = 0x100000 | 'O';
+			hotPos = 0;
+			Assert.Equal (ustring.Make (new Rune [] { tag, 'k' }), result = TextFormatter.ReplaceHotKeyWithTag (text, hotPos));
+			Assert.Equal ('O', (uint)(result.ToRunes () [0] & ~0x100000));
+
+			text = "[◦ Ok ◦]";
+			text = ustring.Make(new Rune [] { '[', '◦', ' ', 'O', 'k', ' ', '◦', ']' });
+			var runes = text.ToRuneList ();
+			Assert.Equal (text.RuneCount, runes.Count);
+			Assert.Equal (text, ustring.Make(runes));
+			tag = 0x100000 | 'O';
+			hotPos = 3;
+			Assert.Equal (ustring.Make (new Rune [] { '[', '◦', ' ', tag, 'k', ' ', '◦', ']' }), result = TextFormatter.ReplaceHotKeyWithTag (text, hotPos));
+			Assert.Equal ('O', (uint)(result.ToRunes () [3] & ~0x100000));
+
+			text = "^k";
+			tag = '^';
+			hotPos = 0;
+			Assert.Equal (ustring.Make (new Rune [] { tag, 'k' }), result = TextFormatter.ReplaceHotKeyWithTag (text, hotPos));
+			Assert.Equal ('^', (uint)(result.ToRunes () [0] & ~0x100000));
+
+		}
+	}
+}

+ 10 - 4
UnitTests/UnitTests.csproj

@@ -6,11 +6,17 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" />
     <PackageReference Include="System.Collections" Version="4.3.0" />
-    <PackageReference Include="xunit" Version="2.4.0" />
-    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" />
-    <PackageReference Include="coverlet.collector" Version="1.2.0" />
+    <PackageReference Include="xunit" Version="2.4.1" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.2">
+      <PrivateAssets>all</PrivateAssets>
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+    </PackageReference>
+    <PackageReference Include="coverlet.collector" Version="1.3.0">
+      <PrivateAssets>all</PrivateAssets>
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+    </PackageReference>
   </ItemGroup>
 
   <ItemGroup>