1 /// Module for testing Fluid nodes using the new I/O system.
2 ///
3 /// Bugs:
4 ///     `auto` functions may generate incorrect mangling. This is worked-around
5 ///     with `pragma(mangle)`.
6 module fluid.test_space;
7 
8 version (Fluid_TestSpace):
9 
10 debug (Fluid_BuildMessages) {
11     pragma(msg, "Fluid: Including TestSpace");
12 
13     version (Fluid_SVG) {
14         pragma(msg, "Fluid: Including SVG support in TestSpace");
15     }
16 }
17 
18 import core.exception;
19 
20 import optional;
21 
22 import std.conv : toText = text;
23 import std.range;
24 import std.string;
25 import std.typecons;
26 import std.algorithm;
27 import std.exception;
28 import std.digest.sha;
29 
30 import fluid.node;
31 import fluid.tree;
32 import fluid.utils;
33 import fluid.space;
34 import fluid.input;
35 import fluid.backend;
36 
37 import fluid.io.canvas;
38 import fluid.io.debug_signal;
39 
40 import fluid.future.pipe;
41 import fluid.future.arena;
42 
43 @safe:
44 
45 alias testSpace = nodeBuilder!TestSpace;
46 alias vtestSpace = nodeBuilder!TestSpace;
47 alias htestSpace = nodeBuilder!(TestSpace, (a) {
48     a.isHorizontal = true;
49 });
50 
51 /// Node property for `TestSpace` that enables cropping. This will prevent overflowing content from being drawn.
52 /// This can be used to test nodes that rely on cropping information, for example to limit drawn content to what
53 /// is visible on the screen.
54 ///
55 /// To control size of the viewport, use `fluid.size_lock.SizeLock` and `fluid.size_lock.sizeLimit`.
56 ///
57 /// Params:
58 ///     enabled = Controls if cropping should be enabled or disabled. Defaults to `true`.
59 /// See_Also:
60 ///     `CanvasIO.cropArea`
61 auto cropViewport(bool enabled = true) {
62 
63     static struct CropViewport {
64 
65         bool enabled;
66 
67         void apply(TestSpace node) {
68             node.cropViewport = enabled;
69         }
70 
71     }
72 
73     return CropViewport(enabled);
74 
75 }
76 
77 /// This node allows automatically testing if other nodes draw their contents as expected.
78 class TestSpace : Space, CanvasIO, DebugSignalIO {
79 
80     public {
81 
82         /// If true, test space will set the default crop area to its own viewport size.
83         /// By default `TestSpace` exposes an infinite crop area, disabling any clipping behavior.
84         /// Enabling this again is useful if testing a node's cropping behavior.
85         ///
86         /// The viewport size is controlled by the node's own size. The canvas available to its children
87         /// will be the same size as `TestSpace`'s contents would normally be. `fluid.size_lock.SizeLock` can be used
88         /// to set a specific size.
89         bool cropViewport;
90 
91     }
92 
93     private {
94 
95         /// Probe the space will use to analyze the tree.
96         TestProbe _probe;
97 
98         /// Current crop area.
99         Optional!Rectangle _cropArea;
100 
101         /// Rectangle given to TestSpace when drawing.
102         Rectangle _viewport;
103 
104         /// Current DPI.
105         Vector2 _dpi = Vector2(96, 96);
106 
107         /// All presently loaded images.
108         ResourceArena!Image _loadedImages;
109 
110         /// Map of image pointers (image.data.ptr) to indices in the resource arena
111         int[size_t] _imageIndices;
112 
113         /// Track number of debug signals received per signal name.
114         int[string] _debugSignals;
115 
116     }
117 
118     this(Node[] nodes...) {
119 
120         super(nodes);
121 
122         // Create a probe for testing.
123         this._probe = new TestProbe();
124 
125     }
126 
127     /// Returns: True if the given image is loaded.
128     /// Params:
129     ///     image = Image to check.
130     bool isImageLoaded(DrawableImage image) nothrow {
131 
132         const ptr = cast(size_t) image.data.ptr;
133 
134         // Image is registered and up to date, OK
135         if (auto index = ptr in _imageIndices) {
136             return *index == image.id
137                 && _loadedImages.isActive(*index);
138         }
139 
140         // Not loaded
141         return false;
142 
143     }
144 
145     /// Returns: The number of images registered by the test runner.
146     int countLoadedImages() nothrow const {
147 
148         return cast(int) _loadedImages.activeResources.walkLength;
149 
150     }
151 
152     override void resizeImpl(Vector2 space) {
153 
154         auto frame = this.implementIO();
155 
156         _viewport = Rectangle(0, 0, space.tupleof);
157         resetCropArea();
158 
159         // Free resources
160         _loadedImages.startCycle((newIndex, ref image) {
161 
162             const id = cast(size_t) image.data.ptr;
163 
164             if (newIndex == -1) {
165                 _imageIndices.remove(id);
166             }
167             else {
168                 _imageIndices[id] = newIndex;
169             }
170 
171         });
172 
173         // Resize contents
174         super.resizeImpl(space);
175 
176     }
177 
178     override void drawImpl(Rectangle outer, Rectangle inner) {
179 
180         _viewport = inner;
181         resetCropArea();
182         super.drawImpl(outer, inner);
183 
184     }
185 
186     override Vector2 dpi() const nothrow {
187         return _dpi;
188     }
189 
190     Vector2 dpi(Vector2 value) {
191         _dpi = value;
192         updateSize();
193         return value;
194     }
195 
196     void setScale(float value) {
197         dpi = Vector2(96, 96) * value;
198     }
199 
200     /// Returns:
201     ///     The number of times a debug signal has been emitted.
202     /// Params:
203     ///     name = Name of the debug signal.
204     int emitCount(string name) const {
205         return _debugSignals.get(name, 0);
206     }
207 
208     override Optional!Rectangle cropArea() const nothrow {
209         return _cropArea;
210     }
211 
212     override void emitSignal(string name) nothrow {
213         assertNotThrown(_debugSignals.require(name, 0)++);
214         _probe.runAssert(a => a.emitSignal(_probe.subject, name));
215     }
216 
217     override void cropArea(Rectangle area) nothrow {
218         _probe.runAssert(a => a.cropArea(_probe.subject, area));
219         _cropArea = area;
220     }
221 
222     override void resetCropArea() nothrow {
223         _probe.runAssert(a => a.resetCropArea(_probe.subject));
224         if (cropViewport) {
225             _cropArea = _viewport;
226         }
227         else {
228             _cropArea = none;
229         }
230     }
231 
232     override void drawTriangleImpl(Vector2 x, Vector2 y, Vector2 z, Color color) nothrow {
233         _probe.runAssert(a => a.drawTriangle(_probe.subject, x, y, z, color));
234     }
235 
236     override void drawCircleImpl(Vector2 center, float radius, Color color) nothrow {
237         _probe.runAssert(a => a.drawCircle(_probe.subject, center, radius, color));
238     }
239 
240     override void drawCircleOutlineImpl(Vector2 center, float radius, float width, Color color) nothrow {
241         _probe.runAssert(a => a.drawCircleOutline(_probe.subject, center, radius, width, color));
242     }
243 
244     override void drawRectangleImpl(Rectangle rectangle, Color color) nothrow {
245         _probe.runAssert(a => a.drawRectangle(_probe.subject, rectangle, color));
246     }
247 
248     override void drawLineImpl(Vector2 start, Vector2 end, float width, Color color) nothrow {
249         _probe.runAssert(a => a.drawLine(_probe.subject, start, end, width, color));
250     }
251 
252     override void drawImageImpl(DrawableImage image, Rectangle destination, Color tint) nothrow {
253 
254         assert(
255             isImageLoaded(image),
256             "Trying to draw an image without loading");
257 
258         _probe.runAssert(a => a.drawImage(_probe.subject, image, destination, tint));
259 
260     }
261 
262     override void drawHintedImageImpl(DrawableImage image, Rectangle destination, Color tint) nothrow {
263 
264         assert(
265             isImageLoaded(image),
266             "Trying to draw an image without loading");
267 
268         _probe.runAssert(a => a.drawHintedImage(_probe.subject, image, destination, tint));
269 
270     }
271 
272     override int load(Image image) nothrow {
273 
274         const ptr = cast(size_t) image.data.ptr;
275 
276         // If the image is already loaded, mark it as so
277         if (auto index = ptr in _imageIndices) {
278             _loadedImages.reload(*index, image);
279             return *index;
280         }
281 
282         // If not, add it
283         else {
284             return _imageIndices[ptr] = _loadedImages.load(image);
285         }
286 
287     }
288 
289     /// Draw a single frame and save the output to an SVG file at given location.
290     ///
291     /// Requires Fluid to be built with SVG support. To do so, set version `Fluid_SVG` and include dependencies
292     /// `elemi` and `arsd-official:image_files`.
293     version (Fluid_SVG)
294     void drawToSVG(string filename) {
295 
296         auto generator = dumpDrawsToSVG(null, filename);
297         _probe.asserts = [generator];
298         _probe.allowFailure = true;
299         scope (exit) _probe.allowFailure = false;
300         queueAction(_probe);
301         draw();
302         generator.saveSVG();
303 
304     }
305 
306     /// Draw a single frame and test if the asserts can be fulfilled.
307     void drawAndAssert(Assert[] asserts...) {
308 
309         _probe.asserts = asserts.dup;
310         queueAction(_probe);
311         draw();
312 
313     }
314 
315     /// Draw a single frame and make sure the asserts are NOT fulfilled.
316     void drawAndAssertFailure(Assert[] asserts...) @trusted {
317 
318         assertThrown!AssertError(
319             drawAndAssert(asserts)
320         );
321 
322     }
323 
324 }
325 
326 private class TestProbe : TreeAction {
327 
328     import fluid.future.stack;
329 
330     public {
331 
332         /// Subject that is currently tested.
333         Node subject;
334 
335         /// Asserts that need to pass before the end of iteration. Asserts that pass are popped off this array.
336         Assert[] asserts;
337 
338         /// Number of asserts that passed since start of iteration.
339         int assertsPassed;
340 
341         /// Disables throwing an error if the probe exited with incomplete asserts.
342         ///
343         /// Every assert needs to finish for a successful test run. `TestProbe` will throw an `AssertError` if it
344         /// finishes a run without completing all of the assigned assertions, but this behavior can be disabled
345         /// by setting this option to `true`.
346         bool allowFailure;
347 
348     }
349 
350     private {
351 
352         /// Node draw stack
353         Stack!Node stack;
354 
355     }
356 
357     /// Check an assertion in the `asserts` queue.
358     /// Params:
359     ///     dg      = Function to run the assert. Returns true if the assert succeeds.
360     protected void runAssert(bool delegate(Assert a) @safe nothrow dg) nothrow {
361 
362         // No tests remain
363         if (asserts.empty) return;
364 
365         // Test passed, continue to the next one
366         if (dg(asserts.front)) {
367             nextAssert();
368         }
369 
370     }
371 
372     /// Move to the next test.
373     protected void nextAssert() nothrow {
374 
375         // Move to the next assert in the list
376         do {
377             asserts.popFront;
378             assertsPassed++;
379         }
380 
381         // Call `resume` on the next item. Continue while tests pass
382         while (!asserts.empty && asserts.front.resume(subject));
383 
384     }
385 
386     override void started() {
387 
388         // Reset pass count
389         assertsPassed = 0;
390 
391     }
392 
393     override void beforeResize(Node node, Vector2) {
394         stack ~= node;
395         this.subject = node;
396     }
397 
398     override void afterResize(Node node, Vector2) {
399         stack.pop();
400 
401         // Restore previous subject from the stack
402         if (!stack.empty) {
403             this.subject = stack.top;
404         }
405         else {
406             this.subject = null;
407         }
408     }
409 
410     override void beforeDraw(Node node, Rectangle space, Rectangle outer, Rectangle inner) {
411         stack ~= node;
412         this.subject = node;
413         runAssert(a => a.beforeDraw(node, space, outer, inner));
414     }
415 
416     override void afterDraw(Node node, Rectangle space, Rectangle outer, Rectangle inner) {
417 
418         stack.pop();
419         runAssert(a => a.afterDraw(node, space, outer, inner));
420 
421         // Restore previous subject from the stack
422         if (!stack.empty) {
423             this.subject = stack.top;
424         }
425         else {
426             this.subject = null;
427         }
428 
429     }
430 
431     override void stopped() {
432 
433         if (allowFailure) return;
434 
435         // Make sure the asserts pass
436         assert(this.asserts.empty, format!"Assert[%s] failure: %s"(
437             assertsPassed, this.asserts.front.toString));
438 
439     }
440 
441 }
442 
443 /// Class to test I/O calls performed by Fluid nodes. Any I/O method of `TestSpace` will call this.
444 ///
445 /// If a tester method returns `pass` or `passNext`, the assert passes, and the next one is loaded.
446 /// It it returns `false`, the frame continues until all nodes are exhausted (and fails),
447 /// or a matching test is found.
448 ///
449 /// `beforeDraw` or `resume` is expected to be called before any of the I/O calls.
450 interface Assert {
451 
452     /// After another test passes and this test is chosen, `resume` will be called to let the test
453     /// know the current position in the tree. This is important in situations where `resume` is immediately
454     /// followed by `beforeDraw`; the node passed to `resume` will be the parent of the one passed to `beforeDraw`.
455     bool resume(Node node) nothrow;
456 
457     // Tree
458     bool beforeDraw(Node node, Rectangle space, Rectangle paddingBox, Rectangle contentBox) nothrow;
459     bool afterDraw(Node node, Rectangle space, Rectangle paddingBox, Rectangle contentBox) nothrow;
460 
461     // DebugSignalIO
462     bool emitSignal(Node node, string name) nothrow;
463 
464     // CanvasIO
465     bool cropArea(Node node, Rectangle area) nothrow;
466     bool resetCropArea(Node node) nothrow;
467     bool drawTriangle(Node node, Vector2 a, Vector2 b, Vector2 c, Color color) nothrow;
468     bool drawCircle(Node node, Vector2 center, float radius, Color color) nothrow;
469     bool drawCircleOutline(Node node, Vector2 center, float radius, float width, Color color) nothrow;
470     bool drawRectangle(Node node, Rectangle rectangle, Color color) nothrow;
471     bool drawLine(Node node, Vector2 start, Vector2 end, float width, Color color) nothrow;
472     bool drawImage(Node node, DrawableImage image, Rectangle destination, Color tint) nothrow;
473     bool drawHintedImage(Node node, DrawableImage image, Rectangle destination, Color tint) nothrow;
474 
475     // Meta
476     string toString() const;
477 
478 }
479 
480 ///
481 pragma(mangle, "fluid__test_space_cropsTo_R_tuple")
482 auto cropsTo(Node subject, typeof(Rectangle.tupleof) rectangle) {
483     return cropsTo(subject, Rectangle(rectangle));
484 }
485 
486 /// ditto
487 pragma(mangle, "fluid__test_space_cropsTo_R")
488 auto cropsTo(Node subject, Rectangle rectangle) {
489     auto result = crops(subject);
490     result.isTestingArea = true;
491     result.targetArea = rectangle;
492     return result;
493 }
494 
495 /// ditto
496 pragma(mangle, "fluid__test_space_crops")
497 auto crops(Node subject) {
498 
499     return new class BlackHole!Assert {
500 
501         bool isTestingArea;
502         Rectangle targetArea;
503 
504         override bool cropArea(Node node, Rectangle area) nothrow {
505 
506             if (isTestingArea) {
507                 if (!equal(area.x, targetArea.x)
508                     || !equal(area.y, targetArea.y)
509                     || !equal(area.w, targetArea.w)
510                     || !equal(area.h, targetArea.h)) return false;
511             }
512 
513             return subject.opEquals(node).assumeWontThrow;
514 
515         }
516 
517         override string toString() const {
518             return toText(subject, " should set crop area")
519                 ~ (isTestingArea ? toText(" to ", targetArea) : "");
520         }
521 
522     };
523 
524 }
525 
526 ///
527 pragma(mangle, "fluid__test_space_resetsCrop")
528 auto resetsCrop(Node subject) {
529 
530     return new class BlackHole!Assert {
531 
532         override bool resetCropArea(Node node) nothrow {
533             return subject.opEquals(node).assumeWontThrow;
534         }
535 
536         override string toString() const {
537             return toText(subject, " should reset crop area");
538         }
539 
540     };
541 
542 }
543 
544 ///
545 pragma(mangle, "fluid__test_space_drawsRectangle_R_tuple")
546 auto drawsRectangle(Node subject, typeof(Rectangle.tupleof) rectangle) {
547     return drawsRectangle(subject, Rectangle(rectangle));
548 }
549 
550 /// ditto
551 pragma(mangle, "fluid__test_space_drawsRectangle_R")
552 auto drawsRectangle(Node subject, Rectangle rectangle) {
553     auto result = drawsRectangle(subject);
554     result.isTestingArea = true;
555     result.targetArea = rectangle;
556     return result;
557 }
558 
559 pragma(mangle, "fluid__test_space_drawsRectangle")
560 auto drawsRectangle(Node subject) {
561 
562     return new class BlackHole!Assert {
563 
564         bool isTestingArea;
565         Rectangle targetArea;
566         bool isTestingColor;
567         Color targetColor;
568 
569         override bool drawRectangle(Node node, Rectangle rect, Color color) nothrow {
570 
571             // node != subject MAY throw
572             if (!node.opEquals(subject).assertNotThrown) return false;
573 
574             if (isTestingArea) {
575                 if (!equal(targetArea.x, rect.x)
576                     || !equal(targetArea.y, rect.y)
577                     || !equal(targetArea.width, rect.width)
578                     || !equal(targetArea.height, rect.height)) return false;
579             }
580 
581             if (isTestingColor) {
582                 if (color != targetColor) return false;
583             }
584 
585             return true;
586 
587         }
588 
589         typeof(this) ofColor(string color) @safe {
590             return ofColor(.color(color));
591         }
592 
593         typeof(this) ofColor(Color color) @safe {
594             isTestingColor = true;
595             targetColor = color;
596             return this;
597         }
598 
599         override string toString() const {
600             return toText(
601                 subject, " should draw a rectangle",
602                 isTestingArea  ? toText(" ", targetArea)                 : "",
603                 isTestingColor ? toText(" of color ", targetColor.toHex) : "",
604             );
605         }
606 
607     };
608 
609 }
610 
611 /// Test if the subject draws a line.
612 pragma(mangle, "fluid__test_space_drawsLine")
613 auto drawsLine(Node subject) {
614 
615     return new class BlackHole!Assert {
616 
617         bool isTestingStart;
618         Vector2 targetStart;
619         bool isTestingEnd;
620         Vector2 targetEnd;
621         bool isTestingWidth;
622         float targetWidth;
623         bool isTestingColor;
624         Color targetColor;
625 
626         override bool drawLine(Node node, Vector2 start, Vector2 end, float width, Color color) nothrow {
627 
628             // node != subject MAY throw
629             if (!node.opEquals(subject).assertNotThrown) return false;
630 
631             if (isTestingStart) {
632                 assert(equal(targetStart.x, start.x)
633                     && equal(targetStart.y, start.y),
634                     format!"Expected start %s, got %s"(targetStart, start).assertNotThrown);
635             }
636 
637             if (isTestingEnd) {
638                 assert(equal(targetEnd.x, end.x)
639                     && equal(targetEnd.y, end.y),
640                     format!"Expected end %s, got %s"(targetEnd, end).assertNotThrown);
641             }
642 
643             if (isTestingWidth) {
644                 assert(equal(targetWidth, width),
645                     format!"Expected width %s, got %s"(targetWidth, width).assertNotThrown);
646             }
647 
648             if (isTestingColor) {
649                 assert(targetColor == color,
650                     format!"Expected color %s, got %s"(targetColor, color).assertNotThrown);
651             }
652 
653             return true;
654 
655         }
656 
657         typeof(this) from(float x, float y) @safe {
658             return from(Vector2(x, y));
659         }
660 
661         typeof(this) from(Vector2 start) @safe {
662             isTestingStart = true;
663             targetStart = start;
664             return this;
665         }
666 
667         typeof(this) to(float x, float y) @safe {
668             return to(Vector2(x, y));
669         }
670 
671         typeof(this) to(Vector2 end) @safe {
672             isTestingEnd = true;
673             targetEnd = end;
674             return this;
675         }
676 
677         typeof(this) ofWidth(float width) @safe {
678             isTestingWidth = true;
679             targetWidth = width;
680             return this;
681         }
682 
683         typeof(this) ofColor(string color) @safe {
684             return ofColor(.color(color));
685         }
686 
687         typeof(this) ofColor(Color color) @safe {
688             isTestingColor = true;
689             targetColor = color;
690             return this;
691         }
692 
693         override string toString() const {
694             return toText(
695                 subject, " should draw a line",
696                 isTestingStart ? toText(" from ", targetStart)           : "",
697                 isTestingEnd   ? toText(" to ", targetEnd)               : "",
698                 isTestingWidth ? toText(" of width ", targetWidth)       : "",
699                 isTestingColor ? toText(" of color ", targetColor.toHex) : "",
700             );
701         }
702 
703     };
704 
705 }
706 
707 /// Test if the subject draws a circle outline.
708 pragma(mangle, "fluid__test_space_drawsCircleOutline")
709 auto drawsCircleOutline(Node subject) {
710     auto a = drawsCircle(subject);
711     a.isOutline = true;
712     return a;
713 }
714 
715 /// ditto
716 pragma(mangle, "fluid__test_space_drawsCircleOutline_float")
717 auto drawsCircleOutline(Node subject, float width) {
718     auto a = drawsCircleOutline(subject);
719     a.isTestingOutlineWidth = true;
720     a.targetOutlineWidth = width;
721     return a;
722 }
723 
724 /// Test if the subject draws a circle.
725 pragma(mangle, "fluid__test_space_drawsCircle")
726 auto drawsCircle(Node subject) {
727 
728     return new class BlackHole!Assert {
729 
730         bool isOutline;
731         bool isTestingCenter;
732         Vector2 targetCenter;
733         bool isTestingRadius;
734         float targetRadius;
735         bool isTestingColor;
736         Color targetColor;
737         bool isTestingOutlineWidth;
738         float targetOutlineWidth;
739 
740         override bool drawCircle(Node node, Vector2 center, float radius, Color color) nothrow {
741             if (isOutline) {
742                 return false;
743             }
744             else {
745                 return drawTargetCircle(node, center, radius, color);
746             }
747         }
748 
749         override bool drawCircleOutline(Node node, Vector2 center, float radius, float width, Color color) nothrow {
750             if (isOutline) {
751                 if (isTestingOutlineWidth) {
752                     assert(equal(width, targetOutlineWidth),
753                         format!"Expected outline width %s, got %s"(targetOutlineWidth, width).assertNotThrown);
754                 }
755                 return drawTargetCircle(node, center, radius, color);
756             }
757             else {
758                 return false;
759             }
760         }
761 
762         bool drawTargetCircle(Node node, Vector2 center, float radius, Color color) nothrow @safe {
763 
764             if (!node.opEquals(subject).assertNotThrown) return false;
765 
766             if (isTestingCenter) {
767                 if (!equal(targetCenter.x, center.x)
768                     || !equal(targetCenter.y, center.y)) return false;
769             }
770 
771             if (isTestingRadius) {
772                 if (!equal(targetRadius, radius)) return false;
773             }
774 
775             if (isTestingColor) {
776                 if (targetColor != color) return false;
777             }
778 
779             return true;
780 
781         }
782 
783         typeof(this) at(float x, float y) @safe {
784             return at(Vector2(x, y));
785         }
786 
787         typeof(this) at(Vector2 center) @safe {
788             isTestingCenter = true;
789             targetCenter = center;
790             return this;
791         }
792 
793         typeof(this) ofRadius(float radius) @safe {
794             isTestingRadius = true;
795             targetRadius = radius;
796             return this;
797         }
798 
799         typeof(this) ofColor(string color) @safe {
800             return ofColor(.color(color));
801         }
802 
803         typeof(this) ofColor(Color color) @safe {
804             isTestingColor = true;
805             targetColor = color;
806             return this;
807         }
808 
809         override string toString() const {
810             return toText(
811                 subject, " should draw a circle",
812                 isOutline             ? "outline"                                : "",
813                 isTestingCenter       ? toText(" at ", targetCenter)             : "",
814                 isTestingRadius       ? toText(" of radius ", targetRadius)      : "",
815                 isTestingOutlineWidth ? toText(" of width ", targetOutlineWidth) : "",
816                 isTestingColor        ? toText(" of color ", targetColor.toHex)  : "",
817             );
818         }
819 
820     };
821 
822 }
823 
824 /// Params:
825 ///     subject = Test if this subject draws an image.
826 /// Returns:
827 ///     An `Assert` that can be passed to `TestSpace.drawAndAssert` to test if a node draws an image.
828 pragma(mangle, "fluid__test_space_drawsImage_I")
829 auto drawsImage(Node subject, Image image) {
830     auto test = drawsImage(subject);
831     test.isTestingImage = true;
832     test.targetImage = image;
833     test.isTestingColor = true;
834     test.targetColor = color("#fff");
835     return test;
836 }
837 
838 /// ditto
839 pragma(mangle, "fluid__test_space_drawsHintedImage_I")
840 auto drawsHintedImage(Node subject, Image image) {
841     auto test = drawsImage(subject, image);
842     test.isTestingHint = true;
843     test.targetHint = true;
844     return test;
845 }
846 
847 /// ditto
848 pragma(mangle, "fluid__test_space_drawsHintedImage")
849 auto drawsHintedImage(Node subject) {
850     auto test = drawsImage(subject);
851     test.isTestingHint = true;
852     test.targetHint = true;
853     return test;
854 }
855 
856 /// ditto
857 pragma(mangle, "fluid__test_space_drawsImage")
858 auto drawsImage(Node subject) {
859 
860     return new class BlackHole!Assert {
861 
862         bool isTestingImage;
863         Image targetImage;
864         bool isTestingDataHash;
865         ubyte[] targetDataHash;
866         bool isTestingStart;
867         Vector2 targetStart;
868         bool isTestingSize;
869         Vector2 targetSize;
870         bool isTestingColor;
871         Color targetColor;
872         bool isTestingHint;
873         bool targetHint;
874         bool isTestingPalette;
875         Color[] targetPalette;
876 
877         override bool drawImage(Node node, DrawableImage image, Rectangle rect, Color color) nothrow {
878 
879             if (!node.opEquals(subject).assertNotThrown) return false;
880 
881             if (isTestingImage) {
882                 const bothEmpty = image.data.empty && targetImage.data.empty;
883                 assert(image.format == targetImage.format);
884                 assert(bothEmpty || image.data is targetImage.data,
885                     format!"%s should draw image 0x%02x but draws 0x%02x"(
886                         node, cast(size_t) targetImage.data.ptr, cast(size_t) image.data.ptr).assertNotThrown);
887 
888                 if (isTestingPalette) {
889                     assert(image.format == Image.Format.palettedAlpha);
890                     assert(image.palette == targetPalette,
891                         format!"%s should draw image with palette %s but uses %s"(
892                             node, targetPalette.map!(a => a.toHex), image.palette.map!(a => a.toHex))
893                             .assertNotThrown);
894                 }
895             }
896 
897             if (isTestingDataHash) {
898                 assert(targetDataHash == sha256Of(image.data),
899                     format!"%s should draw image with SHA256 hash %(%02x%), but draws %(%02x%)"(
900                         node, targetDataHash, sha256Of(image.data))
901                         .assumeWontThrow);
902             }
903 
904             if (isTestingStart) {
905                 assert(equal(targetStart.x, rect.x)
906                     && equal(targetStart.y, rect.y),
907                     format!"%s should draw image at %s, but draws at %s"(node, targetStart, rect.start)
908                         .assertNotThrown);
909             }
910 
911             if (isTestingSize) {
912                 assert(equal(targetSize.x, rect.w)
913                     && equal(targetSize.y, rect.h),
914                     format!"%s should draw image of size %s, but draws %s"(node, targetSize, rect.size)
915                         .assertNotThrown);
916             }
917 
918             if (isTestingColor) {
919                 assert(color == targetColor);
920             }
921 
922             if (isTestingHint) {
923                 assert(!targetHint);
924             }
925 
926             return true;
927 
928         }
929 
930         override bool drawHintedImage(Node node, DrawableImage image, Rectangle rect, Color color) nothrow {
931 
932             targetHint = false;
933             scope (exit) targetHint = true;
934 
935             return drawImage(node, image, rect, color);
936 
937         }
938 
939         /// Test if the image content (using the format it is stored in) matches the hex-encoded
940         /// SHA256 hash.
941         typeof(this) sha256(string content) @safe {
942 
943             import std.conv : to;
944 
945             isTestingDataHash = true;
946             targetDataHash = content
947                 .chunks(2)
948                 .map!(a => a.to!ubyte(16))
949                 .array;
950 
951             return this;
952 
953         }
954 
955         typeof(this) at(Vector2 position) @safe {
956             isTestingStart = true;
957             targetStart = position;
958             // TODO DPI
959             return this;
960 
961         }
962 
963         typeof(this) at(typeof(Vector2.tupleof) position) @safe {
964             return at(Vector2(position));
965         }
966 
967         typeof(this) at(Rectangle area) @safe {
968             at(area.start);
969             isTestingSize = true;
970             targetSize = area.size;
971             return this;
972 
973         }
974 
975         typeof(this) at(typeof(Rectangle.tupleof) area) @safe {
976             return at(Rectangle(area));
977         }
978 
979         typeof(this) withPalette(Color[] colors...) @safe {
980             isTestingPalette = true;
981             targetPalette = colors.dup;
982             return this;
983         }
984 
985         typeof(this) ofColor(string color) @safe {
986             return ofColor(.color(color));
987         }
988 
989         typeof(this) ofColor(Color color) @safe {
990             isTestingColor = true;
991             targetColor = color;
992             return this;
993         }
994 
995         override string toString() const {
996             return toText(
997                 subject, " should draw an image ",
998                 isTestingImage ? toText(targetImage)                     : "",
999                 isTestingStart ? toText(" at ", targetStart)             : "",
1000                 isTestingSize  ? toText(" of size ", targetSize)         : "",
1001                 isTestingColor ? toText(" of color ", targetColor.toHex) : "",
1002             );
1003         }
1004 
1005     };
1006 
1007 }
1008 
1009 /// Assert true if the node draws a child.
1010 /// Bugs:
1011 ///     If testing with a specific child, it will not detect the action if resumed inside of a sibling node.
1012 ///     In other words, this will fail:
1013 ///
1014 ///     ---
1015 ///     // tree
1016 ///     parent = vspace(
1017 ///         sibling = label("Sibling"),
1018 ///         child = label("Target"),
1019 ///     )
1020 ///     // test
1021 ///     drawAndAssert(
1022 ///         sibling.isDrawn,
1023 ///         parent.drawsChild(child),
1024 ///     ),
1025 ///     ---
1026 ///
1027 /// Params:
1028 ///     parent = Parent node, subject of the test.
1029 ///     child  = Child to test. Must be drawn directly.
1030 pragma(mangle, "fluid__test_space_drawsChild")
1031 auto drawsChild(Node parent, Node child = null) {
1032 
1033     return new class BlackHole!Assert {
1034 
1035         // 0 outside of parent, 1 inside, 2 in child, 3 in grandchild, etc.
1036         int parentDepth;
1037 
1038         override bool resume(Node node) {
1039             if (parent.opEquals(node).assertNotThrown) {
1040                 parentDepth = 1;
1041             }
1042             return false;
1043         }
1044 
1045         override bool beforeDraw(Node node, Rectangle, Rectangle, Rectangle) {
1046 
1047             // Found the parent
1048             if (parent.opEquals(node).assertNotThrown) {
1049                 parentDepth = 1;
1050             }
1051 
1052             // Parent drew a child, great! End the test if the child meets expectations.
1053             else if (parentDepth) {
1054                 if (parentDepth++ == 1) {
1055                     return child is null || node.opEquals(child).assertNotThrown;
1056                 }
1057             }
1058 
1059             return false;
1060 
1061         }
1062 
1063         override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) {
1064 
1065             if (parentDepth) {
1066                 parentDepth--;
1067             }
1068 
1069             return false;
1070 
1071         }
1072 
1073         override string toString() const {
1074             if (child)
1075                 return format!"%s must draw %s"(parent, child);
1076             else
1077                 return format!"%s must draw a child"(parent);
1078         }
1079 
1080     };
1081 
1082 }
1083 
1084 ///
1085 @("drawsChild assert works as expected")
1086 unittest {
1087 
1088     import fluid.structs;
1089 
1090     Space child, grandchild;
1091 
1092     auto root = testSpace(
1093         layout!1,
1094         child = vspace(
1095             layout!2,
1096             grandchild = vspace(
1097                 layout!3
1098             ),
1099         ),
1100     );
1101 
1102     root.drawAndAssert(
1103         root.drawsChild(),
1104         child.drawsChild(),
1105     );
1106 
1107     root.drawAndAssert(
1108         root.drawsChild(child),
1109         child.drawsChild(grandchild),
1110     );
1111 
1112     root.drawAndAssert(
1113         root.drawsChild(child),
1114         child.drawsChild(grandchild),
1115         grandchild.doesNotDrawChildren(),
1116         root.doesNotDrawChildren(),
1117     );
1118 
1119     root.drawAndAssertFailure(
1120         root.doesNotDrawChildren(),
1121     );
1122 
1123     root.drawAndAssertFailure(
1124         child.doesNotDrawChildren(),
1125     );
1126 
1127     root.drawAndAssert(
1128         grandchild.doesNotDrawChildren(),
1129     );
1130 
1131     root.drawAndAssertFailure(
1132         grandchild.drawsChild(),
1133     );
1134 
1135     root.drawAndAssertFailure(
1136         root.drawsChild(grandchild),
1137     );
1138 
1139 }
1140 
1141 /// Make sure the parent does not draw any children.
1142 pragma(mangle, "fluid__test_space_doesNotDrawChildren")
1143 auto doesNotDrawChildren(Node parent) {
1144 
1145     return new class BlackHole!Assert {
1146 
1147         bool inParent;
1148 
1149         override bool resume(Node node) {
1150             if (parent.opEquals(node).assertNotThrown) {
1151                 inParent =  true;
1152             }
1153             return false;
1154         }
1155 
1156         override bool beforeDraw(Node node, Rectangle, Rectangle, Rectangle) {
1157 
1158             // Found the parent
1159             if (parent.opEquals(node).assertNotThrown) {
1160                 inParent = true;
1161             }
1162 
1163             // Parent drew a child
1164             else if (inParent) {
1165                 assert(false, format!"%s must not draw children"(parent).assertNotThrown);
1166             }
1167 
1168             return false;
1169 
1170         }
1171 
1172         override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) {
1173             return parent.opEquals(node).assertNotThrown;
1174         }
1175 
1176         override string toString() const {
1177             return format!"%s must not draw children"(parent).assertNotThrown;
1178         }
1179 
1180     };
1181 
1182 }
1183 
1184 /// Assert true if a node is attempted to be drawn,
1185 /// but the node does not need to draw anything for the assert to succeed.
1186 pragma(mangle, "fluid__test_space_isDrawn")
1187 auto isDrawn(Node subject) {
1188 
1189     return new class BlackHole!Assert {
1190 
1191         bool isTestingSpaceStart;
1192         Vector2 targetSpaceStart;
1193         bool isTestingSpaceSize;
1194         Vector2 targetSpaceSize;
1195 
1196         override bool resume(Node node) {
1197             return node.opEquals(subject).assertNotThrown
1198                 && !isTestingSpaceStart
1199                 && !isTestingSpaceSize;
1200         }
1201 
1202         override bool beforeDraw(Node node, Rectangle space, Rectangle, Rectangle) {
1203 
1204             if (isTestingSpaceStart) {
1205                 if (!equal(space.start.x, targetSpaceStart.x)
1206                     || !equal(space.start.y, targetSpaceStart.y)) return false;
1207             }
1208 
1209             if (isTestingSpaceSize) {
1210                 if (!equal(space.size.x, targetSpaceSize.x)
1211                     || !equal(space.size.y, targetSpaceSize.y)) return false;
1212             }
1213 
1214             return node.opEquals(subject).assertNotThrown;
1215         }
1216 
1217         auto at(Rectangle space) @safe {
1218             isTestingSpaceStart = true;
1219             targetSpaceStart = space.start;
1220             isTestingSpaceSize = true;
1221             targetSpaceSize = space.size;
1222             return this;
1223         }
1224 
1225         auto at(float x, float y, float width, float height) @safe {
1226             return at(Rectangle(x, y, width, height));
1227         }
1228 
1229         auto at(Vector2 start) @safe {
1230             isTestingSpaceStart = true;
1231             targetSpaceStart = start;
1232             return this;
1233         }
1234 
1235         auto at(float x, float y) @safe {
1236             return at(Vector2(x, y));
1237         }
1238 
1239         override string toString() const {
1240             return toText(
1241                 subject, " must be drawn",
1242                 isTestingSpaceStart ? toText(" at ",        targetSpaceStart) : "",
1243                 isTestingSpaceSize  ? toText(" with size ", targetSpaceSize)  : "",
1244             );
1245         }
1246 
1247     };
1248 
1249 
1250 }
1251 
1252 /// Make sure the selected node draws, but doesn't matter what.
1253 pragma(mangle, "fluid__test_space_draws")
1254 auto draws(Node subject) {
1255 
1256     return drawsWildcard!((node, methodName) {
1257 
1258         return node.opEquals(subject).assertNotThrown
1259             && methodName.startsWith("draw");
1260 
1261     })(format!"%s should draw"(subject));
1262 
1263 }
1264 
1265 /// Make sure the selected node doesn't draw anything until another node does.
1266 auto doesNotDraw(alias predicate = `a.startsWith("draw")`)(Node subject) {
1267 
1268     import std.functional : unaryFun;
1269 
1270     bool matched;
1271     string failedName;
1272 
1273     alias fun = unaryFun!predicate;
1274 
1275     return drawsWildcard!((node, methodName) {
1276 
1277         // Test failed, skip checks
1278         if (failedName) return false;
1279 
1280         const isSubject = node.opEquals(subject).assertNotThrown;
1281 
1282         // Make sure the node is reached
1283         if (!matched) {
1284             if (!isSubject) {
1285                 return false;
1286             }
1287             matched = true;
1288         }
1289 
1290         // Switching to another node
1291         if (methodName == "beforeDraw" && !isSubject) {
1292             return true;
1293         }
1294 
1295         // Ending this node
1296         if (methodName == "afterDraw" && isSubject) {
1297             return true;
1298         }
1299 
1300         if (isSubject && fun(methodName)) {
1301             failedName = methodName;
1302             return false;
1303         }
1304 
1305         return false;
1306 
1307     })(matched ? format!"%s shouldn't draw, but calls %s"(subject, failedName)
1308                : format!"%s should be reached"(subject));
1309 
1310 }
1311 
1312 alias doesNotDrawImages = doesNotDraw!`a.among("drawImage", "drawHintedImage")`;
1313 
1314 /// Ensure the node emits a debug signal.
1315 pragma(mangle, "fluid__test_space_emits")
1316 auto emits(Node subject, string name) {
1317 
1318     return new class BlackHole!Assert {
1319 
1320         override bool emitSignal(Node node, string emittedName) {
1321 
1322             return subject.opEquals(node).assertNotThrown
1323                 && name == emittedName;
1324 
1325         }
1326 
1327         override string toString() const {
1328             return format!"%s should emit %s"(subject, name);
1329         }
1330 
1331     };
1332 
1333 }
1334 
1335 auto drawsWildcard(alias dg)(lazy string message) {
1336 
1337     return new class Assert {
1338 
1339         override bool resume(Node node) nothrow {
1340             return dg(node, "resume");
1341         }
1342 
1343         override bool beforeDraw(Node node, Rectangle, Rectangle, Rectangle) nothrow {
1344             return dg(node, "beforeDraw");
1345         }
1346 
1347         override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) nothrow {
1348             return dg(node, "afterDraw");
1349         }
1350 
1351         override bool cropArea(Node node, Rectangle) nothrow {
1352             return dg(node, "cropArea");
1353         }
1354 
1355         override bool resetCropArea(Node node) nothrow {
1356             return dg(node, "resetCropArea");
1357         }
1358 
1359         override bool emitSignal(Node node, string) nothrow {
1360             return dg(node, "emitSignal");
1361         }
1362 
1363         override bool drawTriangle(Node node, Vector2, Vector2, Vector2, Color) nothrow {
1364             return dg(node, "drawTriangle");
1365         }
1366 
1367         override bool drawCircle(Node node, Vector2, float, Color) nothrow {
1368             return dg(node, "drawCircle");
1369         }
1370 
1371         override bool drawCircleOutline(Node node, Vector2, float, float, Color) nothrow {
1372             return dg(node, "drawCircleOutline");
1373         }
1374 
1375         override bool drawRectangle(Node node, Rectangle, Color) nothrow {
1376             return dg(node, "drawRectangle");
1377         }
1378 
1379         override bool drawLine(Node node, Vector2, Vector2, float, Color) nothrow {
1380             return dg(node, "drawLine");
1381         }
1382 
1383         override bool drawImage(Node node, DrawableImage, Rectangle, Color) nothrow {
1384             return dg(node, "drawImage");
1385         }
1386 
1387         override bool drawHintedImage(Node node, DrawableImage, Rectangle, Color) nothrow {
1388             return dg(node, "drawHintedImage");
1389         }
1390 
1391         override string toString() const {
1392             return message;
1393         }
1394 
1395     };
1396 
1397 }
1398 
1399 /// Output every draw instruction to stdout (`dumpDraws`), and, optionally, to an SVG file (`dumpDrawsToSVG`).
1400 ///
1401 /// Note that `dumpDraws` is equivalent to an `isDrawn` assert. It cannot be mixed with any other asserts on the same
1402 /// node.
1403 ///
1404 /// SVG support has to be enabled by passing `Fluid_SVG`.
1405 /// It requires extra dependencies: [elemi](https://code.dlang.org/packages/elemi)
1406 /// and [arsd-official:image_files](https://code.dlang.org/packages/arsd-official%3Aimage_files).
1407 /// To create an SVG image, call `dumpDrawsToSVG`.
1408 /// SVG support is currently incomplete and unstable. Changes can be made to this feature without prior announcement.
1409 ///
1410 /// Params:
1411 ///     subject  = Subject the output of which should be captured.
1412 ///     filename = Path to save the SVG output to. Requires version `Fluid_SVG` to be set, ignored otherwise.
1413 /// Returns:
1414 ///     An assert object to pass to `TestSpace.drawAndAssert`.
1415 pragma(mangle, "fluid__test_space_dumpDrawsToSVG")
1416 auto dumpDrawsToSVG(Node subject, string filename = null) {
1417     auto a = dumpDraws(subject);
1418     a.generateSVG = true;
1419     a.svgFilename = filename;
1420     return a;
1421 }
1422 
1423 /// ditto
1424 pragma(mangle, "fluid__test_space_dumpDraws")
1425 auto dumpDraws(Node subject) {
1426 
1427     import std.stdio;
1428 
1429     return new class BlackHole!Assert {
1430 
1431         bool generateSVG;
1432         string svgFilename;
1433 
1434         version (Fluid_SVG) {
1435             import elemi.xml;
1436             Element svg;
1437             bool[Color] tints;
1438         }
1439 
1440         version (Fluid_SVG)
1441         Element exportSVG() nothrow @safe {
1442 
1443             return assumeWontThrow(
1444                 elems(
1445                     Element.XMLDeclaration1_0,
1446                     elem!"svg"(
1447                         attr("xmlns") = "http://www.w3.org/2000/svg",
1448                         attr("version") = "1.1",
1449                         svg,
1450                     ),
1451                 ),
1452             );
1453 
1454         }
1455 
1456         void saveSVG() nothrow @safe {
1457 
1458             import std.file : write;
1459 
1460             version (Fluid_SVG) {
1461                 if (generateSVG && svgFilename !is null) {
1462                     assumeWontThrow(
1463                         write(svgFilename, exportSVG)
1464                     );
1465                 }
1466             }
1467 
1468         }
1469 
1470         bool isSubject(Node node) nothrow @trusted {
1471             return subject is null || node.opEquals(subject).assertNotThrown;
1472         }
1473 
1474         void dump(string fmt, Arguments...)(Node node, Arguments arguments) nothrow @trusted {
1475             if (isSubject(node)) {
1476                 writefln!fmt(arguments).assertNotThrown;
1477             }
1478         }
1479 
1480         override bool beforeDraw(Node node, Rectangle space, Rectangle, Rectangle) nothrow {
1481             dump!"node.isDrawn().at(%s, %s, %s, %s),"(node, space.tupleof);
1482             return false;
1483         }
1484 
1485         override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) nothrow {
1486             if (subject && isSubject(node)) {
1487                 saveSVG();
1488                 return true;
1489             }
1490             return false;
1491         }
1492 
1493         override bool cropArea(Node node, Rectangle rectangle) nothrow {
1494             dump!"node.cropsTo(%s, %s, %s, %s),"(node, rectangle.tupleof);
1495             return false;
1496         }
1497 
1498         override bool resetCropArea(Node node) nothrow {
1499             dump!"node.resetsCrop(),"(node);
1500             return false;
1501         }
1502 
1503         override bool emitSignal(Node node, string text) nothrow {
1504             dump!"node.emits(%(%s%)),"(node, text.only);
1505             return false;
1506         }
1507 
1508         override bool drawTriangle(Node node, Vector2 a, Vector2 b, Vector2 c, Color color) nothrow {
1509 
1510             if (isSubject(node)) {
1511                 dump!"drawTriangle(%s, %s, %s, %s),"(node, a, b, c, color.toHex.assumeWontThrow);
1512 
1513                 version (Fluid_SVG) if (generateSVG) {
1514                     assumeWontThrow(
1515                         svg ~=  elem!"polygon"(
1516                             attr("points") = [
1517                                 toText(a.x, a.y),
1518                                 toText(b.x, b.y),
1519                                 toText(c.x, c.y),
1520                             ],
1521                             attr("fill") = color.toHex,
1522                         ),
1523                     );
1524                 }
1525             }
1526 
1527             return false;
1528         }
1529 
1530         override bool drawCircle(Node node, Vector2 center, float radius, Color color) nothrow {
1531 
1532             if (isSubject(node)) {
1533                 dump!`node.drawsCircle().at(%s, %s).ofRadius(%s).ofColor("%s"),`
1534                     (node, center.x, center.y, radius, color.toHex.assumeWontThrow);
1535 
1536                 version (Fluid_SVG) if (generateSVG) {
1537                     assumeWontThrow(
1538                         svg ~= elem!"circle"(
1539                             attr("cx")   = toText(center.x),
1540                             attr("cy")   = toText(center.y),
1541                             attr("r")    = toText(radius),
1542                             attr("fill") = color.toHex,
1543                         ),
1544                     );
1545                 }
1546             }
1547 
1548             return false;
1549         }
1550 
1551         override bool drawCircleOutline(Node node, Vector2 center, float radius, float width, Color color) nothrow {
1552 
1553             if (isSubject(node)) {
1554                 dump!`node.drawsCircleOutline().at(%s).ofRadius(%s).ofColor("%s"),`
1555                     (node, center, radius, color.toHex.assumeWontThrow);
1556 
1557                 version (Fluid_SVG) if (generateSVG) {
1558                     assumeWontThrow(
1559                         svg ~= elem!"circle"(
1560                             attr("cx")           = toText(center.x),
1561                             attr("cy")           = toText(center.y),
1562                             attr("r")            = toText(radius),
1563                             attr("fill")         = "none",
1564                             attr("stroke")       = color.toHex,
1565                             attr("stroke-width") = toText(width),
1566                         ),
1567                     );
1568                 }
1569             }
1570 
1571             return false;
1572         }
1573 
1574         override bool drawRectangle(Node node, Rectangle area, Color color) nothrow {
1575 
1576             if (isSubject(node)) {
1577                 dump!`node.drawsRectangle(%s, %s, %s, %s).ofColor("%s"),`
1578                     (node, area.tupleof, color.toHex.assumeWontThrow);
1579 
1580                 version (Fluid_SVG) if (generateSVG) {
1581                     assumeWontThrow(
1582                         svg ~= elem!"rect"(
1583                             attr("x")      = toText(area.x),
1584                             attr("y")      = toText(area.y),
1585                             attr("width")  = toText(area.width),
1586                             attr("height") = toText(area.height),
1587                             attr("fill")   = color.toHex,
1588                         ),
1589                     );
1590                 }
1591             }
1592 
1593             return false;
1594         }
1595 
1596         override bool drawLine(Node node, Vector2 start, Vector2 end, float width, Color color) nothrow {
1597 
1598             if (isSubject(node)) {
1599                 dump!`node.drawsLine().from(%s, %s).to(%s, %s).ofWidth(%s).ofColor("%s"),`
1600                     (node, start.tupleof, end.tupleof, width, color.toHex.assumeWontThrow);
1601 
1602                 version (Fluid_SVG) if (generateSVG) {
1603                     assumeWontThrow(
1604                         svg ~= elem!"line"(
1605                             attr("x1") = toText(start.x),
1606                             attr("y1") = toText(start.y),
1607                             attr("x2") = toText(end.x),
1608                             attr("y2") = toText(end.y),
1609                             attr("stroke") = color.toHex,
1610                             attr("stroke-width") = toText(width),
1611                         ),
1612                     );
1613                 }
1614             }
1615 
1616             return false;
1617         }
1618 
1619         override bool drawImage(Node node, DrawableImage image, Rectangle area, Color color)
1620         nothrow {
1621             dumpImage(node, image, area, color, false);
1622             return false;
1623         }
1624 
1625         override bool drawHintedImage(Node node, DrawableImage image, Rectangle area, Color color)
1626         nothrow {
1627             dumpImage(node, image, area, color, true);
1628             return false;
1629         }
1630 
1631         private void dumpImage(Node node, DrawableImage image, Rectangle area, Color tint,
1632             bool isHinted)
1633         nothrow @trusted {
1634 
1635             if (!isSubject(node)) return;
1636 
1637             dump!(`node.draws%sImage().at(%s, %s, %s, %s).ofColor("%s")` ~ "\n"
1638                 ~ `    .sha256("%(%02x%)"),`)
1639                 (node, isHinted ? "Hinted" : "", area.tupleof, tint.toHex.assumeWontThrow,
1640                 sha256Of(image.data)[]);
1641 
1642             if (image.area == 0) return;
1643 
1644             version (Fluid_SVG) if (generateSVG) {
1645 
1646                 import std.base64;
1647                 import arsd.png;
1648                 import arsd.image;
1649 
1650                 ubyte[] data = cast(ubyte[]) image.toRGBA.data;
1651 
1652                 // Load the image
1653                 auto arsdImage = new TrueColorImage(image.width, image.height, data);
1654 
1655                 // Encode as a PNG in a data URL
1656                 const png = arsdImage.writePngToArray().assumeWontThrow;
1657                 const string base64 = Base64.encode(png);
1658                 const url = "data:image/png;base64," ~ base64;
1659 
1660                 assumeWontThrow(
1661                     svg ~= elems(
1662                         useTint(tint),
1663                         elem!"image"(
1664                             attr("x")      = toText(area.x),
1665                             attr("y")      = toText(area.y),
1666                             attr("width")  = toText(area.width),
1667                             attr("height") = toText(area.height),
1668                             attr("href")   = url,
1669                             attr("style")  = format!"filter:url(#%s)"(tint.toHex!"t"),
1670                         ),
1671                     ),
1672                 );
1673 
1674             }
1675 
1676         }
1677 
1678         /// Generate a tint filter for the given color
1679         version (Fluid_SVG)
1680         private Element useTint(Color color) {
1681 
1682             // Ignore if the given filter already exists
1683             if (color in tints) return elems();
1684 
1685             tints[color] = true;
1686 
1687             // <pain>
1688             return elem!"filter"(
1689 
1690                 // Use the color as the filter ID, prefixed with "t" instead of "#"
1691                 attr("id") = color.toHex!"t",
1692 
1693                 // Create a layer full of that color
1694                 elem!"feFlood"(
1695                     attr("x") = "0",
1696                     attr("y") = "0",
1697                     attr("width") = "100%",
1698                     attr("height") = "100%",
1699                     attr("flood-color") = color.toHex,
1700                 ),
1701 
1702                 // Blend in with the original image
1703                 elem!"feBlend"(
1704                     attr("in2") = "SourceGraphic",
1705                     attr("mode") = "multiply",
1706                 ),
1707 
1708                 // Use the source image for opacity
1709                 elem!"feComposite"(
1710                     attr("in2") = "SourceGraphic",
1711                     attr("operator") = "in",
1712                 ),
1713 
1714             );
1715             // </pain>
1716 
1717         }
1718 
1719         override string toString() const {
1720             return format!"%s must be reached"(subject);
1721         }
1722 
1723     };
1724 
1725 }
1726 
1727 private bool equal(float a, float b) nothrow {
1728 
1729     const diff = a - b;
1730 
1731     return diff >= -0.01
1732         && diff <= +0.01;
1733 
1734 }