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