TextFormatter.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Globalization;
  4. using System.Linq;
  5. using NStack;
  6. namespace Terminal.Gui {
  7. /// <summary>
  8. /// Text alignment enumeration, controls how text is displayed.
  9. /// </summary>
  10. public enum TextAlignment {
  11. /// <summary>
  12. /// Aligns the text to the left of the frame.
  13. /// </summary>
  14. Left,
  15. /// <summary>
  16. /// Aligns the text to the right side of the frame.
  17. /// </summary>
  18. Right,
  19. /// <summary>
  20. /// Centers the text in the frame.
  21. /// </summary>
  22. Centered,
  23. /// <summary>
  24. /// Shows the text as justified text in the frame.
  25. /// </summary>
  26. Justified
  27. }
  28. /// <summary>
  29. /// Provides text formatting capabilites for console apps. Supports, hotkeys, horizontal alignment, multille lines, and word-based line wrap.
  30. /// </summary>
  31. public class TextFormatter {
  32. List<ustring> lines = new List<ustring> ();
  33. ustring text;
  34. TextAlignment textAlignment;
  35. Attribute textColor = -1;
  36. bool needsFormat;
  37. Key hotKey;
  38. Size size;
  39. /// <summary>
  40. /// The text to be displayed. This text is never modified.
  41. /// </summary>
  42. public virtual ustring Text {
  43. get => text;
  44. set {
  45. text = value;
  46. if (Size.IsEmpty) {
  47. // Proivde a default size (width = length of longest line, height = 1)
  48. // TODO: It might makem more sense for the default to be width = length of first line?
  49. Size = new Size (TextFormatter.MaxWidth (Text, int.MaxValue), 1);
  50. }
  51. NeedsFormat = true;
  52. }
  53. }
  54. // TODO: Add Vertical Text Alignment
  55. /// <summary>
  56. /// Controls the horizontal text-alignment property.
  57. /// </summary>
  58. /// <value>The text alignment.</value>
  59. public TextAlignment Alignment {
  60. get => textAlignment;
  61. set {
  62. textAlignment = value;
  63. NeedsFormat = true;
  64. }
  65. }
  66. /// <summary>
  67. /// Gets or sets the size of the area the text will be constrainted to when formatted.
  68. /// </summary>
  69. public Size Size {
  70. get => size;
  71. set {
  72. size = value;
  73. NeedsFormat = true;
  74. }
  75. }
  76. /// <summary>
  77. /// The specifier character for the hotkey (e.g. '_'). Set to '\xffff' to disable hotkey support for this View instance. The default is '\xffff'.
  78. /// </summary>
  79. public Rune HotKeySpecifier { get; set; } = (Rune)0xFFFF;
  80. /// <summary>
  81. /// The position in the text of the hotkey. The hotkey will be rendered using the hot color.
  82. /// </summary>
  83. public int HotKeyPos { get => hotKeyPos; set => hotKeyPos = value; }
  84. /// <summary>
  85. /// Gets the hotkey. Will be an upper case letter or digit.
  86. /// </summary>
  87. public Key HotKey { get => hotKey; internal set => hotKey = value; }
  88. /// <summary>
  89. /// Specifies the mask to apply to the hotkey to tag it as the hotkey. The default value of <c>0x100000</c> causes
  90. /// the underlying Rune to be identified as a "private use" Unicode character.
  91. /// </summary>HotKeyTagMask
  92. public uint HotKeyTagMask { get; set; } = 0x100000;
  93. /// <summary>
  94. /// Gets the formatted lines.
  95. /// </summary>
  96. /// <remarks>
  97. /// <para>
  98. /// Upon a 'get' of this property, if the text needs to be formatted (if <see cref="NeedsFormat"/> is <c>true</c>)
  99. /// <see cref="Format(ustring, int, TextAlignment, bool)"/> will be called internally.
  100. /// </para>
  101. /// </remarks>
  102. public List<ustring> Lines {
  103. get {
  104. // With this check, we protect against subclasses with overrides of Text
  105. if (ustring.IsNullOrEmpty (Text)) {
  106. lines = new List<ustring> ();
  107. lines.Add (ustring.Empty);
  108. NeedsFormat = false;
  109. return lines;
  110. }
  111. if (NeedsFormat) {
  112. var shown_text = text;
  113. if (FindHotKey (text, HotKeySpecifier, true, out hotKeyPos, out hotKey)) {
  114. shown_text = RemoveHotKeySpecifier (Text, hotKeyPos, HotKeySpecifier);
  115. shown_text = ReplaceHotKeyWithTag (shown_text, hotKeyPos);
  116. }
  117. if (Size.IsEmpty) {
  118. throw new InvalidOperationException ("Size must be set before accessing Lines");
  119. }
  120. lines = Format (shown_text, Size.Width, textAlignment, Size.Height > 1);
  121. NeedsFormat = false;
  122. }
  123. return lines;
  124. }
  125. }
  126. /// <summary>
  127. /// Gets or sets whether the <see cref="TextFormatter"/> needs to format the text when <see cref="Draw(Rect, Attribute, Attribute)"/> is called.
  128. /// If it is <c>false</c> when Draw is called, the Draw call will be faster.
  129. /// </summary>
  130. /// <remarks>
  131. /// <para>
  132. /// This is set to true when the properties of <see cref="TextFormatter"/> are set.
  133. /// </para>
  134. /// </remarks>
  135. public bool NeedsFormat { get => needsFormat; set => needsFormat = value; }
  136. static ustring StripCRLF (ustring str)
  137. {
  138. var runes = str.ToRuneList ();
  139. for (int i = 0; i < runes.Count; i++) {
  140. switch (runes [i]) {
  141. case '\n':
  142. runes.RemoveAt (i);
  143. break;
  144. case '\r':
  145. if ((i + 1) < runes.Count && runes [i + 1] == '\n') {
  146. runes.RemoveAt (i);
  147. runes.RemoveAt (i + 1);
  148. i++;
  149. }
  150. break;
  151. }
  152. }
  153. return ustring.Make (runes);
  154. }
  155. static ustring ReplaceCRLFWithSpace (ustring str)
  156. {
  157. var runes = str.ToRuneList ();
  158. for (int i = 0; i < runes.Count; i++) {
  159. switch (runes [i]) {
  160. case '\n':
  161. runes [i] = (Rune)' ';
  162. break;
  163. case '\r':
  164. if ((i + 1) < runes.Count && runes [i + 1] == '\n') {
  165. runes [i] = (Rune)' ';
  166. runes.RemoveAt (i + 1);
  167. i++;
  168. }
  169. break;
  170. }
  171. }
  172. return ustring.Make (runes); ;
  173. }
  174. /// <summary>
  175. /// Formats the provided text to fit within the width provided using word wrapping.
  176. /// </summary>
  177. /// <param name="text">The text to word warp</param>
  178. /// <param name="width">The width to contrain the text to</param>
  179. /// <returns>Returns a list of word wrapped lines.</returns>
  180. /// <remarks>
  181. /// <para>
  182. /// This method does not do any justification.
  183. /// </para>
  184. /// <para>
  185. /// Newlines ('\n' and '\r\n') sequences are honored, adding the appropriate lines to the output.
  186. /// </para>
  187. /// </remarks>
  188. public static List<ustring> WordWrap (ustring text, int width)
  189. {
  190. if (width < 0) {
  191. throw new ArgumentOutOfRangeException ("Width cannot be negative.");
  192. }
  193. int start = 0, end;
  194. var lines = new List<ustring> ();
  195. if (ustring.IsNullOrEmpty (text)) {
  196. return lines;
  197. }
  198. var runes = StripCRLF (text).ToRuneList ();
  199. while ((end = start + width) < runes.Count) {
  200. while (runes [end] != ' ' && end > start)
  201. end -= 1;
  202. if (end == start)
  203. end = start + width;
  204. lines.Add (ustring.Make (runes.GetRange (start, end - start)).TrimSpace ());
  205. start = end;
  206. }
  207. if (start < text.RuneCount) {
  208. lines.Add (ustring.Make (runes.GetRange (start, runes.Count - start)).TrimSpace ());
  209. }
  210. return lines;
  211. }
  212. /// <summary>
  213. /// Justifies text within a specified width.
  214. /// </summary>
  215. /// <param name="text">The text to justify.</param>
  216. /// <param name="width">If the text length is greater that <c>width</c> it will be clipped.</param>
  217. /// <param name="talign">Alignment.</param>
  218. /// <returns>Justified and clipped text.</returns>
  219. public static ustring ClipAndJustify (ustring text, int width, TextAlignment talign)
  220. {
  221. if (width < 0) {
  222. throw new ArgumentOutOfRangeException ("Width cannot be negative.");
  223. }
  224. if (ustring.IsNullOrEmpty (text)) {
  225. return text;
  226. }
  227. var runes = text.ToRuneList ();
  228. int slen = runes.Count;
  229. if (slen > width) {
  230. return ustring.Make (runes.GetRange (0, width));
  231. } else {
  232. if (talign == TextAlignment.Justified) {
  233. return Justify (text, width);
  234. }
  235. return text;
  236. }
  237. }
  238. /// <summary>
  239. /// Justifies the text to fill the width provided. Space will be added between words (demarked by spaces and tabs) to
  240. /// make the text just fit <c>width</c>. Spaces will not be added to the ends.
  241. /// </summary>
  242. /// <param name="text"></param>
  243. /// <param name="width"></param>
  244. /// <param name="spaceChar">Character to replace whitespace and pad with. For debugging purposes.</param>
  245. /// <returns>The justifed text.</returns>
  246. public static ustring Justify (ustring text, int width, char spaceChar = ' ')
  247. {
  248. if (width < 0) {
  249. throw new ArgumentOutOfRangeException ("Width cannot be negative.");
  250. }
  251. if (ustring.IsNullOrEmpty (text)) {
  252. return text;
  253. }
  254. // TODO: Use ustring
  255. var words = text.Split (ustring.Make (' '));// whitespace, StringSplitOptions.RemoveEmptyEntries);
  256. int textCount = words.Sum (arg => arg.RuneCount);
  257. var spaces = words.Length > 1 ? (width - textCount) / (words.Length - 1) : 0;
  258. var extras = words.Length > 1 ? (width - textCount) % words.Length : 0;
  259. var s = new System.Text.StringBuilder ();
  260. //s.Append ($"tc={textCount} sp={spaces},x={extras} - ");
  261. for (int w = 0; w < words.Length; w++) {
  262. var x = words [w];
  263. s.Append (x);
  264. if (w + 1 < words.Length)
  265. for (int i = 0; i < spaces; i++)
  266. s.Append (spaceChar);
  267. if (extras > 0) {
  268. //s.Append ('_');
  269. extras--;
  270. }
  271. }
  272. return ustring.Make (s.ToString ());
  273. }
  274. static char [] whitespace = new char [] { ' ', '\t' };
  275. private int hotKeyPos;
  276. /// <summary>
  277. /// Reformats text into lines, applying text alignment and optionally wrapping text to new lines on word boundaries.
  278. /// </summary>
  279. /// <param name="text"></param>
  280. /// <param name="width">The width to bound the text to for word wrapping and clipping.</param>
  281. /// <param name="talign">Specifies how the text will be aligned horizontally.</param>
  282. /// <param name="wordWrap">If <c>true</c>, the text will be wrapped to new lines as need. If <c>false</c>, forces text to fit a single line. Line breaks are converted to spaces. The text will be clipped to <c>width</c></param>
  283. /// <returns>A list of word wrapped lines.</returns>
  284. /// <remarks>
  285. /// <para>
  286. /// An empty <c>text</c> string will result in one empty line.
  287. /// </para>
  288. /// <para>
  289. /// If <c>width</c> is 0, a single, empty line will be returned.
  290. /// </para>
  291. /// <para>
  292. /// If <c>width</c> is int.MaxValue, the text will be formatted to the maximum width possible.
  293. /// </para>
  294. /// </remarks>
  295. public static List<ustring> Format (ustring text, int width, TextAlignment talign, bool wordWrap)
  296. {
  297. if (width < 0) {
  298. throw new ArgumentOutOfRangeException ("width cannot be negative");
  299. }
  300. List<ustring> lineResult = new List<ustring> ();
  301. if (ustring.IsNullOrEmpty (text) || width == 0) {
  302. lineResult.Add (ustring.Empty);
  303. return lineResult;
  304. }
  305. if (wordWrap == false) {
  306. text = ReplaceCRLFWithSpace (text);
  307. lineResult.Add (ClipAndJustify (text, width, talign));
  308. return lineResult;
  309. }
  310. var runes = text.ToRuneList ();
  311. int runeCount = runes.Count;
  312. int lp = 0;
  313. for (int i = 0; i < runeCount; i++) {
  314. Rune c = text [i];
  315. if (c == '\n') {
  316. var wrappedLines = WordWrap (ustring.Make (runes.GetRange (lp, i - lp)), width);
  317. foreach (var line in wrappedLines) {
  318. lineResult.Add (ClipAndJustify (line, width, talign));
  319. }
  320. if (wrappedLines.Count == 0) {
  321. lineResult.Add (ustring.Empty);
  322. }
  323. lp = i + 1;
  324. }
  325. }
  326. foreach (var line in WordWrap (ustring.Make (runes.GetRange (lp, runeCount - lp)), width)) {
  327. lineResult.Add (ClipAndJustify (line, width, talign));
  328. }
  329. return lineResult;
  330. }
  331. /// <summary>
  332. /// Computes the number of lines needed to render the specified text given the width.
  333. /// </summary>
  334. /// <returns>Number of lines.</returns>
  335. /// <param name="text">Text, may contain newlines.</param>
  336. /// <param name="width">The minimum width for the text.</param>
  337. public static int MaxLines (ustring text, int width)
  338. {
  339. var result = TextFormatter.Format (text, width, TextAlignment.Left, true);
  340. return result.Count;
  341. }
  342. /// <summary>
  343. /// Computes the maximum width needed to render the text (single line or multple lines) given a minimium width.
  344. /// </summary>
  345. /// <returns>Max width of lines.</returns>
  346. /// <param name="text">Text, may contain newlines.</param>
  347. /// <param name="width">The minimum width for the text.</param>
  348. public static int MaxWidth (ustring text, int width)
  349. {
  350. var result = TextFormatter.Format (text, width, TextAlignment.Left, true);
  351. return result.Max (s => s.RuneCount);
  352. }
  353. /// <summary>
  354. /// Calculates the rectangle requried to hold text, assuming no word wrapping.
  355. /// </summary>
  356. /// <param name="x">The x location of the rectangle</param>
  357. /// <param name="y">The y location of the rectangle</param>
  358. /// <param name="text">The text to measure</param>
  359. /// <returns></returns>
  360. public static Rect CalcRect (int x, int y, ustring text)
  361. {
  362. if (ustring.IsNullOrEmpty (text))
  363. return Rect.Empty;
  364. int mw = 0;
  365. int ml = 1;
  366. int cols = 0;
  367. foreach (var rune in text) {
  368. if (rune == '\n') {
  369. ml++;
  370. if (cols > mw)
  371. mw = cols;
  372. cols = 0;
  373. } else {
  374. if (rune != '\r') {
  375. cols++;
  376. }
  377. }
  378. }
  379. if (cols > mw)
  380. mw = cols;
  381. return new Rect (x, y, mw, ml);
  382. }
  383. /// <summary>
  384. /// Finds the hotkey and its location in text.
  385. /// </summary>
  386. /// <param name="text">The text to look in.</param>
  387. /// <param name="hotKeySpecifier">The hotkey specifier (e.g. '_') to look for.</param>
  388. /// <param name="firstUpperCase">If <c>true</c> the legacy behavior of identifying the first upper case character as the hotkey will be eanbled.
  389. /// Regardless of the value of this parameter, <c>hotKeySpecifier</c> takes precidence.</param>
  390. /// <param name="hotPos">Outputs the Rune index into <c>text</c>.</param>
  391. /// <param name="hotKey">Outputs the hotKey.</param>
  392. /// <returns><c>true</c> if a hotkey was found; <c>false</c> otherwise.</returns>
  393. public static bool FindHotKey (ustring text, Rune hotKeySpecifier, bool firstUpperCase, out int hotPos, out Key hotKey)
  394. {
  395. if (ustring.IsNullOrEmpty (text) || hotKeySpecifier == (Rune)0xFFFF) {
  396. hotPos = -1;
  397. hotKey = Key.Unknown;
  398. return false;
  399. }
  400. Rune hot_key = (Rune)0;
  401. int hot_pos = -1;
  402. // Use first hot_key char passed into 'hotKey'.
  403. // TODO: Ignore hot_key of two are provided
  404. // TODO: Do not support non-alphanumeric chars that can't be typed
  405. int i = 0;
  406. foreach (Rune c in text) {
  407. if ((char)c != 0xFFFD) {
  408. if (c == hotKeySpecifier) {
  409. hot_pos = i;
  410. } else if (hot_pos > -1) {
  411. hot_key = c;
  412. break;
  413. }
  414. }
  415. i++;
  416. }
  417. // Legacy support - use first upper case char if the specifier was not found
  418. if (hot_pos == -1 && firstUpperCase) {
  419. i = 0;
  420. foreach (Rune c in text) {
  421. if ((char)c != 0xFFFD) {
  422. if (Rune.IsUpper (c)) {
  423. hot_key = c;
  424. hot_pos = i;
  425. break;
  426. }
  427. }
  428. i++;
  429. }
  430. }
  431. if (hot_key != (Rune)0 && hot_pos != -1) {
  432. hotPos = hot_pos;
  433. if (hot_key.IsValid && char.IsLetterOrDigit ((char)hot_key)) {
  434. hotKey = (Key)char.ToUpperInvariant ((char)hot_key);
  435. return true;
  436. }
  437. }
  438. hotPos = -1;
  439. hotKey = Key.Unknown;
  440. return false;
  441. }
  442. /// <summary>
  443. /// Replaces the Rune at the index specfiied by the <c>hotPos</c> parameter with a tag identifying
  444. /// it as the hotkey.
  445. /// </summary>
  446. /// <param name="text">The text to tag the hotkey in.</param>
  447. /// <param name="hotPos">The Rune index of the hotkey in <c>text</c>.</param>
  448. /// <returns>The text with the hotkey tagged.</returns>
  449. /// <remarks>
  450. /// The returned string will not render correctly without first un-doing the tag. To undo the tag, search for
  451. /// Runes with a bitmask of <c>otKeyTagMask</c> and remove that bitmask.
  452. /// </remarks>
  453. public ustring ReplaceHotKeyWithTag (ustring text, int hotPos)
  454. {
  455. // Set the high bit
  456. var runes = text.ToRuneList ();
  457. if (Rune.IsLetterOrNumber (runes [hotPos])) {
  458. runes [hotPos] = new Rune ((uint)runes [hotPos] | HotKeyTagMask);
  459. }
  460. return ustring.Make (runes);
  461. }
  462. /// <summary>
  463. /// Removes the hotkey specifier from text.
  464. /// </summary>
  465. /// <param name="text">The text to manipulate.</param>
  466. /// <param name="hotKeySpecifier">The hot-key specifier (e.g. '_') to look for.</param>
  467. /// <param name="hotPos">Returns the postion of the hot-key in the text. -1 if not found.</param>
  468. /// <returns>The input text with the hotkey specifier ('_') removed.</returns>
  469. public static ustring RemoveHotKeySpecifier (ustring text, int hotPos, Rune hotKeySpecifier)
  470. {
  471. if (ustring.IsNullOrEmpty (text)) {
  472. return text;
  473. }
  474. // Scan
  475. ustring start = ustring.Empty;
  476. int i = 0;
  477. foreach (Rune c in text) {
  478. if (c == hotKeySpecifier && i == hotPos) {
  479. i++;
  480. continue;
  481. }
  482. start += ustring.Make (c);
  483. i++;
  484. }
  485. return start;
  486. }
  487. /// <summary>
  488. /// Draws the text held by <see cref="TextFormatter"/> to <see cref="Application.Driver"/> using the colors specified.
  489. /// </summary>
  490. /// <param name="bounds">Specifies the screen-relative location and maximum size for drawing the text.</param>
  491. /// <param name="normalColor">The color to use for all text except the hotkey</param>
  492. /// <param name="hotColor">The color to use to draw the hotkey</param>
  493. public void Draw (Rect bounds, Attribute normalColor, Attribute hotColor)
  494. {
  495. // With this check, we protect against subclasses with overrides of Text (like Button)
  496. if (ustring.IsNullOrEmpty (text)) {
  497. return;
  498. }
  499. Application.Driver?.SetAttribute (normalColor);
  500. // Use "Lines" to ensure a Format (don't use "lines"))
  501. for (int line = 0; line < Lines.Count; line++) {
  502. if (line > bounds.Height)
  503. continue;
  504. var runes = lines [line].ToRunes ();
  505. int x;
  506. switch (textAlignment) {
  507. case TextAlignment.Left:
  508. x = bounds.Left;
  509. break;
  510. case TextAlignment.Justified:
  511. x = bounds.Left;
  512. break;
  513. case TextAlignment.Right:
  514. x = bounds.Right - runes.Length;
  515. break;
  516. case TextAlignment.Centered:
  517. x = bounds.Left + (bounds.Width - runes.Length) / 2;
  518. break;
  519. default:
  520. throw new ArgumentOutOfRangeException ();
  521. }
  522. for (var col = bounds.Left; col < bounds.Left + bounds.Width; col++) {
  523. Application.Driver?.Move (col, bounds.Top + line);
  524. var rune = (Rune)' ';
  525. if (col >= x && col < (x + runes.Length)) {
  526. rune = runes [col - x];
  527. }
  528. if ((rune & HotKeyTagMask) == HotKeyTagMask) {
  529. Application.Driver?.SetAttribute (hotColor);
  530. Application.Driver?.AddRune ((Rune)((uint)rune & ~HotKeyTagMask));
  531. Application.Driver?.SetAttribute (normalColor);
  532. } else {
  533. Application.Driver?.AddRune (rune);
  534. }
  535. }
  536. }
  537. }
  538. }
  539. }