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 }