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 }