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 }