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