1 module fluid.text; 2 3 import std.math; 4 import std.range; 5 import std.traits; 6 import std.string; 7 import std.algorithm; 8 9 import fluid.node; 10 import fluid.style; 11 import fluid.utils; 12 import fluid.backend; 13 import fluid.typeface; 14 15 public import fluid.rope; 16 17 18 @safe: 19 20 21 /// Create a Text struct with given range as a text layer map. 22 StyledText!StyleRange mapText(StyleRange)(Node node, const char[] text, StyleRange range) { 23 24 return typeof(return)(node, text, range); 25 26 } 27 28 alias Text = StyledText!(); 29 30 /// Draws text: handles updates, formatting and styling. 31 struct StyledText(StyleRange = TextStyleSlice[]) { 32 33 static assert(isForwardRange!StyleRange, 34 "StyleRange must be a valid forward range of TextStyleSlices"); 35 static assert(is(ElementType!StyleRange : TextStyleSlice), 36 "StyleRange must be a valid forward range of TextStyleSlices"); 37 38 public { 39 40 /// Node owning this text struct. 41 Node node; 42 43 /// Texture generated by the struct. 44 CompositeTexture texture; 45 46 /// Underlying text. 47 Rope value; 48 49 /// Range assigning slices of text to styles by index. A single text can have up to 256 different styles. 50 /// 51 /// Ranges should not overlap, and must be ordered by `start`. If a piece of text is not matched, it is assumed 52 /// to belong to style 0. 53 StyleRange styleMap; 54 55 /// If true, enables optimizations for frequently edited text. 56 bool hasFastEdits; 57 58 /// Indent width, in pixels. 59 float indentWidth = 32; 60 61 } 62 63 private { 64 65 /// Text bounding box size, in dots. 66 Vector2 _sizeDots; 67 68 /// If true, text will be wrapped if it doesn't fit available space. 69 bool _wrap; 70 71 } 72 73 alias minSize = size; 74 alias value this; 75 76 static if (is(StyleRange == TextStyleSlice[])) 77 this(Node node, Rope text) { 78 79 this.node = node; 80 opAssign(text); 81 82 } 83 84 this(Node node, Rope text, StyleRange styleMap) { 85 86 this.styleMap = styleMap; 87 this.node = node; 88 opAssign(text); 89 90 } 91 92 static if (is(StyleRange == TextStyleSlice[])) 93 this(Node node, const(char)[] text) { 94 95 this.node = node; 96 opAssign(text); 97 98 } 99 100 this(Node node, const(char)[] text, StyleRange styleMap) { 101 102 this.styleMap = styleMap; 103 this.node = node; 104 opAssign(text); 105 106 } 107 108 /// Copy the text, clear ownership and texture. 109 this(StyledText text) const { 110 111 this.node = null; 112 this.value = text.value; 113 this.styleMap = text.styleMap.save; 114 115 } 116 117 inout(FluidBackend) backend() inout 118 119 => node.tree.backend; 120 121 Rope opAssign(Rope text) { 122 123 // Identical; no change is to be made 124 if (text is value) return text; 125 126 // Request an update 127 node.updateSize(); 128 return value = text; 129 130 131 } 132 133 const(char)[] opAssign(const(char)[] text) { 134 135 // Ignore if there's no change to be made 136 if (text == value) return text; 137 138 // Request update otherwise 139 node.updateSize; 140 value = text; 141 return text; 142 143 } 144 145 void opOpAssign(string operator)(const(char)[] text) { 146 147 node.updateSize; 148 mixin("value ", operator, "= text;"); 149 150 } 151 152 /// Get the size of the text. 153 Vector2 size() const { 154 155 const scale = backend.hidpiScale; 156 157 return Vector2( 158 _sizeDots.x / scale.x, 159 _sizeDots.y / scale.y, 160 ); 161 162 } 163 164 alias minSize = size; 165 166 /// Set new bounding box for the text. 167 void resize() { 168 169 auto style = node.pickStyle; 170 auto typeface = style.getTypeface; 171 const dpi = backend.dpi; 172 const scale = backend.hidpiScale; 173 174 style.setDPI(dpi); 175 typeface.indentWidth = cast(int) (indentWidth * scale.x); 176 177 // Update the size 178 _sizeDots = typeface.measure(value); 179 _wrap = false; 180 clearTextures(); 181 182 } 183 184 /// Set new bounding box for the text; wrap the text if it doesn't fit in boundaries. 185 void resize(alias splitter = Typeface.defaultWordChunks)(Vector2 space, bool wrap = true) { 186 187 auto style = node.pickStyle; 188 auto typeface = style.getTypeface; 189 const dpi = backend.dpi; 190 const scale = backend.hidpiScale; 191 192 // Apply DPI 193 style.setDPI(dpi); 194 typeface.indentWidth = cast(int) (indentWidth * scale.x); 195 space.x *= scale.x; 196 space.y *= scale.y; 197 198 // Update the size 199 _sizeDots = style.getTypeface.measure!splitter(space, value, wrap); 200 _wrap = wrap; 201 clearTextures(); 202 203 } 204 205 /// Reset the texture, destroying it and replacing it with a blank. 206 void clearTextures() { 207 208 texture.format = Image.Format.palettedAlpha; 209 texture.resize(_sizeDots, hasFastEdits); 210 211 } 212 213 /// Generate the textures, if not already generated. 214 /// 215 /// Params: 216 /// chunks = Indices of chunks that need to be regenerated. 217 /// position = Position of the text; If given, only on-screen chunks will be generated. 218 void generate(Vector2 position) { 219 220 generate(texture.visibleChunks(position, backend.windowSize)); 221 222 } 223 224 /// ditto 225 void generate(R)(R chunks) @trusted { 226 227 // Empty, nothing to do 228 if (chunks.empty) return; 229 230 auto style = node.pickStyle; 231 auto typeface = style.getTypeface; 232 const dpi = backend.dpi; 233 const scale = backend.hidpiScale; 234 235 // Apply sizing settings 236 style.setDPI(dpi); 237 typeface.indentWidth = cast(int) (indentWidth * scale.x); 238 239 // Ignore chunks which have already been generated 240 auto newChunks = chunks 241 .filter!(index => !texture.chunks[index].isValid); 242 243 // No chunks to render, stop here 244 if (newChunks.empty) return; 245 246 // Clear the chunks 247 foreach (chunkIndex; newChunks) { 248 249 texture.clearImage(chunkIndex); 250 251 } 252 253 auto ruler = TextRuler(typeface, _sizeDots.x); 254 255 // Copy the layer range, make it infinite 256 auto styleMap = this.styleMap.save.chain(TextStyleSlice.init.repeat); 257 258 // Run through the text 259 foreach (index, line; Typeface.lineSplitterIndex(value)) { 260 261 ruler.startLine(); 262 263 // Split on words 264 // TODO use the splitter provided when resizing 265 foreach (word, penPosition; Typeface.eachWord(ruler, line, _wrap)) { 266 267 const wordEnd = index + word.length; 268 269 // Split the word based on the layer map 270 while (index != wordEnd) { 271 272 const remaining = wordEnd - index; 273 auto wordFragment = word[$ - remaining .. $]; 274 auto range = styleMap.front; 275 276 // Advance the layer map if exceeded the end 277 if (index >= range.end) { 278 styleMap.popFront; 279 continue; 280 } 281 282 ubyte styleIndex; 283 284 // Match found here 285 if (index >= range.start) { 286 287 // Find the end of the range 288 const end = min(wordEnd, range.end) - index; 289 wordFragment = wordFragment[0 .. end]; 290 styleIndex = range.styleIndex; 291 292 } 293 294 // Match found later 295 else if (range.start < wordEnd) { 296 297 wordFragment = wordFragment[0 .. range.start - index]; 298 299 } 300 301 const currentPenPosition = penPosition; 302 303 // Draw the fragment to selected chunks 304 foreach (chunkIndex; newChunks) { 305 306 const chunkRect = texture.chunkRectangle(chunkIndex); 307 308 // Ignore chunks this word is not in the bounds of 309 const relevant = chunkRect.contains(ruler.caret(currentPenPosition).start) 310 || chunkRect.contains(ruler.caret.end); 311 312 if (!relevant) continue; 313 314 // Get pen position relative to this chunk 315 auto relativePenPosition = currentPenPosition - chunkRect.start; 316 317 // Note: relativePenPosition is passed by ref 318 auto image = texture.chunks[chunkIndex].image; 319 typeface.drawLine(image, relativePenPosition, wordFragment, styleIndex); 320 321 // Update the pen position; Result of this should be the same for each chunk 322 penPosition = relativePenPosition + chunkRect.start; 323 324 } 325 326 // Update the index 327 index += wordFragment.length; 328 329 } 330 331 } 332 333 } 334 335 // Load the updated chunks 336 foreach (chunkIndex; newChunks) { 337 338 texture.upload(backend, chunkIndex, dpi); 339 340 } 341 342 } 343 344 /// Draw the text. 345 void draw(const Style style, Vector2 position) { 346 347 scope const Style[1] styles = [style]; 348 349 draw(styles, position); 350 351 } 352 353 /// ditto 354 void draw(scope const Style[] styles, Vector2 position) 355 in (styles.length >= 1, "At least one style must be passed to draw(Style[], Vector2)") 356 do { 357 358 import std.math; 359 import fluid.utils; 360 361 const rectangle = Rectangle(position.tupleof, size.tupleof); 362 const screen = Rectangle(0, 0, node.io.windowSize.tupleof); 363 364 // Ignore if offscreen 365 if (!overlap(rectangle, screen)) return; 366 367 // Regenerate visible textures 368 generate(position); 369 370 // Make space in the texture's palette 371 if (texture.palette.length != styles.length) 372 texture.palette.length = styles.length; 373 374 // Fill it with text colors of each of the styles 375 styles.map!"a.textColor".copy(texture.palette); 376 377 // Draw the texture if present 378 texture.drawAlign(backend, rectangle); 379 380 } 381 382 /// ditto 383 deprecated("Use draw(Style, Vector2) instead. Hint: Use fluid.utils.start(Rectangle) to get the position vector.") 384 void draw(const Style style, Rectangle rectangle) { 385 386 // Should this "crop" the result? 387 388 draw(style, Vector2(rectangle.x, rectangle.y)); 389 390 } 391 392 string toString() const { 393 394 import std.conv : to; 395 396 return value.to!string; 397 398 } 399 400 } 401 402 struct TextStyleSlice { 403 404 /// Start and end of this slice. Start is inclusive, end is exclusive. The range may exceed text boundaries. 405 auto start = size_t.max; 406 407 /// ditto 408 auto end = size_t.max; 409 410 invariant(start <= end); 411 412 /// Index of the style to be assigned to the text covered by this slice. 413 ubyte styleIndex; 414 415 ptrdiff_t opCmp(const TextStyleSlice that) const { 416 417 return cast(ptrdiff_t) this.start - cast(ptrdiff_t) that.start; 418 419 } 420 421 /// Apply some offset to the slice. 422 TextStyleSlice offset(int offset) const { 423 424 return TextStyleSlice(start + offset, end + offset, styleIndex); 425 426 } 427 428 } 429 430 unittest { 431 432 import fluid.space; 433 434 auto io = new HeadlessBackend; 435 auto root = vspace(); 436 auto text = Text(root, "Hello, green world!"); 437 438 // Set colors for each part 439 Style[4] styles; 440 styles[0].textColor = color("#000000"); 441 styles[1].textColor = color("#1eff00"); 442 styles[2].textColor = color("#55b9ff"); 443 styles[3].textColor = color("#0058f1"); 444 445 // Define regions 446 text.styleMap = [ 447 TextStyleSlice(7, 12, 1), // green 448 TextStyleSlice(13, 14, 2), // w 449 TextStyleSlice(14, 15, 3), // o 450 TextStyleSlice(15, 16, 2), // r 451 TextStyleSlice(16, 17, 3), // l 452 TextStyleSlice(17, 18, 2), // d 453 ]; 454 455 // Prepare the tree 456 root.io = io; 457 root.draw(); 458 459 // Draw the text 460 io.nextFrame; 461 text.resize(); 462 text.draw(styles, Vector2(0, 0)); 463 464 // Make sure the texture was drawn with the correct color 465 io.assertTexture(text.texture.chunks[0], Vector2(), color("#fff")); 466 467 foreach (i; 0..4) { 468 469 assert(text.texture.chunks[0].palette[i] == styles[i].textColor); 470 assert(text.texture.palette[i] == styles[i].textColor); 471 472 } 473 474 // TODO Is there a way to reliably test if the result was drawn properly? Sampling specific pixels maybe? 475 476 } 477 478 unittest { 479 480 import fluid.space; 481 482 auto io = new HeadlessBackend; 483 auto root = vspace(); 484 485 Style[2] styles; 486 styles[0].textColor = color("#000000"); 487 styles[1].textColor = color("#1eff00"); 488 489 auto styleMap = recurrence!"a[n-1] + 1"(0) 490 .map!(a => TextStyleSlice(a, a+1, cast(ubyte) (a % 2))); 491 492 auto text = mapText(root, "Hello, World!", styleMap); 493 494 // Prepare the tree 495 root.io = io; 496 root.draw(); 497 498 // Draw the text 499 io.nextFrame; 500 text.resize(Vector2(50, 50)); 501 text.draw(styles, Vector2(0, 0)); 502 503 } 504 505 unittest { 506 507 import fluid.space; 508 509 auto io = new HeadlessBackend; 510 auto root = vspace(); 511 512 Style[2] styles; 513 styles[0].textColor = color("#000000"); 514 styles[1].textColor = color("#1eff00"); 515 516 auto styleMap = [ 517 TextStyleSlice(2, 11, 1), 518 ]; 519 520 auto text = mapText(root, "Hello, World!", styleMap); 521 522 // Prepare the tree 523 root.io = io; 524 root.draw(); 525 526 // Draw the text 527 io.nextFrame; 528 text.resize(Vector2(60, 50)); 529 text.draw(styles, Vector2(0, 0)); 530 531 } 532 533 unittest { 534 535 import fluid.space; 536 537 Style[2] styles; 538 auto root = vspace(); 539 auto styleMap = [ 540 TextStyleSlice(0, 0, 1), 541 ]; 542 auto text = mapText(root, "Hello, World!", styleMap); 543 544 root.draw(); 545 text.resize(); 546 text.draw(styles, Vector2(0, 0)); 547 548 } 549 550 /// A composite texture splits a larger area onto smaller chunks, making rendering large pieces of text more efficient. 551 struct CompositeTexture { 552 553 enum maxChunkSize = 1024; 554 555 struct Chunk { 556 557 TextureGC texture; 558 Image image; 559 bool isValid; 560 561 alias texture this; 562 563 } 564 565 /// Format of the texture. 566 Image.Format format; 567 568 /// Total size of the texture. 569 Vector2 size; 570 571 /// Underlying textures. 572 /// 573 /// Each texture, except for the last in each column or row, has the size of maxChunkSize on each side. The last 574 /// texture in each row and column may have reduced width and height respectively. 575 Chunk[] chunks; 576 577 /// Palette to use for the texture, if relevant. 578 Color[] palette; 579 580 private bool _alwaysMax; 581 582 this(Vector2 size, bool alwaysMax = false) { 583 584 resize(size, alwaysMax); 585 586 } 587 588 /// Set a new size for the texture; recalculate the chunk number 589 /// Params: 590 /// size = New size of the texture. 591 /// alwaysMax = Always give chunks maximum size. Improves performance in nodes that frequently change their 592 /// content. 593 void resize(Vector2 size, bool alwaysMax = false) { 594 595 this.size = size; 596 this._alwaysMax = alwaysMax; 597 598 const chunkCount = columns * rows; 599 600 this.chunks.length = chunkCount; 601 602 // Invalidate the chunks 603 foreach (ref chunk; chunks) { 604 605 chunk.isValid = false; 606 607 } 608 609 } 610 611 size_t chunkCount() const { 612 613 return chunks.length; 614 615 } 616 617 size_t columns() const { 618 619 return cast(size_t) ceil(size.x / maxChunkSize); 620 621 } 622 623 size_t rows() const { 624 625 return cast(size_t) ceil(size.y / maxChunkSize); 626 627 } 628 629 size_t column(size_t i) const { 630 631 return i % columns; 632 633 } 634 635 size_t row(size_t i) const { 636 637 return i / columns; 638 639 } 640 641 /// Get the expected size of the chunk at given index 642 Vector2 chunkSize(size_t i) const { 643 644 // Return max chunk size if requested 645 if (_alwaysMax) 646 return Vector2(maxChunkSize, maxChunkSize); 647 648 const x = column(i); 649 const y = row(i); 650 651 // Reduce size for last column 652 const width = x + 1 == columns 653 ? size.x % maxChunkSize 654 : maxChunkSize; 655 656 // Reduce size for last row 657 const height = y + 1 == rows 658 ? size.y % maxChunkSize 659 : maxChunkSize; 660 661 return Vector2(width, height); 662 663 } 664 665 /// Get index of the chunk at given X or Y. 666 size_t index(size_t x, size_t y) const 667 in (x < columns) 668 in (y < rows) 669 do { 670 671 return x + y * columns; 672 673 } 674 675 /// Get position of the given chunk in dots. 676 Vector2 chunkPosition(size_t i) const { 677 678 const x = column(i); 679 const y = row(i); 680 681 return maxChunkSize * Vector2(x, y); 682 683 } 684 685 /// Get the rectangle of the given chunk in dots. 686 /// Params: 687 /// i = Index of the chunk. 688 /// offset = Translate the resulting rectangle by this vector. 689 Rectangle chunkRectangle(size_t i, Vector2 offset = Vector2()) const { 690 691 return Rectangle( 692 (chunkPosition(i) + offset).tupleof, 693 chunkSize(i).tupleof, 694 ); 695 696 } 697 698 /// Get a range of indices for all currently visible chunks. 699 const visibleChunks(Vector2 position, Vector2 windowSize) { 700 701 const offset = -position; 702 const end = offset + windowSize; 703 704 ptrdiff_t positionToIndex(alias round)(float position, ptrdiff_t limit) { 705 706 const index = cast(ptrdiff_t) round(position / maxChunkSize); 707 708 return index.clamp(0, limit); 709 710 } 711 712 const rowStart = positionToIndex!floor(offset.y, rows); 713 const rowEnd = positionToIndex!ceil(end.y, rows); 714 const columnStart = positionToIndex!floor(offset.x, columns); 715 const columnEnd = positionToIndex!ceil(end.x, columns); 716 717 // For each row 718 return iota(rowStart, rowEnd) 719 .map!(row => 720 721 // And each column 722 iota(columnStart, columnEnd) 723 724 // Get its index 725 .map!(column => index(column, row))) 726 .joiner; 727 728 } 729 730 /// Clear the image of the given chunk, making it transparent. 731 void clearImage(size_t i) { 732 733 const size = chunkSize(i); 734 const width = cast(int) size.x; 735 const height = cast(int) size.y; 736 737 // Check if the size of the chunk has changed 738 const sizeMatches = chunks[i].image.width == width 739 && chunks[i].image.height == height; 740 741 // Size matches, reuse the image 742 if (sizeMatches) 743 chunks[i].image.clear(PalettedColor.init); 744 745 // No match, generate a new image 746 else final switch (format) { 747 748 case format.rgba: 749 chunks[i].image = generateColorImage(width, height, color("#0000")); 750 return; 751 752 case format.palettedAlpha: 753 chunks[i].image = generatePalettedImage(width, height, 0); 754 return; 755 756 case format.alpha: 757 chunks[i].image = generateAlphaMask(width, height, 0); 758 return; 759 760 } 761 762 } 763 764 /// Update the texture of a given chunk using its corresponding image. 765 void upload(FluidBackend backend, size_t i, Vector2 dpi) @trusted { 766 767 const sizeMatches = chunks[i].image.width == chunks[i].texture.width 768 && chunks[i].image.height == chunks[i].texture.height; 769 770 // Size is the same as before, update the texture 771 if (sizeMatches) { 772 773 assert(chunks[i].texture.backend !is null); 774 debug assert(backend is chunks[i].texture.backend, 775 .format!"Backend mismatch %s != %s"(backend, chunks[i].texture.backend)); 776 777 chunks[i].texture.update(chunks[i].image); 778 779 } 780 781 // No match, create a new texture 782 else { 783 784 chunks[i].texture = TextureGC(backend, chunks[i].image); 785 786 } 787 788 // Update DPI 789 chunks[i].texture.dpiX = cast(int) dpi.x; 790 chunks[i].texture.dpiY = cast(int) dpi.y; 791 792 // Mark as valid 793 chunks[i].isValid = true; 794 795 } 796 797 /// Draw onscreen parts of the texture. 798 void drawAlign(FluidBackend backend, Rectangle rectangle, Color tint = color("#fff")) { 799 800 // Draw each visible chunk 801 foreach (index; visibleChunks(rectangle.start, backend.windowSize)) { 802 803 assert(chunks[index].texture.backend !is null); 804 debug assert(backend is chunks[index].texture.backend, 805 .format!"Backend mismatch %s != %s"(backend, chunks[index].texture.backend)); 806 807 const start = rectangle.start + chunkPosition(index); 808 const size = chunks[index].texture.viewportSize; 809 const rect = Rectangle(start.tupleof, size.tupleof); 810 811 // Assign palette 812 chunks[index].palette = palette; 813 814 backend.drawTextureAlign(chunks[index], rect, tint); 815 816 } 817 818 } 819 820 } 821 822 unittest { 823 824 import std.conv; 825 import fluid.label; 826 import fluid.scroll; 827 828 enum chunkSize = CompositeTexture.maxChunkSize; 829 830 auto io = new HeadlessBackend; 831 auto root = vscrollable!label( 832 nullTheme.derive( 833 rule!Label( 834 Rule.textColor = color("#000"), 835 ), 836 ), 837 "One\nTwo\nThree\nFour\nFive\n" 838 ); 839 840 root.io = io; 841 root.draw(); 842 843 // One chunk only 844 assert(root.text.texture.chunks.length == 1); 845 846 // This one chunk must have been drawn 847 io.assertTexture(root.text.texture.chunks[0], Vector2(), color("#fff")); 848 849 // Add a lot more text 850 io.nextFrame; 851 root.text = root.text.repeat(30).joiner.text; 852 root.draw(); 853 854 const textSize = root.text._sizeDots; 855 856 // Make sure assumptions for this test are sound: 857 assert(textSize.y > chunkSize * 2, "Generated text must span at least three chunks"); 858 assert(io.windowSize.y < chunkSize, "Window size must be smaller than chunk size"); 859 860 // This time, there should be more chunks 861 assert(root.text.texture.chunks.length >= 3); 862 863 // Only the first one would be drawn, however 864 io.assertTexture(root.text.texture.chunks[0], Vector2(), color("#fff")); 865 assert(io.textures.walkLength == 1); 866 867 // And, only the first one should be generated 868 assert(root.text.texture.chunks[0].isValid); 869 assert(root.text.texture.chunks[1 .. $].all!((ref a) => !a.isValid)); 870 871 // Scroll just enough so that both chunks should be on screen 872 io.nextFrame; 873 root.scroll = chunkSize - 1; 874 root.draw(); 875 876 // First two chunks must have been generated and drawn 877 assert(root.text.texture.chunks[0 .. 2].all!((ref a) => a.isValid)); 878 assert(root.text.texture.chunks[2 .. $].all!((ref a) => !a.isValid)); 879 880 io.assertTexture(root.text.texture.chunks[0], Vector2(0, -root.scroll), color("#fff")); 881 io.assertTexture(root.text.texture.chunks[1], Vector2(0, -root.scroll + chunkSize), color("#fff")); 882 assert(io.textures.walkLength == 2); 883 884 // Skip to third chunk, force regeneration 885 io.nextFrame; 886 root.scroll = 2 * chunkSize - 1; 887 root.updateSize(); 888 root.draw(); 889 890 // Because of the resize, the first chunk must have been destroyed 891 assert(root.text.texture.chunks[0 .. 1].all!((ref a) => !a.isValid)); 892 assert(root.text.texture.chunks[1 .. 3].all!((ref a) => a.isValid)); 893 assert(root.text.texture.chunks[3 .. $].all!((ref a) => !a.isValid)); 894 895 io.assertTexture(root.text.texture.chunks[1], Vector2(0, -root.scroll + chunkSize), color("#fff")); 896 io.assertTexture(root.text.texture.chunks[2], Vector2(0, -root.scroll + chunkSize*2), color("#fff")); 897 assert(io.textures.walkLength == 2); 898 899 } 900 901 unittest { 902 903 import std.file; 904 import fluid.text_input; 905 906 auto root = textInput(); 907 root.draw(); 908 root.io.clipboard = readText(__FILE_FULL_PATH__); 909 root.paste(); 910 root.draw(); 911 912 }