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