-
-
Notifications
You must be signed in to change notification settings - Fork 6
/
Paragraph.cs
393 lines (362 loc) · 17.2 KB
/
Paragraph.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
using System;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Extensions;
using MLEM.Font;
using MLEM.Formatting;
using MLEM.Formatting.Codes;
using MLEM.Graphics;
using MLEM.Misc;
using MLEM.Ui.Style;
namespace MLEM.Ui.Elements {
/// <summary>
/// A paragraph element for use inside of a <see cref="UiSystem"/>.
/// A paragraph is an element that contains text.
/// A paragraph's text can be formatted using the ui system's <see cref="UiSystem.TextFormatter"/>.
/// </summary>
public class Paragraph : Element {
/// <summary>
/// The font that this paragraph draws text with.
/// To set its bold and italic font, use <see cref="GenericFont.Bold"/> and <see cref="GenericFont.Italic"/>.
/// </summary>
public StyleProp<GenericFont> RegularFont {
get => this.regularFont;
set {
this.regularFont = value;
this.SetTextDirty();
}
}
/// <summary>
/// The tokenized version of the <see cref="Text"/>
/// </summary>
public TokenizedString TokenizedText {
get {
this.CheckTextChange();
this.TokenizeIfNecessary();
return this.tokenizedText;
}
}
/// <summary>
/// The color that the text will be rendered with
/// </summary>
public StyleProp<Color> TextColor;
/// <summary>
/// The scale that the text will be rendered with.
/// To add a multiplier rather than changing the scale directly, use <see cref="TextScaleMultiplier"/>.
/// </summary>
public StyleProp<float> TextScale {
get => this.textScale;
set {
this.textScale = value;
this.SetTextDirty();
}
}
/// <summary>
/// A multiplier that will be applied to <see cref="TextScale"/>.
/// To change the text scale itself, use <see cref="TextScale"/>.
/// </summary>
public float TextScaleMultiplier {
get => this.textScaleMultiplier;
set {
if (this.textScaleMultiplier != value) {
this.textScaleMultiplier = value;
this.SetTextDirty();
}
}
}
/// <summary>
/// The text to render inside of this paragraph.
/// Use <see cref="GetTextCallback"/> if the text changes frequently.
/// </summary>
public string Text {
get {
this.CheckTextChange();
return this.displayedText;
}
set {
this.explicitlySetText = value;
this.CheckTextChange();
}
}
/// <summary>
/// If this paragraph should automatically adjust its width based on the width of the text within it
/// </summary>
public bool AutoAdjustWidth {
get => this.autoAdjustWidth;
set {
if (this.autoAdjustWidth != value) {
this.autoAdjustWidth = value;
this.SetAreaDirty();
}
}
}
/// <summary>
/// Whether this paragraph should be truncated instead of split if the displayed <see cref="Text"/>'s width exceeds the provided width.
/// When the string is truncated, the <see cref="Ellipsis"/> is added to its end.
/// </summary>
public bool TruncateIfLong {
get => this.truncateIfLong;
set {
if (this.truncateIfLong != value) {
this.truncateIfLong = value;
this.SetAlignSplitDirty();
}
}
}
/// <summary>
/// The ellipsis characters to use if <see cref="TruncateIfLong"/> is enabled and the string is truncated.
/// If this is set to an empty string, no ellipsis will be attached to the truncated string.
/// </summary>
public string Ellipsis {
get => this.ellipsis;
set {
if (this.ellipsis != value) {
this.ellipsis = value;
this.SetAlignSplitDirty();
}
}
}
/// <summary>
/// An event that gets called when this paragraph's <see cref="Text"/> is queried.
/// Use this event for setting this paragraph's text if it changes frequently.
/// </summary>
public TextCallback GetTextCallback;
/// <summary>
/// The action that is executed if <see cref="Link"/> objects inside of this paragraph are pressed.
/// By default, <see cref="MlemPlatform.OpenLinkOrFile"/> is executed.
/// </summary>
public Action<Link, LinkCode> LinkAction;
/// <summary>
/// The <see cref="TextAlignment"/> that this paragraph's text should be rendered with
/// </summary>
public StyleProp<TextAlignment> Alignment {
get => this.alignment;
set {
this.alignment = value;
this.SetTextDirty();
}
}
/// <summary>
/// The inclusive index in this paragraph's <see cref="Text"/> to start drawing at.
/// This value is passed to <see cref="TokenizedString.Draw"/>.
/// </summary>
public int? DrawStartIndex;
/// <summary>
/// The exclusive index in this paragraph's <see cref="Text"/> to stop drawing at.
/// This value is passed to <see cref="TokenizedString.Draw"/>.
/// </summary>
public int? DrawEndIndex;
/// <inheritdoc />
public override bool IsHidden => base.IsHidden || string.IsNullOrWhiteSpace(this.Text);
private string displayedText;
private string explicitlySetText;
private StyleProp<TextAlignment> alignment;
private StyleProp<GenericFont> regularFont;
private StyleProp<float> textScale;
private TokenizedString tokenizedText;
private float? lastAlignSplitWidth;
private float? lastAlignSplitScale;
private string ellipsis = "...";
private bool truncateIfLong;
private float textScaleMultiplier = 1;
private bool autoAdjustWidth;
/// <summary>
/// Creates a new paragraph with the given settings.
/// </summary>
/// <param name="anchor">The paragraph's anchor</param>
/// <param name="width">The paragraph's width. Note that its height is automatically calculated.</param>
/// <param name="textCallback">The paragraph's text</param>
/// <param name="alignment">The paragraph's text alignment.</param>
/// <param name="autoAdjustWidth">Whether the paragraph's width should automatically be calculated based on the text within it.</param>
public Paragraph(Anchor anchor, float width, TextCallback textCallback, TextAlignment alignment, bool autoAdjustWidth = false) : this(anchor, width, textCallback, autoAdjustWidth) {
this.Alignment = alignment;
}
/// <summary>
/// Creates a new paragraph with the given settings.
/// </summary>
/// <param name="anchor">The paragraph's anchor</param>
/// <param name="width">The paragraph's width. Note that its height is automatically calculated.</param>
/// <param name="text">The paragraph's text</param>
/// <param name="alignment">The paragraph's text alignment.</param>
/// <param name="autoAdjustWidth">Whether the paragraph's width should automatically be calculated based on the text within it.</param>
public Paragraph(Anchor anchor, float width, string text, TextAlignment alignment, bool autoAdjustWidth = false) : this(anchor, width, text, autoAdjustWidth) {
this.Alignment = alignment;
}
/// <summary>
/// Creates a new paragraph with the given settings.
/// </summary>
/// <param name="anchor">The paragraph's anchor</param>
/// <param name="width">The paragraph's width. Note that its height is automatically calculated.</param>
/// <param name="textCallback">The paragraph's text</param>
/// <param name="autoAdjustWidth">Whether the paragraph's width should automatically be calculated based on the text within it.</param>
public Paragraph(Anchor anchor, float width, TextCallback textCallback, bool autoAdjustWidth = false) : this(anchor, width, string.Empty, autoAdjustWidth) {
this.GetTextCallback = textCallback;
}
/// <summary>
/// Creates a new paragraph with the given settings.
/// </summary>
/// <param name="anchor">The paragraph's anchor</param>
/// <param name="width">The paragraph's width. Note that its height is automatically calculated.</param>
/// <param name="text">The paragraph's text</param>
/// <param name="autoAdjustWidth">Whether the paragraph's width should automatically be calculated based on the text within it.</param>
public Paragraph(Anchor anchor, float width, string text, bool autoAdjustWidth = false) : base(anchor, new Vector2(width, 0)) {
this.Text = text;
this.AutoAdjustWidth = autoAdjustWidth;
this.CanBeSelected = false;
this.CanBeMoused = false;
}
/// <inheritdoc />
public override void SetAreaAndUpdateChildren(RectangleF area) {
base.SetAreaAndUpdateChildren(area);
// in case an outside source sets our area, we still want to display our text correctly
this.AlignAndSplitIfNecessary(area.Size);
}
/// <inheritdoc />
protected override Vector2 CalcActualSize(RectangleF parentArea) {
var size = base.CalcActualSize(parentArea);
this.CheckTextChange();
this.TokenizeIfNecessary();
this.AlignAndSplitIfNecessary(size);
var textSize = this.tokenizedText.GetArea(Vector2.Zero, this.TextScale * this.TextScaleMultiplier * this.Scale).Size;
// if we auto-adjust our width, then we would also split the same way with our adjusted width, so cache that
if (this.AutoAdjustWidth)
this.lastAlignSplitWidth = textSize.X;
return new Vector2(this.AutoAdjustWidth ? textSize.X + this.ScaledPadding.Width : size.X, textSize.Y + this.ScaledPadding.Height);
}
/// <inheritdoc />
public override void Update(GameTime time) {
this.TokenizedText?.Update(time);
base.Update(time);
}
/// <inheritdoc />
public override void Draw(GameTime time, SpriteBatch batch, float alpha, SpriteBatchContext context) {
var pos = this.DisplayArea.Location + new Vector2(this.GetAlignmentOffset(), 0);
var sc = this.TextScale * this.TextScaleMultiplier * this.Scale;
var color = this.TextColor.OrDefault(Color.White) * alpha;
this.TokenizedText.Draw(time, batch, pos, this.RegularFont, color, sc, 0, this.DrawStartIndex, this.DrawEndIndex);
base.Draw(time, batch, alpha, context);
}
/// <inheritdoc />
protected override void InitStyle(UiStyle style) {
base.InitStyle(style);
this.RegularFont = this.RegularFont.OrStyle(style.Font ?? throw new NotSupportedException("Paragraphs cannot use ui styles that don't have a font. Please supply a custom font by setting UiStyle.Font."));
this.TextScale = this.TextScale.OrStyle(style.TextScale);
this.TextColor = this.TextColor.OrStyle(style.TextColor);
this.Alignment = this.Alignment.OrStyle(style.TextAlignment);
}
private void SetTextDirty() {
this.tokenizedText = null;
// only set our area dirty if our size changed as a result of this action
if (!this.AreaDirty && (this.System == null || !this.CalcActualSize(this.ParentArea).Equals(this.DisplayArea.Size, Element.Epsilon)))
this.SetAreaDirty();
}
private void CheckTextChange() {
var newText = this.GetTextCallback?.Invoke(this) ?? this.explicitlySetText;
if (this.displayedText == newText)
return;
var emptyChanged = string.IsNullOrWhiteSpace(this.displayedText) != string.IsNullOrWhiteSpace(newText);
this.displayedText = newText;
if (emptyChanged)
this.SetAreaDirty();
this.SetTextDirty();
}
private float GetAlignmentOffset() {
switch (this.Alignment.Value) {
case TextAlignment.Center:
return this.DisplayArea.Width / 2;
case TextAlignment.Right:
return this.DisplayArea.Width;
}
return 0;
}
private void TokenizeIfNecessary() {
if (this.tokenizedText != null)
return;
// tokenize the text
this.tokenizedText = this.System.TextFormatter.Tokenize(this.RegularFont, this.Text, this.Alignment);
this.SetAlignSplitDirty();
// add links to the paragraph
this.RemoveChildren(c => c is Link);
foreach (var link in this.tokenizedText.Tokens.Where(t => t.AppliedCodes.Any(c => c is LinkCode)))
this.AddChild(new Link(Anchor.TopLeft, link, this.TextScale * this.TextScaleMultiplier));
}
private void AlignAndSplitIfNecessary(Vector2 size) {
var width = size.X - this.ScaledPadding.Width;
var scale = this.TextScale * this.TextScaleMultiplier * this.Scale;
if (this.lastAlignSplitWidth?.Equals(width, Element.Epsilon) == true && this.lastAlignSplitScale?.Equals(scale, Element.Epsilon) == true)
return;
this.lastAlignSplitWidth = width;
this.lastAlignSplitScale = scale;
if (this.TruncateIfLong) {
this.tokenizedText.Truncate(this.RegularFont, width, scale, this.Ellipsis, this.Alignment);
} else {
this.tokenizedText.Split(this.RegularFont, width, scale, this.Alignment);
}
}
private void SetAlignSplitDirty() {
this.lastAlignSplitWidth = null;
this.lastAlignSplitScale = null;
}
/// <summary>
/// A delegate method used for <see cref="Paragraph.GetTextCallback"/>
/// </summary>
/// <param name="paragraph">The current paragraph</param>
public delegate string TextCallback(Paragraph paragraph);
/// <summary>
/// A link is a sub-element of the <see cref="Paragraph"/> that is added onto it as a child for any tokens that contain <see cref="LinkCode"/>, to make them selectable and clickable.
/// </summary>
public class Link : Element {
/// <summary>
/// The token that this link represents
/// </summary>
public readonly Token Token;
private readonly float textScale;
/// <summary>
/// Creates a new link element with the given settings
/// </summary>
/// <param name="anchor">The link's anchor</param>
/// <param name="token">The token that this link represents</param>
/// <param name="textScale">The scale that text is rendered with</param>
public Link(Anchor anchor, Token token, float textScale) : base(anchor, Vector2.Zero) {
this.Token = token;
this.textScale = textScale;
this.OnPressed += e => {
foreach (var code in token.AppliedCodes.OfType<LinkCode>()) {
if (this.Parent is Paragraph p && p.LinkAction != null) {
p.LinkAction.Invoke(this, code);
} else {
MlemPlatform.Current.OpenLinkOrFile(code.Match.Groups[1].Value);
}
}
};
}
/// <inheritdoc />
public override void ForceUpdateArea() {
// set the position offset and size to the token's first area
var area = this.Token.GetArea(Vector2.Zero, this.textScale).FirstOrDefault();
if (this.Parent is Paragraph p)
area.Location += new Vector2(p.GetAlignmentOffset() / p.Scale, 0);
this.PositionOffset = area.Location;
this.IsHidden = area.IsEmpty;
this.Size = area.Size;
base.ForceUpdateArea();
}
/// <inheritdoc />
public override Element GetElementUnderPos(Vector2 position) {
var ret = base.GetElementUnderPos(position);
if (ret != null)
return ret;
// check if any of our token's parts are hovered
var location = this.Parent.DisplayArea.Location;
if (this.Parent is Paragraph p)
location.X += p.GetAlignmentOffset();
foreach (var rect in this.Token.GetArea(location, this.Scale * this.textScale)) {
if (rect.Contains(this.TransformInverse(position)))
return this;
}
return null;
}
}
}
}