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 }