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 osu.Framework.Caching;
5using osu.Framework.Graphics.Sprites;
6using System;
7using System.Collections.Generic;
8using System.Linq;
9using System.Text;
10using osu.Framework.Extensions.EnumExtensions;
11
12namespace osu.Framework.Graphics.Containers
13{
14 /// <inheritdoc />
15 public class TextFlowContainer : TextFlowContainer<SpriteText>
16 {
17 public TextFlowContainer(Action<SpriteText> defaultCreationParameters = null)
18 : base(defaultCreationParameters)
19 {
20 }
21
22 protected override SpriteText CreateSpriteText() => new SpriteText();
23 }
24
25 /// <summary>
26 /// A drawable text object that supports more advanced text formatting.
27 /// </summary>
28 public abstract class TextFlowContainer<T> : FillFlowContainer
29 where T : SpriteText
30 {
31 private float firstLineIndent;
32 private readonly Action<T> defaultCreationParameters;
33
34 protected TextFlowContainer(Action<T> defaultCreationParameters = null)
35 {
36 this.defaultCreationParameters = defaultCreationParameters;
37 }
38
39 /// <summary>
40 /// An indent value for the first (header) line of a paragraph.
41 /// </summary>
42 public float FirstLineIndent
43 {
44 get => firstLineIndent;
45 set
46 {
47 if (value == firstLineIndent) return;
48
49 firstLineIndent = value;
50
51 layout.Invalidate();
52 }
53 }
54
55 private float contentIndent;
56
57 /// <summary>
58 /// An indent value for all lines proceeding the first line in a paragraph.
59 /// </summary>
60 public float ContentIndent
61 {
62 get => contentIndent;
63 set
64 {
65 if (value == contentIndent) return;
66
67 contentIndent = value;
68
69 layout.Invalidate();
70 }
71 }
72
73 private float paragraphSpacing = 0.5f;
74
75 /// <summary>
76 /// Vertical space between paragraphs (i.e. text separated by '\n') in multiples of the text size.
77 /// The default value is 0.5.
78 /// </summary>
79 public float ParagraphSpacing
80 {
81 get => paragraphSpacing;
82 set
83 {
84 if (value == paragraphSpacing) return;
85
86 paragraphSpacing = value;
87
88 layout.Invalidate();
89 }
90 }
91
92 private float lineSpacing;
93
94 /// <summary>
95 /// Vertical space between lines both when a new paragraph begins and when line wrapping occurs.
96 /// Additive with <see cref="ParagraphSpacing"/> on new paragraph. Default value is 0.
97 /// </summary>
98 public float LineSpacing
99 {
100 get => lineSpacing;
101 set
102 {
103 if (value == lineSpacing) return;
104
105 lineSpacing = value;
106
107 layout.Invalidate();
108 }
109 }
110
111 private Anchor textAnchor = Anchor.TopLeft;
112
113 /// <summary>
114 /// The <see cref="Anchor"/> which text should flow from.
115 /// </summary>
116 public Anchor TextAnchor
117 {
118 get => textAnchor;
119 set
120 {
121 if (textAnchor == value)
122 return;
123
124 textAnchor = value;
125
126 layout.Invalidate();
127 }
128 }
129
130 /// <summary>
131 /// An easy way to set the full text of a text flow in one go.
132 /// This will overwrite any existing text added using this method of <see cref="AddText(string, Action{T})"/>
133 /// </summary>
134 public string Text
135 {
136 set
137 {
138 Clear();
139 AddText(value);
140 }
141 }
142
143 protected override void InvalidateLayout()
144 {
145 base.InvalidateLayout();
146 layout.Invalidate();
147 }
148
149 public override IEnumerable<Drawable> FlowingChildren
150 {
151 get
152 {
153 if ((TextAnchor & (Anchor.x2 | Anchor.y2)) == 0)
154 return base.FlowingChildren;
155
156 var childArray = base.FlowingChildren.ToArray();
157
158 if ((TextAnchor & Anchor.x2) > 0)
159 reverseHorizontal(childArray);
160 if ((TextAnchor & Anchor.y2) > 0)
161 reverseVertical(childArray);
162
163 return childArray;
164 }
165 }
166
167 private void reverseHorizontal(Drawable[] children)
168 {
169 int reverseStartIndex = 0;
170
171 // Inverse the order of all children when displaying backwards, stopping at newline boundaries
172 for (int i = 0; i < children.Length; i++)
173 {
174 if (!(children[i] is NewLineContainer))
175 continue;
176
177 Array.Reverse(children, reverseStartIndex, i - reverseStartIndex);
178 reverseStartIndex = i + 1;
179 }
180
181 // Extra loop for the last newline boundary (or all children if there are no newlines)
182 Array.Reverse(children, reverseStartIndex, children.Length - reverseStartIndex);
183 }
184
185 private void reverseVertical(Drawable[] children)
186 {
187 // A vertical reverse reverses the order of the newline sections, but not the order within the newline sections
188 // For code clarity this is done by reversing the entire array, and then reversing within the newline sections to restore horizontal order
189 Array.Reverse(children);
190 reverseHorizontal(children);
191 }
192
193 protected override void UpdateAfterChildren()
194 {
195 if (!layout.IsValid)
196 {
197 computeLayout();
198 layout.Validate();
199 }
200
201 base.UpdateAfterChildren();
202 }
203
204 protected override int Compare(Drawable x, Drawable y)
205 {
206 // FillFlowContainer will reverse the ordering of right-anchored words such that the (previously) first word would be
207 // the right-most word, whereas it should still be flowed left-to-right. This is achieved by reversing the comparator.
208 if (TextAnchor.HasFlagFast(Anchor.x2))
209 return base.Compare(y, x);
210
211 return base.Compare(x, y);
212 }
213
214 /// <summary>
215 /// Add new text to this text flow. The \n character will create a new paragraph, not just a line break. If you need \n to be a line break, use <see cref="AddParagraph(string, Action{T})"/> instead.
216 /// </summary>
217 /// <returns>A collection of <see cref="Drawable" /> objects for each <see cref="SpriteText"/> word and <see cref="NewLineContainer"/> created from the given text.</returns>
218 /// <param name="text">The text to add.</param>
219 /// <param name="creationParameters">A callback providing any <see cref="SpriteText" /> instances created for this new text.</param>
220 public IEnumerable<Drawable> AddText(string text, Action<T> creationParameters = null) => AddLine(new TextChunk<T>(text, true, creationParameters));
221
222 /// <summary>
223 /// Add an arbitrary <see cref="SpriteText"/> to this <see cref="TextFlowContainer"/>.
224 /// While default creation parameters are applied automatically, word wrapping is unavailable for contained words.
225 /// This should only be used when a specialised <see cref="SpriteText"/> type is required.
226 /// </summary>
227 /// <param name="text">The text to add.</param>
228 /// <param name="creationParameters">A callback providing any <see cref="SpriteText" /> instances created for this new text.</param>
229 public void AddText(T text, Action<T> creationParameters = null)
230 {
231 base.Add(text);
232 defaultCreationParameters?.Invoke(text);
233 creationParameters?.Invoke(text);
234 }
235
236 /// <summary>
237 /// Add a new paragraph to this text flow. The \n character will create a line break. If you need \n to be a new paragraph, not just a line break, use <see cref="AddText(string, Action{T})"/> instead.
238 /// </summary>
239 /// <returns>A collection of <see cref="Drawable" /> objects for each <see cref="SpriteText"/> word and <see cref="NewLineContainer"/> created from the given text.</returns>
240 /// <param name="paragraph">The paragraph to add.</param>
241 /// <param name="creationParameters">A callback providing any <see cref="SpriteText" /> instances created for this new paragraph.</param>
242 public IEnumerable<Drawable> AddParagraph(string paragraph, Action<T> creationParameters = null) => AddLine(new TextChunk<T>(paragraph, false, creationParameters));
243
244 /// <summary>
245 /// End current line and start a new one.
246 /// </summary>
247 public void NewLine() => base.Add(new NewLineContainer(false));
248
249 /// <summary>
250 /// End current paragraph and start a new one.
251 /// </summary>
252 public void NewParagraph() => base.Add(new NewLineContainer(true));
253
254 protected abstract T CreateSpriteText();
255
256 internal SpriteText CreateSpriteTextWithChunk(TextChunk<T> chunk)
257 {
258 var spriteText = CreateSpriteText();
259 defaultCreationParameters?.Invoke(spriteText);
260 chunk.ApplyParameters(spriteText);
261 return spriteText;
262 }
263
264 public override void Add(Drawable drawable)
265 {
266 throw new InvalidOperationException($"Use {nameof(AddText)} to add text to a {nameof(TextFlowContainer)}.");
267 }
268
269 internal virtual IEnumerable<Drawable> AddLine(TextChunk<T> chunk)
270 {
271 var sprites = new List<Drawable>();
272
273 // !newLineIsParagraph effectively means that we want to add just *one* paragraph, which means we need to make sure that any previous paragraphs
274 // are terminated. Thus, we add a NewLineContainer that indicates the end of the paragraph before adding our current paragraph.
275 if (!chunk.NewLineIsParagraph)
276 {
277 var newLine = new NewLineContainer(true);
278 sprites.Add(newLine);
279 base.Add(newLine);
280 }
281
282 sprites.AddRange(AddString(chunk));
283
284 return sprites;
285 }
286
287 internal IEnumerable<Drawable> AddString(TextChunk<T> chunk)
288 {
289 bool first = true;
290 var sprites = new List<Drawable>();
291
292 foreach (string l in chunk.Text.Split('\n'))
293 {
294 if (!first)
295 {
296 Drawable lastChild = Children.LastOrDefault();
297
298 if (lastChild != null)
299 {
300 var newLine = new NewLineContainer(chunk.NewLineIsParagraph);
301 sprites.Add(newLine);
302 base.Add(newLine);
303 }
304 }
305
306 foreach (string word in SplitWords(l))
307 {
308 if (string.IsNullOrEmpty(word)) continue;
309
310 var textSprite = CreateSpriteTextWithChunk(chunk);
311 textSprite.Text = word;
312 sprites.Add(textSprite);
313 base.Add(textSprite);
314 }
315
316 first = false;
317 }
318
319 return sprites;
320 }
321
322 protected string[] SplitWords(string text)
323 {
324 var words = new List<string>();
325 var builder = new StringBuilder();
326
327 for (var i = 0; i < text.Length; i++)
328 {
329 if (i == 0 || char.IsSeparator(text[i - 1]) || char.IsControl(text[i - 1]))
330 {
331 words.Add(builder.ToString());
332 builder.Clear();
333 }
334
335 builder.Append(text[i]);
336 }
337
338 if (builder.Length > 0)
339 words.Add(builder.ToString());
340
341 return words.ToArray();
342 }
343
344 private readonly Cached layout = new Cached();
345
346 private void computeLayout()
347 {
348 var childrenByLine = new List<List<Drawable>>();
349 var curLine = new List<Drawable>();
350
351 foreach (var c in Children)
352 {
353 c.Anchor = TextAnchor;
354 c.Origin = TextAnchor;
355
356 if (c is NewLineContainer nlc)
357 {
358 curLine.Add(nlc);
359 childrenByLine.Add(curLine);
360 curLine = new List<Drawable>();
361 }
362 else
363 {
364 if (c.X == 0)
365 {
366 if (curLine.Count > 0)
367 childrenByLine.Add(curLine);
368 curLine = new List<Drawable>();
369 }
370
371 curLine.Add(c);
372 }
373 }
374
375 if (curLine.Count > 0)
376 childrenByLine.Add(curLine);
377
378 bool isFirstLine = true;
379 float lastLineHeight = 0f;
380
381 foreach (var line in childrenByLine)
382 {
383 bool isFirstChild = true;
384 IEnumerable<float> lineBaseHeightValues = line.OfType<IHasLineBaseHeight>().Select(l => l.LineBaseHeight);
385 float lineBaseHeight = lineBaseHeightValues.Any() ? lineBaseHeightValues.Max() : 0f;
386 float currentLineHeight = 0f;
387 float lineSpacingValue = lastLineHeight * LineSpacing;
388
389 foreach (Drawable c in line)
390 {
391 if (c is NewLineContainer nlc)
392 {
393 nlc.Height = nlc.IndicatesNewParagraph ? (currentLineHeight == 0 ? lastLineHeight : currentLineHeight) * ParagraphSpacing : 0;
394 continue;
395 }
396
397 float childLineBaseHeight = (c as IHasLineBaseHeight)?.LineBaseHeight ?? 0f;
398 MarginPadding margin = new MarginPadding { Top = (childLineBaseHeight != 0f ? lineBaseHeight - childLineBaseHeight : 0f) + lineSpacingValue };
399 if (isFirstLine)
400 margin.Left = FirstLineIndent;
401 else if (isFirstChild)
402 margin.Left = ContentIndent;
403
404 c.Margin = margin;
405
406 if (c.Height > currentLineHeight)
407 currentLineHeight = c.Height;
408
409 isFirstChild = false;
410 }
411
412 if (currentLineHeight != 0f)
413 lastLineHeight = currentLineHeight;
414
415 isFirstLine = false;
416 }
417 }
418
419 protected override bool ForceNewRow(Drawable child) => child is NewLineContainer;
420
421 public class NewLineContainer : Container
422 {
423 public readonly bool IndicatesNewParagraph;
424
425 public NewLineContainer(bool newParagraph)
426 {
427 IndicatesNewParagraph = newParagraph;
428 }
429 }
430 }
431}