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