/
MGTextLine.cs
502 lines (436 loc) · 27.1 KB
/
MGTextLine.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
using Microsoft.Xna.Framework;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MGUI.Shared.Helpers;
using Microsoft.Xna.Framework.Graphics;
using MonoGame.Extended;
namespace MGUI.Core.UI.Text
{
public interface ITextMeasurer
{
/// <param name="IgnoreFirstGlyphNegativeLeftSideBearing">Typically true for the first glyph of a line that is being rendered, but false in all other cases.<para/>
/// See also: <see cref="SpriteFont.MeasureString(string)"/> source code at:<br/>
/// <see href="https://github.com/MonoGame/MonoGame/blob/develop/MonoGame.Framework/Graphics/SpriteFont.cs"/><para/>
/// <code>
/// if (firstGlyphOfLine) {
/// offset.X = Math.Max(pCurrentGlyph->LeftSideBearing, 0);
/// firstGlyphOfLine = false;
/// }
/// </code></param>
Vector2 MeasureText(string Text, bool IsBold, bool IsItalic, bool IgnoreFirstGlyphNegativeLeftSideBearing);
}
public record class MGTextLine
{
public ReadOnlyCollection<MGTextRun> Runs { get; }
public float LineWidth { get; }
/// <summary>The height of the text-content of this line.<para/>
/// If the line only includes text-content (See: <see cref="MGTextRunText"/>), then this value is typically equivalent to <see cref="LineTotalHeight"/> (unless the line has a MinimumHeight, which is usually the height of a space ' ' character).<br/>
/// If the line includes images (See: <see cref="MGTextRunImage"/>), then this value may differ from <see cref="LineTotalHeight"/>, depending on if the tallest image is taller than the tallest piece of text.</summary>
public float LineTextHeight { get; }
/// <summary>The height of the image-content of this line.<para/>
/// If the line only includes image-content (See: <see cref="MGTextRunImage"/>), then this value is equivalent to <see cref="LineTotalHeight"/> (unless the line has a MinimumHeight, which is usually the height of a space ' ' character).<br/>
/// If the line includes text (See: <see cref="MGTextRunText"/>), then this value may differ from <see cref="LineTotalHeight"/>, depending on if the tallest piece of text is taller than the tallest image.</summary>
public float LineImageHeight { get; }
/// <summary>The total height of all content of this line.<para/>
/// If the line only includes text-content (See: <see cref="MGTextRunText"/>), then this value is typically equivalent to <see cref="LineTextHeight"/>.<br/>
/// If the line only includes image-content (See: <see cref="MGTextRunImage"/>), then this value is typically equivalent to <see cref="LineImageHeight"/>.<br/>
/// If the line includes both text and images, then represents the larger value of <see cref="LineTextHeight"/> and <see cref="LineImageHeight"/>.<para/>
/// Note: In some cases, the line may have a MinimumHeight (usually the height of a space ' ' character)<br/>
/// which may cause this value to be larger than <see cref="LineTextHeight"/> and/or <see cref="LineImageHeight"/></summary>
public float LineTotalHeight { get; }
/// <summary>The 1-based line number. This value does not use 0-based indexing.</summary>
public int LineNumber { get; }
/// <summary>True if the end of this line is due to a linebreak. False if the end of this line is due to text wrapping or because there is no more text afterwards.</summary>
public bool EndsInLinebreakCharacter { get; }
public ReadOnlyCollection<int> OriginalCharacterIndices { get; }
/// <param name="Indices">The indices of the original character that each character in the lines corresponds to. Only used for very specific purposes.<para/>
/// EX: If input is a run with Text = "ABC12345", and that text couldn't fit on a single line, it may be split to something like: [ "ABC12-", "345" ]<br/>
/// Notice that a '-' character was automatically inserted at the end of the first line, so now the wrapped text at index=5 is '-', while the wrapped text in the input "ABC12345" at index=5 is '3'.<br/>
/// So the indices mapping in this case would be: [ 0, 1, 2, 3, 4, 4, 5, 6, 7 ]<para/>
/// This parameter is intended to know to which character in the original input corresponds to each character in the wrapped output.</param>
public MGTextLine(IEnumerable<MGTextRun> Runs, float LineWidth, float LineTextHeight, float LineImageHeight, float LineTotalHeight, int LineNumber, bool EndsInLinebreakCharacter, IEnumerable<int> Indices)
{
this.Runs = Runs.ToList().AsReadOnly();
this.LineWidth = LineWidth;
this.LineTextHeight = LineTextHeight;
this.LineTotalHeight = LineTotalHeight;
this.LineImageHeight = LineImageHeight;
this.LineNumber = LineNumber;
this.EndsInLinebreakCharacter = EndsInLinebreakCharacter;
this.OriginalCharacterIndices = Indices.ToList().AsReadOnly();
}
private class WrappableRunGroup
{
public readonly ReadOnlyCollection<char> WordDelimiters;
public readonly ReadOnlyCollection<MGTextRun> OriginalRuns;
public readonly ReadOnlyCollection<WrappableRun> WrappableRuns;
public readonly List<WrappableRun> RemainingRuns;
public bool IsDelimiterWord(string Word) => WordDelimiters.Any(x => x.ToString() == Word);
public WrappableRunGroup(IEnumerable<char> WordDelimiters, IEnumerable<MGTextRun> OriginalRuns)
{
this.WordDelimiters = WordDelimiters.ToList().AsReadOnly();
this.OriginalRuns = OriginalRuns.ToList().AsReadOnly();
this.WrappableRuns = OriginalRuns.Where(x => x.RunType != TextRunType.Text || (!string.IsNullOrEmpty(((MGTextRunText)x).Text)))
.Select(Run => new WrappableRun(this, Run)).ToList().AsReadOnly();
for (int i = 0; i < WrappableRuns.Count; i++)
{
WrappableRun Current = WrappableRuns[i];
WrappableRun Next = i == WrappableRuns.Count - 1 ? null : WrappableRuns[i + 1];
if (Next != null && Current.IsText && Next.IsText)
{
WrappableRunWord LastWordOfCurrent = Current.OriginalWords[^1];
WrappableRunWord FirstWordOfNext = Next.OriginalWords[0];
if (!IsDelimiterWord(LastWordOfCurrent.Text) && !IsDelimiterWord(FirstWordOfNext.Text))
{
LastWordOfCurrent.HasNext = true;
}
}
}
this.RemainingRuns = new(WrappableRuns);
}
public WrappableRun GetNext(WrappableRun Current)
{
int Index = RemainingRuns.IndexOf(Current);
int NextIndex = Index + 1;
if (RemainingRuns.Count > NextIndex)
return RemainingRuns[NextIndex];
else
return null;
}
}
private class WrappableRun
{
public readonly WrappableRunGroup Group;
public readonly MGTextRun OriginalRun;
public TextRunType RunType => OriginalRun.RunType;
public bool IsText => RunType == TextRunType.Text;
public bool IsLineBreak => RunType == TextRunType.LineBreak;
public bool IsImage => RunType == TextRunType.Image;
public readonly List<WrappableRunWord> OriginalWords;
public readonly List<WrappableRunWord> RemainingWords;
public WrappableRun(WrappableRunGroup Group, MGTextRun OriginalRun)
{
this.Group = Group;
this.OriginalRun = OriginalRun;
if (OriginalRun is MGTextRunText TextRun)
{
List<string> Words = TextRun.Text.SplitAndKeepDelimiters(Group.WordDelimiters).ToList();
OriginalWords = Words.Select(x => new WrappableRunWord(this, x, TextRun.Settings, false)).ToList();
}
else
OriginalWords = new();
this.RemainingWords = new List<WrappableRunWord>(OriginalWords);
}
public MGTextRunText AsTextRun(string Text)
{
if (!IsText)
throw new InvalidOperationException();
else
return new MGTextRunText(Text, ((MGTextRunText)OriginalRun).Settings, OriginalRun.ToolTipId, OriginalRun.ActionId);
}
public string GetAllRemainingText() => string.Join("", RemainingWords.Select(x => x.Text));
public WrappableRunWord GetNext(WrappableRunWord Current)
{
int Index = RemainingWords.IndexOf(Current);
int NextIndex = Index + 1;
if (RemainingWords.Count > NextIndex)
return RemainingWords[NextIndex];
else
return Group.GetNext(this).RemainingWords[0];
}
public override string ToString() =>
RunType switch
{
TextRunType.Text => $"{nameof(WrappableRun)}: {GetAllRemainingText()}",
TextRunType.LineBreak => $"{nameof(WrappableRun)}: LineBreak",
TextRunType.Image => $"{nameof(WrappableRun)}: Image",
_ => throw new NotImplementedException($"Unrecognized {nameof(TextRunType)}: {RunType}")
};
}
private class WrappableRunWord
{
public readonly WrappableRun Run;
public readonly string Text;
public bool HasNext { get; set; }
private readonly MGTextRunConfig Settings;
public bool IsBold => Settings.IsBold;
public bool IsItalic => Settings.IsItalic;
public WrappableRunWord(WrappableRun Run, string Text, MGTextRunConfig Settings, bool HasNext)
{
this.Run = Run;
this.Text = Text;
this.Settings = Settings;
this.HasNext = HasNext;
}
public IEnumerable<WrappableRunWord> GetNextWords(bool IncludeSelf)
{
if (IncludeSelf)
yield return this;
WrappableRunWord Current = this;
while (Current.HasNext)
{
Current = Current.GetNextWord();
yield return Current;
}
}
public WrappableRunWord GetNextWord() => Run.GetNext(this);
public override string ToString() => $"{Text} ({nameof(HasNext)}={HasNext})";
}
/// <param name="IgnoreEmptySpaceLines">If true, lines consisting of only a single space character will be ignored, unless it is the first line or if it immediately follows a linebreak.<para/>
/// For example, if the line could fit exactly 5 characters, and the text is "Hello World", the result would normally be:
/// <code>
/// -------<br/>
/// |Hello|<br/>
/// | ....| (This line only contains a space)<br/>
/// |World|<br/>
/// -------</code>
/// If <paramref name="IgnoreEmptySpaceLines"/> is true, it would instead result in:
/// <code>
/// -------<br/>
/// |Hello|<br/>
/// |World|<br/>
/// -------</code>
/// Note that lines consisting of multiple consecutive spaces will still be returned.</param>
public static IEnumerable<MGTextLine> ParseLines(ITextMeasurer Measurer, double MaxLineWidth, bool WrapText, IEnumerable<MGTextRun> Runs, bool IgnoreEmptySpaceLines)
=> ParseLines(Measurer, MaxLineWidth, WrapText, Runs, IgnoreEmptySpaceLines, ' ', '-');
/// <param name="IgnoreEmptySpaceLines">If true, lines consisting of only a single space character will be ignored, unless it is the first line or if it immediately follows a linebreak.<para/>
/// For example, if the line could fit exactly 5 characters, and the text is "Hello World", the result would normally be:
/// <code>
/// -------<br/>
/// |Hello|<br/>
/// | ....| (This line only contains a space)<br/>
/// |World|<br/>
/// -------</code>
/// If <paramref name="IgnoreEmptySpaceLines"/> is true, it would instead result in:
/// <code>
/// -------<br/>
/// |Hello|<br/>
/// |World|<br/>
/// -------</code>
/// Note that lines consisting of multiple consecutive spaces will still be returned.</param>
/// <param name="WordDelimiters">Recommended: ' ' (space) and '-' (hyphen)</param>
public static IEnumerable<MGTextLine> ParseLines(ITextMeasurer Measurer, double MaxLineWidth, bool WrapText, IEnumerable<MGTextRun> Runs, bool IgnoreEmptySpaceLines, params char[] WordDelimiters)
{
if (Runs?.Any() != true || MaxLineWidth < 1)
yield break;
const string MultiLineWordSuffix = "-"; // A suffix to append to the end of a line, when the line only consists of a single word that must wrap across multiple lines
float MinLineHeight = (float)Math.Ceiling(Measurer.MeasureText(" ", false, false, false).Y);
int LineNumber = 1;
List<MGTextRun> CurrentLine = new();
float CurrentX = 0;
List<int> CurrentIndicesMap = new();
int CurrentIndexInOriginalText = 0;
MGTextLine PreviousLine = null;
bool FlushLine(out MGTextLine Line, bool EndsInLinebreakCharacter, int LineBreakCharacterCount = 1)
{
if (!CurrentLine.Any())
throw new InvalidOperationException($"Cannot create an {nameof(MGTextLine)} with no {nameof(MGTextRun)}s.");
if (EndsInLinebreakCharacter)
{
for (int i = 0; i < LineBreakCharacterCount; i++)
{
CurrentIndicesMap.Add(CurrentIndexInOriginalText);
CurrentIndexInOriginalText++;
}
}
else if (CurrentLine.Where(x => x.RunType == TextRunType.Text && x is MGTextRunText).Cast<MGTextRunText>().All(x => string.IsNullOrEmpty(x.Text)))
{
CurrentIndicesMap.Add(CurrentIndexInOriginalText);
}
List<Vector2> TextRunSizes = CurrentLine.Where(x => x.RunType == TextRunType.Text).Cast<MGTextRunText>()
.Select((x, index) => Measurer.MeasureText(x.Text, x.Settings.IsBold, x.Settings.IsItalic, index == 0)).ToList();
List<Vector2> ImageRunSizes = CurrentLine.Where(x => x.RunType == TextRunType.Image).Cast<MGTextRunImage>()
.Select(x => new Vector2(x.TargetWidth, x.TargetHeight)).ToList();
float LineWidth = Math.Max(CurrentX, TextRunSizes.Sum(x => x.X) + ImageRunSizes.Sum(x => x.X));
float LineTextHeight = TextRunSizes.DefaultIfEmpty(Vector2.Zero).Max(x => x.Y);
float LineImageHeight = ImageRunSizes.DefaultIfEmpty(Vector2.Zero).Max(x => x.Y);
float LineTotalHeight = GeneralUtils.Max(MinLineHeight, LineTextHeight, LineImageHeight);
Line = new(CurrentLine, LineWidth, LineTextHeight, LineImageHeight, LineTotalHeight, LineNumber, EndsInLinebreakCharacter, CurrentIndicesMap);
LineNumber++;
CurrentLine.Clear();
CurrentIndicesMap.Clear();
CurrentX = 0;
bool IsIgnoreable = IgnoreEmptySpaceLines && PreviousLine != null && !PreviousLine.EndsInLinebreakCharacter &&
Line.Runs.Count == 1 && Line.Runs.First() is MGTextRunText TextRun && TextRun.Text == " ";
PreviousLine = Line;
return !IsIgnoreable;
}
MGTextLine Line;
WrappableRunGroup Group = new(WordDelimiters, Runs);
while (Group.RemainingRuns.Any())
{
WrappableRun Run = Group.RemainingRuns.First();
Group.RemainingRuns.RemoveAt(0);
if (Run.IsLineBreak && Run.OriginalRun is MGTextRunLineBreak LineBreakRun)
{
if (!CurrentLine.Any())
CurrentLine.Add(new MGTextRunText("", new MGTextRunConfig(false), null, null));
if (FlushLine(out Line, true, LineBreakRun.LineBreakCharacterCount))
yield return Line;
}
else if (Run.IsImage && Run.OriginalRun is MGTextRunImage ImageRun)
{
if (!WrapText || MaxLineWidth == double.MaxValue || MaxLineWidth >= 100000)
{
CurrentLine.Add(ImageRun);
}
else
{
int ImgWidth = ImageRun.TargetWidth;
if (CurrentLine.Any() && CurrentX + ImgWidth > MaxLineWidth && FlushLine(out Line, false))
yield return Line;
CurrentLine.Add(ImageRun);
CurrentX += ImgWidth;
}
}
else if (Run.IsText && Run.OriginalRun is MGTextRunText TextRun)
{
if (!WrapText || MaxLineWidth == double.MaxValue || MaxLineWidth >= 100000)
{
string Text = Run.GetAllRemainingText();
CurrentLine.Add(Run.AsTextRun(Text));
foreach (char c in Text)
{
CurrentIndicesMap.Add(CurrentIndexInOriginalText);
CurrentIndexInOriginalText++;
}
}
else
{
StringBuilder UnwrappedText = new();
while (Run.RemainingWords.Any())
{
WrappableRunWord Current = Run.RemainingWords[0];
List<WrappableRunWord> CurrentAndNext = Current.GetNextWords(true).ToList();
// Measure the next chunk to calculate word-wrapping on
// This may be several consecutive items.
// EX: "Hello[b]World" results in 2 runs: Run1="Hello", Run2="World" but there is no word delimiter (space) between them so it's a single word "HelloWorld"
List<Vector2> Measurements = CurrentAndNext.Select((Word, Index) =>
Measurer.MeasureText(Word.Text, Word.IsBold, Word.IsItalic, CurrentLine.Count == 0 && Index == 0 && UnwrappedText.Length == 0)).ToList();
float CurrentWidth = Measurements[0].X;
float TotalWidth = Measurements.Sum(x => x.X);
if (CurrentX + TotalWidth <= MaxLineWidth)
{
UnwrappedText.Append(Current.Text);
foreach (char c in Current.Text)
{
CurrentIndicesMap.Add(CurrentIndexInOriginalText);
CurrentIndexInOriginalText++;
}
CurrentX += CurrentWidth;
Run.RemainingWords.RemoveAt(0);
}
else
{
if (UnwrappedText.ToString().Length > 0)
{
MGTextRun NewRun = Run.AsTextRun(UnwrappedText.ToString());
CurrentLine.Add(NewRun);
}
if (CurrentLine.Any() && FlushLine(out Line, false))
yield return Line;
UnwrappedText.Clear();
if (TotalWidth <= MaxLineWidth)
{
UnwrappedText.Append(Current.Text);
foreach (char c in Current.Text)
{
CurrentIndicesMap.Add(CurrentIndexInOriginalText);
CurrentIndexInOriginalText++;
}
CurrentX += CurrentWidth;
Run.RemainingWords.RemoveAt(0);
}
else
{
// This chunk of text is wider than an entire line, so it must be split up into several pieces
double MaxLineSuffixWidth = string.IsNullOrEmpty(MultiLineWordSuffix) ? 0 : CurrentAndNext.Max(x => Measurer.MeasureText(MultiLineWordSuffix, x.IsBold, x.IsItalic, false).X);
if (MaxLineSuffixWidth >= MaxLineWidth)
{
if (CurrentLine.Any() && FlushLine(out Line, false))
yield return Line;
yield break;
//string ErrorMsg = $"{nameof(MGTextLine)}.{nameof(ParseLines)} could not be evaluated because zero characters could fit on an entire line. " +
// $"{nameof(MultiLineWordSuffix)}='{MultiLineWordSuffix}'";
//throw new InvalidOperationException(ErrorMsg);
}
foreach (IGrouping<WrappableRun, WrappableRunWord> WordsByRun in CurrentAndNext.GroupBy(x => x.Run))
{
foreach (WrappableRunWord Word in WordsByRun)
{
Vector2 WordSize = Measurer.MeasureText(Word.Text, Word.IsBold, Word.IsItalic, CurrentLine.Count == 0 && UnwrappedText.Length == 0);
if (CurrentX + WordSize.X + MaxLineSuffixWidth <= MaxLineWidth)
{
UnwrappedText.Append(Word.Text);
foreach (char c in Word.Text)
{
CurrentIndicesMap.Add(CurrentIndexInOriginalText);
CurrentIndexInOriginalText++;
}
CurrentX += WordSize.X;
}
else
{
for (int CharIndex = 0; CharIndex < Word.Text.Length; CharIndex++)
{
char CurrentChar = Word.Text[CharIndex];
float CharacterWidth = Measurer.MeasureText(CurrentChar.ToString(), Word.IsBold, Word.IsItalic, CurrentLine.Count == 0 && UnwrappedText.Length == 0).X;
bool FitsOnCurrentLine = (CurrentLine.Count == 0 && UnwrappedText.Length == 0) || // Ensure we at least have 1 character per line to avoid infinite loop
(CurrentX + CharacterWidth + MaxLineSuffixWidth <= MaxLineWidth);
if (FitsOnCurrentLine)
{
UnwrappedText.Append(CurrentChar);
CurrentIndicesMap.Add(CurrentIndexInOriginalText);
CurrentIndexInOriginalText++;
CurrentX += CharacterWidth;
}
else
{
if (!string.IsNullOrEmpty(MultiLineWordSuffix))
{
UnwrappedText.Append(MultiLineWordSuffix);
foreach (char c in MultiLineWordSuffix)
{
CurrentIndicesMap.Add(CurrentIndexInOriginalText);
}
}
CurrentLine.Add(WordsByRun.Key.AsTextRun(UnwrappedText.ToString()));
if (FlushLine(out Line, false))
yield return Line;
UnwrappedText.Clear();
CharIndex--;
}
}
}
}
if (UnwrappedText.Length > 0)
{
CurrentLine.Add(WordsByRun.Key.AsTextRun(UnwrappedText.ToString()));
UnwrappedText.Clear();
}
}
foreach (WrappableRunWord Word in CurrentAndNext)
{
Word.Run.RemainingWords.Remove(Word);
}
}
}
}
if (UnwrappedText.ToString().Length > 0)
CurrentLine.Add(Run.AsTextRun(UnwrappedText.ToString()));
}
}
else
throw new NotImplementedException($"Unrecognized {nameof(TextRunType)}: {Run.RunType}");
}
if (!CurrentLine.Any())
CurrentLine.Add(new MGTextRunText("", new MGTextRunConfig(false), null, null));
if (FlushLine(out Line, false))
yield return Line;
}
}
}