A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Collections.Generic;
6using osu.Framework.Allocation;
7using osu.Framework.Bindables;
8using osu.Framework.Development;
9using osu.Framework.Extensions.IEnumerableExtensions;
10using osu.Framework.Graphics.Containers;
11using osu.Framework.Graphics.Shaders;
12using osu.Framework.Graphics.UserInterface;
13using osu.Framework.IO.Stores;
14using osu.Framework.Layout;
15using osu.Framework.Localisation;
16using osu.Framework.Utils;
17using osu.Framework.Text;
18using osuTK;
19using osuTK.Graphics;
20
21namespace osu.Framework.Graphics.Sprites
22{
23 /// <summary>
24 /// A container for simple text rendering purposes. If more complex text rendering is required, use <see cref="TextFlowContainer"/> instead.
25 /// </summary>
26 public partial class SpriteText : Drawable, IHasLineBaseHeight, ITexturedShaderDrawable, IHasText, IHasFilterTerms, IFillFlowContainer, IHasCurrentValue<string>
27 {
28 private const float default_text_size = 20;
29
30 /// <remarks>
31 /// <c>U+00A0</c> is the Unicode NON-BREAKING SPACE character (distinct from the standard ASCII space).
32 /// </remarks>
33 private static readonly char[] default_never_fixed_width_characters = { '.', ',', ':', ' ', '\u00A0' };
34
35 [Resolved]
36 private FontStore store { get; set; }
37
38 [Resolved]
39 private LocalisationManager localisation { get; set; }
40
41 private ILocalisedBindableString localisedText;
42
43 public IShader TextureShader { get; private set; }
44 public IShader RoundedTextureShader { get; private set; }
45
46 public SpriteText()
47 {
48 current.BindValueChanged(text =>
49 {
50 // importantly, to avoid a feedback loop which will overwrite a localised text object, check equality of the resulting text before propagating a basic string to Text.
51 // in the case localisedText is not yet setup, special consideration does not need to be given as it can be assumed the change to current was a user invoked change.
52 if (localisedText == null || text.NewValue != localisedText.Value)
53 Text = text.NewValue;
54 });
55
56 AddLayout(charactersCache);
57 AddLayout(parentScreenSpaceCache);
58 AddLayout(localScreenSpaceCache);
59 AddLayout(shadowOffsetCache);
60 AddLayout(textBuilderCache);
61 }
62
63 [BackgroundDependencyLoader]
64 private void load(ShaderManager shaders)
65 {
66 localisedText = localisation.GetLocalisedString(text);
67
68 TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE);
69 RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED);
70
71 // Pre-cache the characters in the texture store
72 foreach (var character in localisedText.Value)
73 {
74 var unused = store.Get(font.FontName, character) ?? store.Get(null, character);
75 }
76 }
77
78 protected override void LoadComplete()
79 {
80 base.LoadComplete();
81
82 localisedText.BindValueChanged(str =>
83 {
84 current.Value = localisedText.Value;
85
86 if (string.IsNullOrEmpty(str.NewValue))
87 {
88 // We'll become not present and won't update the characters to set the size to 0, so do it manually
89 if (requiresAutoSizedWidth)
90 base.Width = Padding.TotalHorizontal;
91 if (requiresAutoSizedHeight)
92 base.Height = Padding.TotalVertical;
93 }
94
95 invalidate(true);
96 }, true);
97 }
98
99 private LocalisableString text = string.Empty;
100
101 /// <summary>
102 /// Gets or sets the text to be displayed.
103 /// </summary>
104 public LocalisableString Text
105 {
106 get => text;
107 set
108 {
109 if (text.Equals(value))
110 return;
111
112 text = value;
113
114 if (localisedText != null)
115 {
116 localisedText.Text = value;
117 }
118 }
119 }
120
121 private readonly BindableWithCurrent<string> current = new BindableWithCurrent<string>();
122
123 public Bindable<string> Current
124 {
125 get => current.Current;
126 set => current.Current = value;
127 }
128
129 private string displayedText => localisedText?.Value ?? text.ToString();
130
131 private FontUsage font = FontUsage.Default;
132
133 /// <summary>
134 /// Contains information on the font used to display the text.
135 /// </summary>
136 public FontUsage Font
137 {
138 get => font;
139 set
140 {
141 font = value;
142
143 invalidate(true, true);
144 shadowOffsetCache.Invalidate();
145 }
146 }
147
148 private bool allowMultiline = true;
149
150 /// <summary>
151 /// True if the text should be wrapped if it gets too wide. Note that \n does NOT cause a line break. If you need explicit line breaks, use <see cref="TextFlowContainer"/> instead.
152 /// </summary>
153 /// <remarks>
154 /// If enabled, <see cref="Truncate"/> will be disabled.
155 /// </remarks>
156 public bool AllowMultiline
157 {
158 get => allowMultiline;
159 set
160 {
161 if (allowMultiline == value)
162 return;
163
164 if (value)
165 Truncate = false;
166
167 allowMultiline = value;
168 invalidate(true, true);
169 }
170 }
171
172 private bool shadow;
173
174 /// <summary>
175 /// True if a shadow should be displayed around the text.
176 /// </summary>
177 public bool Shadow
178 {
179 get => shadow;
180 set
181 {
182 if (shadow == value)
183 return;
184
185 shadow = value;
186
187 Invalidate(Invalidation.DrawNode);
188 }
189 }
190
191 private Color4 shadowColour = new Color4(0, 0, 0, 0.2f);
192
193 /// <summary>
194 /// The colour of the shadow displayed around the text. A shadow will only be displayed if the <see cref="Shadow"/> property is set to true.
195 /// </summary>
196 public Color4 ShadowColour
197 {
198 get => shadowColour;
199 set
200 {
201 if (shadowColour == value)
202 return;
203
204 shadowColour = value;
205
206 Invalidate(Invalidation.DrawNode);
207 }
208 }
209
210 private Vector2 shadowOffset = new Vector2(0, 0.06f);
211
212 /// <summary>
213 /// The offset of the shadow displayed around the text. A shadow will only be displayed if the <see cref="Shadow"/> property is set to true.
214 /// </summary>
215 public Vector2 ShadowOffset
216 {
217 get => shadowOffset;
218 set
219 {
220 if (shadowOffset == value)
221 return;
222
223 shadowOffset = value;
224
225 invalidate(true);
226 shadowOffsetCache.Invalidate();
227 }
228 }
229
230 private bool useFullGlyphHeight = true;
231
232 /// <summary>
233 /// True if the <see cref="SpriteText"/>'s vertical size should be equal to <see cref="FontUsage.Size"/> (the full height) or precisely the size of used characters.
234 /// Set to false to allow better centering of individual characters/numerals/etc.
235 /// </summary>
236 public bool UseFullGlyphHeight
237 {
238 get => useFullGlyphHeight;
239 set
240 {
241 if (useFullGlyphHeight == value)
242 return;
243
244 useFullGlyphHeight = value;
245
246 invalidate(true, true);
247 }
248 }
249
250 private bool truncate;
251
252 /// <summary>
253 /// If true, text should be truncated when it exceeds the <see cref="Drawable.DrawWidth"/> of this <see cref="SpriteText"/>.
254 /// </summary>
255 /// <remarks>
256 /// Has no effect if no <see cref="Width"/> or custom sizing is set.
257 /// If enabled, <see cref="AllowMultiline"/> will be disabled.
258 /// </remarks>
259 public bool Truncate
260 {
261 get => truncate;
262 set
263 {
264 if (truncate == value) return;
265
266 if (value)
267 AllowMultiline = false;
268
269 truncate = value;
270 invalidate(true, true);
271 }
272 }
273
274 private string ellipsisString = "…";
275
276 /// <summary>
277 /// When <see cref="Truncate"/> is enabled, this decides what string is used to signify that truncation has occured.
278 /// Defaults to "…".
279 /// </summary>
280 public string EllipsisString
281 {
282 get => ellipsisString;
283 set
284 {
285 if (ellipsisString == value) return;
286
287 ellipsisString = value;
288 invalidate(true, true);
289 }
290 }
291
292 private bool requiresAutoSizedWidth => explicitWidth == null && (RelativeSizeAxes & Axes.X) == 0;
293
294 private bool requiresAutoSizedHeight => explicitHeight == null && (RelativeSizeAxes & Axes.Y) == 0;
295
296 private float? explicitWidth;
297
298 /// <summary>
299 /// Gets or sets the width of this <see cref="SpriteText"/>. The <see cref="SpriteText"/> will maintain this width when set.
300 /// </summary>
301 public override float Width
302 {
303 get
304 {
305 if (requiresAutoSizedWidth)
306 computeCharacters();
307 return base.Width;
308 }
309 set
310 {
311 if (explicitWidth == value)
312 return;
313
314 base.Width = value;
315 explicitWidth = value;
316
317 invalidate(true, true);
318 }
319 }
320
321 private float maxWidth = float.PositiveInfinity;
322
323 /// <summary>
324 /// The maximum width of this <see cref="SpriteText"/>. Affects both auto and fixed sizing modes.
325 /// </summary>
326 /// <remarks>
327 /// This becomes a relative value if this <see cref="SpriteText"/> is relatively-sized on the X-axis.
328 /// </remarks>
329 public float MaxWidth
330 {
331 get => maxWidth;
332 set
333 {
334 if (maxWidth == value)
335 return;
336
337 maxWidth = value;
338 invalidate(true, true);
339 }
340 }
341
342 private float? explicitHeight;
343
344 /// <summary>
345 /// Gets or sets the height of this <see cref="SpriteText"/>. The <see cref="SpriteText"/> will maintain this height when set.
346 /// </summary>
347 public override float Height
348 {
349 get
350 {
351 if (requiresAutoSizedHeight)
352 computeCharacters();
353 return base.Height;
354 }
355 set
356 {
357 if (explicitHeight == value)
358 return;
359
360 base.Height = value;
361 explicitHeight = value;
362
363 invalidate(true, true);
364 }
365 }
366
367 /// <summary>
368 /// Gets or sets the size of this <see cref="SpriteText"/>. The <see cref="SpriteText"/> will maintain this size when set.
369 /// </summary>
370 public override Vector2 Size
371 {
372 get
373 {
374 if (requiresAutoSizedWidth || requiresAutoSizedHeight)
375 computeCharacters();
376 return base.Size;
377 }
378 set
379 {
380 Width = value.X;
381 Height = value.Y;
382 }
383 }
384
385 private Vector2 spacing;
386
387 /// <summary>
388 /// Gets or sets the spacing between characters of this <see cref="SpriteText"/>.
389 /// </summary>
390 public Vector2 Spacing
391 {
392 get => spacing;
393 set
394 {
395 if (spacing == value)
396 return;
397
398 spacing = value;
399
400 invalidate(true, true);
401 }
402 }
403
404 private MarginPadding padding;
405
406 /// <summary>
407 /// Shrinks the space which may be occupied by characters of this <see cref="SpriteText"/> by the specified amount on each side.
408 /// </summary>
409 public MarginPadding Padding
410 {
411 get => padding;
412 set
413 {
414 if (padding.Equals(value))
415 return;
416
417 if (!Validation.IsFinite(value)) throw new ArgumentException($@"{nameof(Padding)} must be finite, but is {value}.");
418
419 padding = value;
420
421 invalidate(true, true);
422 }
423 }
424
425 public override bool IsPresent => base.IsPresent && (AlwaysPresent || !string.IsNullOrEmpty(displayedText));
426
427 #region Characters
428
429 private readonly LayoutValue charactersCache = new LayoutValue(Invalidation.DrawSize | Invalidation.Presence, InvalidationSource.Parent);
430
431 /// <summary>
432 /// Glyph list to be passed to <see cref="TextBuilder"/>.
433 /// </summary>
434 private readonly List<TextBuilderGlyph> charactersBacking = new List<TextBuilderGlyph>();
435
436 /// <summary>
437 /// The characters in local space.
438 /// </summary>
439 private List<TextBuilderGlyph> characters
440 {
441 get
442 {
443 computeCharacters();
444 return charactersBacking;
445 }
446 }
447
448 /// <summary>
449 /// Compute character textures and positions.
450 /// </summary>
451 private void computeCharacters()
452 {
453 if (LoadState >= LoadState.Loaded)
454 ThreadSafety.EnsureUpdateThread();
455
456 if (store == null)
457 return;
458
459 if (charactersCache.IsValid)
460 return;
461
462 charactersBacking.Clear();
463
464 // Todo: Re-enable this assert after autosize is split into two passes.
465 // Debug.Assert(!isComputingCharacters, "Cyclic invocation of computeCharacters()!");
466
467 Vector2 textBounds = Vector2.Zero;
468
469 try
470 {
471 if (string.IsNullOrEmpty(displayedText))
472 return;
473
474 TextBuilder textBuilder = getTextBuilder();
475
476 textBuilder.Reset();
477 textBuilder.AddText(displayedText);
478 textBounds = textBuilder.Bounds;
479 }
480 finally
481 {
482 if (requiresAutoSizedWidth)
483 base.Width = textBounds.X + Padding.Right;
484 if (requiresAutoSizedHeight)
485 base.Height = textBounds.Y + Padding.Bottom;
486
487 base.Width = Math.Min(base.Width, MaxWidth);
488
489 charactersCache.Validate();
490 }
491 }
492
493 private readonly LayoutValue parentScreenSpaceCache = new LayoutValue(Invalidation.DrawSize | Invalidation.Presence | Invalidation.DrawInfo, InvalidationSource.Parent);
494 private readonly LayoutValue localScreenSpaceCache = new LayoutValue(Invalidation.MiscGeometry, InvalidationSource.Self);
495
496 private readonly List<ScreenSpaceCharacterPart> screenSpaceCharactersBacking = new List<ScreenSpaceCharacterPart>();
497
498 /// <summary>
499 /// The characters in screen space. These are ready to be drawn.
500 /// </summary>
501 private List<ScreenSpaceCharacterPart> screenSpaceCharacters
502 {
503 get
504 {
505 computeScreenSpaceCharacters();
506 return screenSpaceCharactersBacking;
507 }
508 }
509
510 private void computeScreenSpaceCharacters()
511 {
512 if (!parentScreenSpaceCache.IsValid)
513 {
514 localScreenSpaceCache.Invalidate();
515 parentScreenSpaceCache.Validate();
516 }
517
518 if (localScreenSpaceCache.IsValid)
519 return;
520
521 screenSpaceCharactersBacking.Clear();
522
523 Vector2 inflationAmount = DrawInfo.MatrixInverse.ExtractScale().Xy;
524
525 foreach (var character in characters)
526 {
527 screenSpaceCharactersBacking.Add(new ScreenSpaceCharacterPart
528 {
529 DrawQuad = ToScreenSpace(character.DrawRectangle.Inflate(inflationAmount)),
530 InflationPercentage = Vector2.Divide(inflationAmount, character.DrawRectangle.Size),
531 Texture = character.Texture
532 });
533 }
534
535 localScreenSpaceCache.Validate();
536 }
537
538 private readonly LayoutValue<Vector2> shadowOffsetCache = new LayoutValue<Vector2>(Invalidation.DrawInfo, InvalidationSource.Parent);
539
540 private Vector2 premultipliedShadowOffset =>
541 shadowOffsetCache.IsValid ? shadowOffsetCache.Value : shadowOffsetCache.Value = ToScreenSpace(shadowOffset * Font.Size) - ToScreenSpace(Vector2.Zero);
542
543 #endregion
544
545 #region Invalidation
546
547 private void invalidate(bool characters = false, bool textBuilder = false)
548 {
549 if (characters)
550 charactersCache.Invalidate();
551
552 if (textBuilder)
553 InvalidateTextBuilder();
554
555 parentScreenSpaceCache.Invalidate();
556 localScreenSpaceCache.Invalidate();
557
558 Invalidate(Invalidation.DrawNode);
559 }
560
561 #endregion
562
563 #region DrawNode
564
565 protected override DrawNode CreateDrawNode() => new SpriteTextDrawNode(this);
566
567 #endregion
568
569 /// <summary>
570 /// The characters that should be excluded from fixed-width application. Defaults to (".", ",", ":", " ") if null.
571 /// </summary>
572 protected virtual char[] FixedWidthExcludeCharacters => null;
573
574 /// <summary>
575 /// The character to use to calculate the fixed width width. Defaults to 'm'.
576 /// </summary>
577 protected virtual char FixedWidthReferenceCharacter => 'm';
578
579 /// <summary>
580 /// The character to fallback to use if a character glyph lookup failed.
581 /// </summary>
582 protected virtual char FallbackCharacter => '?';
583
584 private readonly LayoutValue<TextBuilder> textBuilderCache = new LayoutValue<TextBuilder>(Invalidation.DrawSize, InvalidationSource.Parent);
585
586 /// <summary>
587 /// Invalidates the current <see cref="TextBuilder"/>, causing a new one to be created next time it's required via <see cref="CreateTextBuilder"/>.
588 /// </summary>
589 protected void InvalidateTextBuilder() => textBuilderCache.Invalidate();
590
591 /// <summary>
592 /// Creates a <see cref="TextBuilder"/> to generate the character layout for this <see cref="SpriteText"/>.
593 /// </summary>
594 /// <param name="store">The <see cref="ITexturedGlyphLookupStore"/> where characters should be retrieved from.</param>
595 /// <returns>The <see cref="TextBuilder"/>.</returns>
596 protected virtual TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store)
597 {
598 var excludeCharacters = FixedWidthExcludeCharacters ?? default_never_fixed_width_characters;
599
600 float builderMaxWidth = requiresAutoSizedWidth
601 ? MaxWidth
602 : ApplyRelativeAxes(RelativeSizeAxes, new Vector2(Math.Min(MaxWidth, base.Width), base.Height), FillMode).X - Padding.Right;
603
604 if (AllowMultiline)
605 {
606 return new MultilineTextBuilder(store, Font, builderMaxWidth, UseFullGlyphHeight, new Vector2(Padding.Left, Padding.Top), Spacing, charactersBacking,
607 excludeCharacters, FallbackCharacter, FixedWidthReferenceCharacter);
608 }
609
610 if (Truncate)
611 {
612 return new TruncatingTextBuilder(store, Font, builderMaxWidth, ellipsisString, UseFullGlyphHeight, new Vector2(Padding.Left, Padding.Top), Spacing, charactersBacking,
613 excludeCharacters, FallbackCharacter, FixedWidthReferenceCharacter);
614 }
615
616 return new TextBuilder(store, Font, builderMaxWidth, UseFullGlyphHeight, new Vector2(Padding.Left, Padding.Top), Spacing, charactersBacking,
617 excludeCharacters, FallbackCharacter, FixedWidthReferenceCharacter);
618 }
619
620 private TextBuilder getTextBuilder()
621 {
622 if (!textBuilderCache.IsValid)
623 textBuilderCache.Value = CreateTextBuilder(store);
624
625 return textBuilderCache.Value;
626 }
627
628 public override string ToString() => $@"""{displayedText}"" " + base.ToString();
629
630 /// <summary>
631 /// Gets the base height of the font used by this text. If the font of this text is invalid, 0 is returned.
632 /// </summary>
633 public float LineBaseHeight
634 {
635 get
636 {
637 var baseHeight = store.GetBaseHeight(Font.FontName);
638 if (baseHeight.HasValue)
639 return baseHeight.Value * Font.Size;
640
641 if (string.IsNullOrEmpty(displayedText))
642 return 0;
643
644 return store.GetBaseHeight(displayedText[0]).GetValueOrDefault() * Font.Size;
645 }
646 }
647
648 public IEnumerable<string> FilterTerms => displayedText.Yield();
649 }
650}