A game framework written with osu! in mind.
at master 431 lines 15 kB view raw
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}