1 /// A headless backend. This backend does not actually render anything. This allows apps reliant on Fluid to run
2 /// outside of graphical environments, provided an alternative method of access exist.
3 ///
4 /// This backend is used internally in Fluid for performing tests. For this reason, this module may be configured to
5 /// capture the output in a way that can be analyzed or compared againt later. This functionality is disabled by
6 /// default due to significant overhead — use version `Fluid_HeadlessOutput` to turn it on.
7 ///
8 /// If `elemi` is added as a dependency and `Fluid_HeadlessOutput` is set, the backend will also expose its
9 /// experimental SVG export functionality through `saveSVG`. It is only intended for testing; note it will export text
10 /// as embedded raster images rather than proper vector text.
11 module fluid.backend.headless;
12 
13 debug (Fluid_BuildMessages) {
14     pragma(msg, "Fluid: Building with headless backend");
15 }
16 
17 import std.math;
18 import std.array;
19 import std.range;
20 import std.string;
21 import std.algorithm;
22 
23 import fluid.backend;
24 
25 version (Fluid_HeadlessOutput) {
26     import std.sumtype;
27 }
28 
29 
30 @safe:
31 
32 
33 /// Rendering textures in SVG requires arsd.image
34 version (Have_arsd_official_image_files)
35     enum svgTextures = true;
36 else
37     enum svgTextures = false;
38 
39 debug (Fluid_BuildMessages) {
40     pragma(msg, "Fluid: SVG output support " ~ (svgTextures ? "ON" : "OFF"));
41 }
42 
43 class HeadlessBackend : FluidBackend {
44 
45     enum defaultWindowSize = Vector2(800, 600);
46 
47     enum State {
48 
49         up,
50         pressed,
51         repeated,
52         down,
53         released,
54 
55     }
56 
57     struct DrawnLine {
58 
59         Vector2 start, end;
60         Color color;
61 
62         bool isClose(Vector2 a, Vector2 b) const {
63             return (
64                 .isClose(start.x, this.start.x)
65                 && .isClose(start.y, this.start.y)
66                 && .isClose(end.x, this.end.x)
67                 && .isClose(end.y, this.end.y))
68             || (
69                 .isClose(end.x, this.end.x)
70                 && .isClose(end.y, this.end.y)
71                 && .isClose(start.x, this.start.x)
72                 && .isClose(start.y, this.start.y));
73         }
74 
75     }
76 
77     struct DrawnTriangle {
78 
79         Vector2 a, b, c;
80         Color color;
81 
82         bool isClose(Vector2 a, Vector2 b, Vector2 c) const {
83             return .isClose(a.x, this.a.x)
84                 && .isClose(a.y, this.a.y)
85                 && .isClose(b.x, this.b.x)
86                 && .isClose(b.y, this.b.y)
87                 && .isClose(c.x, this.c.x)
88                 && .isClose(c.y, this.c.y);
89         }
90 
91     }
92 
93     struct DrawnCircle {
94 
95         Vector2 position;
96         float radius;
97         Color color;
98         bool outlineOnly;
99 
100         bool isClose(Vector2 position, float radius) const {
101             return .isClose(position.x, this.position.x)
102                 && .isClose(position.y, this.position.y)
103                 && .isClose(radius, this.radius);
104         }
105 
106     }
107 
108     struct DrawnRectangle {
109 
110         Rectangle rectangle;
111         Color color;
112 
113         alias rectangle this;
114 
115         bool isClose(Rectangle rectangle) const {
116             return isClose(rectangle.tupleof);
117         }
118 
119         bool isClose(float x, float y, float width, float height) const {
120             return .isClose(this.rectangle.x, x)
121                 && .isClose(this.rectangle.y, y)
122                 && .isClose(this.rectangle.width, width)
123                 && .isClose(this.rectangle.height, height);
124         }
125 
126         bool isStartClose(Vector2 start) const {
127             return isStartClose(start.tupleof);
128         }
129 
130         bool isStartClose(float x, float y) const {
131             return .isClose(this.rectangle.x, x)
132                 && .isClose(this.rectangle.y, y);
133         }
134 
135     }
136 
137     struct DrawnTexture {
138 
139         uint id;
140         int width;
141         int height;
142         int dpiX;
143         int dpiY;
144         Rectangle rectangle;
145         Color tint;
146 
147         alias drawnRectangle this;
148 
149         this(Texture texture, Rectangle rectangle, Color tint) {
150 
151             // Omit the "backend" Texture field to make `canvas` @safe
152             this.id = texture.id;
153             this.width = texture.width;
154             this.height = texture.height;
155             this.dpiX = texture.dpiX;
156             this.dpiY = texture.dpiY;
157             this.rectangle = rectangle;
158             this.tint = tint;
159 
160         }
161 
162         Vector2 position() const {
163             return Vector2(rectangle.x, rectangle.y);
164         }
165 
166         DrawnRectangle drawnRectangle() const {
167             return DrawnRectangle(rectangle, tint);
168         }
169 
170         alias isPositionClose = isStartClose;
171 
172         bool isStartClose(Vector2 start) const {
173             return isStartClose(start.tupleof);
174         }
175 
176         bool isStartClose(float x, float y) const {
177             return .isClose(rectangle.x, x)
178                 && .isClose(rectangle.y, y);
179         }
180 
181     }
182 
183     version (Fluid_HeadlessOutput) {
184 
185         alias Drawing = SumType!(DrawnLine, DrawnTriangle, DrawnCircle, DrawnRectangle, DrawnTexture);
186 
187         /// All items drawn during the last frame
188         Appender!(Drawing[]) canvas;
189 
190     }
191 
192     private {
193 
194         dstring characterQueue;
195         State[MouseButton.max+1] mouse;
196         State[KeyboardKey.max+1] keyboard;
197         State[GamepadButton.max+1] gamepad;
198         Vector2 _scroll;
199         Vector2 _mousePosition;
200         Vector2 _windowSize;
201         Vector2 _dpi = Vector2(96, 96);
202         float _scale = 1;
203         Rectangle _area;
204         FluidMouseCursor _cursor;
205         float _deltaTime = 1f / 60f;
206         bool _justResized;
207         bool _scissorsOn;
208         Color _tint = Color(0xff, 0xff, 0xff, 0xff);
209         string _clipboard;
210 
211         /// Currently allocated/used textures as URLs.
212         ///
213         /// Textures loaded from images are `null` if arsd.image isn't present.
214         string[uint] allocatedTextures;
215 
216         /// Texture reaper.
217         TextureReaper _reaper;
218 
219         /// Last used texture ID.
220         uint lastTextureID;
221 
222     }
223 
224     this(Vector2 windowSize = defaultWindowSize) {
225 
226         this._windowSize = windowSize;
227 
228     }
229 
230     bool opEquals(FluidBackend other) const {
231 
232         return this is other;
233 
234     }
235 
236     /// Switch to the next frame.
237     void nextFrame(float deltaTime = 1f / 60f) {
238 
239         deltaTime = deltaTime;
240 
241         // Clear temporary data
242         characterQueue = null;
243         _justResized = false;
244         _scroll = Vector2();
245 
246         version (Fluid_HeadlessOutput) {
247             canvas.clear();
248         }
249 
250         // Update input
251         foreach (ref state; chain(mouse[], keyboard[], gamepad[])) {
252 
253             final switch (state) {
254 
255                 case state.up:
256                 case state.down:
257                     break;
258                 case state.pressed:
259                 case state.repeated:
260                     state = State.down;
261                     break;
262                 case state.released:
263                     state = State.up;
264                     break;
265 
266 
267             }
268 
269         }
270 
271     }
272 
273     /// Resize the window.
274     void resize(Vector2 size) {
275 
276         _windowSize = size;
277         _justResized = true;
278 
279     }
280 
281     /// Press the given key, and hold it until `release`. Marks as repeated if already down.
282     void press(KeyboardKey key) {
283 
284         if (isDown(key))
285             keyboard[key] = State.repeated;
286         else
287             keyboard[key] = State.pressed;
288 
289     }
290 
291     /// Release the given keyboard key.
292     void release(KeyboardKey key) {
293 
294         keyboard[key] = State.released;
295 
296     }
297 
298     /// Press the given button, and hold it until `release`.
299     void press(MouseButton button = MouseButton.left) {
300 
301         mouse[button] = State.pressed;
302 
303     }
304 
305     /// Release the given mouse button.
306     void release(MouseButton button = MouseButton.left) {
307 
308         mouse[button] = State.released;
309 
310     }
311 
312     /// Press the given button, and hold it until `release`.
313     void press(GamepadButton button) {
314 
315         gamepad[button] = State.pressed;
316 
317     }
318 
319     /// Release the given mouse button.
320     void release(GamepadButton button) {
321 
322         gamepad[button] = State.released;
323 
324     }
325 
326     /// Check if the given mouse button has just been pressed/released or, if it's held down or not (up).
327     bool isPressed(MouseButton button) const {
328          return mouse[button] == State.pressed;
329     }
330 
331     bool isReleased(MouseButton button) const {
332         return mouse[button] == State.released;
333     }
334 
335     bool isDown(MouseButton button) const {
336         return mouse[button] == State.pressed
337             || mouse[button] == State.repeated
338             || mouse[button] == State.down;
339     }
340 
341     bool isUp(MouseButton button) const {
342         return mouse[button] == State.released
343             || mouse[button] == State.up;
344     }
345 
346     /// Check if the given keyboard key has just been pressed/released or, if it's held down or not (up).
347     bool isPressed(KeyboardKey key) const {
348         return keyboard[key] == State.pressed;
349     }
350 
351     bool isReleased(KeyboardKey key) const {
352         return keyboard[key] == State.released;
353     }
354 
355     bool isDown(KeyboardKey key) const {
356         return keyboard[key] == State.pressed
357             || keyboard[key] == State.repeated
358             || keyboard[key] == State.down;
359     }
360 
361     bool isUp(KeyboardKey key) const {
362         return keyboard[key] == State.released
363             || keyboard[key] == State.up;
364     }
365 
366     /// If true, the given keyboard key has been virtually pressed again, through a long-press.
367     bool isRepeated(KeyboardKey key) const {
368         return keyboard[key] == State.repeated;
369     }
370 
371     /// Get next queued character from user's input. The queue should be cleared every frame. Return null if no
372     /// character was pressed.
373     dchar inputCharacter() {
374 
375         if (characterQueue.empty) return '\0';
376 
377         auto c = characterQueue.front;
378         characterQueue.popFront;
379         return c;
380 
381     }
382 
383     /// Insert a character into input queue.
384     void inputCharacter(dchar character) {
385 
386         characterQueue ~= character;
387 
388     }
389 
390     /// ditto
391     void inputCharacter(dstring str) {
392 
393         characterQueue ~= str;
394 
395     }
396 
397     /// Check if the given gamepad button has been pressed/released or, if it's held down or not (up).
398     int isPressed(GamepadButton button) const {
399 		return gamepad[button] == State.pressed;
400 	}
401 
402 	int isReleased(GamepadButton button) const {
403 		return gamepad[button] == State.released;
404 	}
405 
406 	int isDown(GamepadButton button) const {
407 		return gamepad[button] == State.pressed
408 			|| gamepad[button] == State.repeated
409 			|| gamepad[button] == State.down;
410 	}
411 
412     int isUp(GamepadButton button) const {
413         return gamepad[button] == State.released
414             || gamepad[button] == State.up;
415     }
416 
417     int isRepeated(GamepadButton button) const {
418         return gamepad[button] == State.repeated;
419     }
420 
421     /// Get/set mouse position
422     Vector2 mousePosition(Vector2 value) {
423         return _mousePosition = value;}
424 
425     Vector2 mousePosition() const {
426         return _mousePosition;
427     }
428 
429     /// Get/set mouse scroll
430     Vector2 scroll(Vector2 value) {
431         return _scroll = scroll;
432     }
433 
434     Vector2 scroll() const {
435         return _scroll;
436     }
437 
438     string clipboard(string value) @trusted {
439         return _clipboard = value;
440     }
441 
442     string clipboard() const @trusted {
443         return _clipboard;
444     }
445 
446     /// Get time elapsed since last frame in seconds.
447     float deltaTime() const {
448         return _deltaTime;
449     }
450 
451     /// True if the user has just resized the window.
452     bool hasJustResized() const {
453         return _justResized;
454     }
455 
456     /// Get or set the size of the window.
457     Vector2 windowSize(Vector2 value) {
458         resize(value);
459         return value;
460     }
461 
462     Vector2 windowSize() const {
463         return _windowSize;
464     }
465 
466     float scale() const {
467         return _scale;
468     }
469 
470     float scale(float value) {
471         return _scale = value;
472     }
473 
474     /// Get HiDPI scale of the window. This is not currently supported by this backend.
475     Vector2 dpi() const {
476         return _dpi * _scale;
477     }
478 
479     /// Set area within the window items will be drawn to; any pixel drawn outside will be discarded.
480     Rectangle area(Rectangle rect) {
481         _scissorsOn = true;
482         return _area = rect;
483     }
484 
485     Rectangle area() const {
486 
487         if (_scissorsOn)
488             return _area;
489         else
490             return Rectangle(0, 0, _windowSize.tupleof);
491     }
492 
493     /// Restore the capability to draw anywhere in the window.
494     void restoreArea() {
495         _scissorsOn = false;
496     }
497 
498     /// Get or set mouse cursor icon.
499     FluidMouseCursor mouseCursor(FluidMouseCursor cursor) {
500         return _cursor = cursor;
501     }
502 
503     FluidMouseCursor mouseCursor() const {
504         return _cursor;
505     }
506 
507     TextureReaper* reaper() return scope {
508 
509         return &_reaper;
510 
511     }
512 
513     Texture loadTexture(Image image) @system {
514 
515         auto texture = loadTexture(null, image.width, image.height);
516         texture.format = image.format;
517 
518         // Fill the texture with data
519         updateTexture(texture, image);
520 
521         return texture;
522 
523     }
524 
525     Texture loadTexture(string filename) @system {
526 
527         static if (svgTextures) {
528 
529             import std.uri : encodeURI = encode;
530             import std.path;
531             import arsd.image;
532 
533             // Load the image to check its size
534             auto image = loadImageFromFile(filename);
535             auto url = format!"file:///%s"(filename.absolutePath.encodeURI);
536 
537             return loadTexture(url, image.width, image.height);
538 
539         }
540 
541         // Can't load the texture, pretend to load a 16px texture
542         else return loadTexture(null, 16, 16);
543 
544     }
545 
546     Texture loadTexture(string url, int width, int height) {
547 
548         Texture texture;
549         texture.id = ++lastTextureID;
550         texture.tombstone = reaper.makeTombstone(this, texture.id);
551         texture.width = width;
552         texture.height = height;
553 
554         // Allocate the texture
555         allocatedTextures[texture.id] = url;
556 
557         return texture;
558 
559     }
560 
561     void updateTexture(Texture texture, Image image) @system
562     in (false)
563     do {
564 
565         static if (svgTextures) {
566 
567             import std.base64;
568             import arsd.png;
569             import arsd.image;
570 
571             ubyte[] data = cast(ubyte[]) image.toRGBA.data;
572 
573             // Load the image
574             auto arsdImage = new TrueColorImage(image.width, image.height, data);
575 
576             // Encode as a PNG in a data URL
577             auto png = arsdImage.writePngToArray();
578             auto base64 = Base64.encode(png);
579             auto url = format!"data:image/png;base64,%s"(base64);
580 
581             // Set the URL
582             allocatedTextures[texture.id] = url;
583 
584         }
585 
586         else
587             allocatedTextures[texture.id] = null;
588 
589     }
590 
591     /// Destroy a texture created by this backend. `texture.destroy()` is the preferred way of calling this, since it
592     /// will ensure the correct backend is called.
593     void unloadTexture(uint id) @system {
594 
595         const found = id in allocatedTextures;
596 
597         assert(found, format!"headless: Attempted to free nonexistent texture ID %s (double free?)"(id));
598 
599         allocatedTextures.remove(id);
600 
601     }
602 
603     /// Check if the given texture has a valid ID
604     bool isTextureValid(Texture texture) {
605 
606         return cast(bool) (texture.id in allocatedTextures);
607 
608     }
609 
610     bool isTextureValid(uint id) {
611 
612         return cast(bool) (id in allocatedTextures);
613 
614     }
615 
616     Color tint(Color color) {
617 
618         return _tint = color;
619 
620     }
621 
622     Color tint() const {
623 
624         return _tint;
625 
626     }
627 
628     /// Draw a line.
629     void drawLine(Vector2 start, Vector2 end, Color color) {
630 
631         color = multiply(color, tint);
632 
633         version (Fluid_HeadlessOutput) {
634             canvas ~= Drawing(DrawnLine(start, end, color));
635         }
636 
637     }
638 
639     /// Draw a triangle, consisting of 3 vertices with counter-clockwise winding.
640     void drawTriangle(Vector2 a, Vector2 b, Vector2 c, Color color) {
641 
642         color = multiply(color, tint);
643         version (Fluid_HeadlessOutput) {
644             canvas ~= Drawing(DrawnTriangle(a, b, c, color));
645         }
646 
647     }
648 
649     /// Draw a circle.
650     void drawCircle(Vector2 position, float radius, Color color) {
651 
652         color = multiply(color, tint);
653         version (Fluid_HeadlessOutput) {
654             canvas ~= Drawing(DrawnCircle(position, radius, color));
655         }
656 
657     }
658 
659     /// Draw a circle, but outline only.
660     void drawCircleOutline(Vector2 position, float radius, Color color) {
661 
662         color = multiply(color, tint);
663         version (Fluid_HeadlessOutput) {
664             canvas ~= Drawing(DrawnCircle(position, radius, color, true));
665         }
666 
667     }
668 
669     /// Draw a rectangle.
670     void drawRectangle(Rectangle rectangle, Color color) {
671 
672         color = multiply(color, tint);
673         version (Fluid_HeadlessOutput) {
674             canvas ~= Drawing(DrawnRectangle(rectangle, color));
675         }
676 
677     }
678 
679     /// Draw a texture.
680     void drawTexture(Texture texture, Rectangle rectangle, Color tint)
681     in (false)
682     do {
683 
684         tint = multiply(tint, this.tint);
685         version (Fluid_HeadlessOutput) {
686             canvas ~= Drawing(DrawnTexture(texture, rectangle, tint));
687         }
688 
689     }
690 
691     /// Draw a texture, but keep it aligned to pixel boundaries.
692     void drawTextureAlign(Texture texture, Rectangle rectangle, Color tint)
693     in (false)
694     do {
695 
696         drawTexture(texture, rectangle, tint);
697 
698     }
699 
700     /// Get items from the canvas that match the given type.
701     version (Fluid_HeadlessOutput) {
702 
703         auto filterCanvas(T)() {
704 
705             return canvas[]
706 
707                 // Filter out items that don't match what was requested
708                 .filter!(a => a.match!(
709                     (T item) => true,
710                     (_) => false
711                 ))
712 
713                 // Return items that match
714                 .map!(a => a.match!(
715                     (T item) => item,
716                     (_) => assert(false),
717                 ));
718 
719         }
720 
721         alias lines = filterCanvas!DrawnLine;
722         alias triangles = filterCanvas!DrawnTriangle;
723         alias rectangles = filterCanvas!DrawnRectangle;
724         alias textures = filterCanvas!DrawnTexture;
725 
726     }
727 
728     /// Throw an `AssertError` if given line was never drawn.
729     version (Fluid_HeadlessOutput)
730     void assertLine(Vector2 a, Vector2 b, Color color) {
731 
732         assert(
733             lines.canFind!(line => line.isClose(a, b) && line.color == color),
734             "No matching line"
735         );
736 
737     }
738 
739     /// Throw an `AssertError` if given triangle was never drawn.
740     version (Fluid_HeadlessOutput)
741     void assertTriangle(Vector2 a, Vector2 b, Vector2 c, Color color) {
742 
743         assert(
744             triangles.canFind!(trig => trig.isClose(a, b, c) && trig.color == color),
745             "No matching triangle"
746         );
747 
748     }
749 
750     /// Throw an `AssertError` if given rectangle was never drawn.
751     version (Fluid_HeadlessOutput)
752     void assertRectangle(Rectangle r, Color color) {
753 
754         assert(
755             rectangles.canFind!(rect => rect.isClose(r) && rect.color == color),
756             format!"No rectangle matching %s %s"(r, color)
757         );
758 
759     }
760 
761     /// Throw an `AssertError` if the texture was never drawn with given parameters.
762     version (Fluid_HeadlessOutput)
763     void assertTexture(const Texture texture, Vector2 position, Color tint) {
764 
765         assert(texture.backend is this, "Given texture comes from a different backend");
766         assert(
767             textures.canFind!(tex
768                 => tex.id == texture.id
769                 && tex.width == texture.width
770                 && tex.height == texture.height
771                 && tex.dpiX == texture.dpiX
772                 && tex.dpiY == texture.dpiY
773                 && tex.isPositionClose(position)
774                 && tex.tint == tint),
775             "No matching texture"
776         );
777 
778     }
779 
780     /// Throw an `AssertError` if given texture was never drawn.
781     version (Fluid_HeadlessOutput)
782     void assertTexture(Rectangle r, Color color) {
783 
784         assert(
785             textures.canFind!(rect => rect.isClose(r) && rect.color == color),
786             "No matching texture"
787         );
788 
789     }
790 
791     version (Fluid_HeadlessOutput)
792     version (Have_elemi) {
793 
794         import std.conv;
795         import elemi.xml;
796 
797         /// Convert the canvas to SVG. Intended for debugging only.
798         ///
799         /// `toSVG` provides the document as a string (including the XML prolog), `toSVGElement` provides a Fluid element
800         /// (without the prolog) and `saveSVG` saves it to a file.
801         ///
802         /// Note that rendering textures and text is only done if arsd.image is available. Otherwise, they will display
803         /// as rectangles filled with whatever tint color was set. Text, if rendered, is rasterized, because it occurs
804         /// earlier in the pipeline, and is not available to the backend.
805         void saveSVG(string filename) const {
806 
807             import std.file : write;
808 
809             write(filename, toSVG);
810 
811         }
812 
813         /// ditto
814         string toSVG() const {
815             return Element.XMLDeclaration1_0 ~ this.toSVGElement;
816         }
817 
818         /// ditto
819         Element toSVGElement() const {
820 
821             /// Colors available as tint filters in the document.
822             bool[Color] tints;
823 
824             /// Generate a tint filter for the given color
825             Element useTint(Color color) {
826 
827                 // Ignore if the given filter already exists
828                 if (color in tints) return elems();
829 
830                 tints[color] = true;
831 
832                 // <pain>
833                 return elem!"filter"(
834 
835                     // Use the color as the filter ID, prefixed with "t" instead of "#"
836                     attr("id") = color.toHex!"t",
837 
838                     // Create a layer full of that color
839                     elem!"feFlood"(
840                         attr("x") = "0",
841                         attr("y") = "0",
842                         attr("width") = "100%",
843                         attr("height") = "100%",
844                         attr("flood-color") = color.toHex,
845                     ),
846 
847                     // Blend in with the original image
848                     elem!"feBlend"(
849                         attr("in2") = "SourceGraphic",
850                         attr("mode") = "multiply",
851                     ),
852 
853                     // Use the source image for opacity
854                     elem!"feComposite"(
855                         attr("in2") = "SourceGraphic",
856                         attr("operator") = "in",
857                     ),
858 
859                 );
860                 // </pain>
861 
862             }
863 
864             return elem!"svg"(
865                 attr("xmlns") = "http://www.w3.org/2000/svg",
866                 attr("version") = "1.1",
867                 attr("width") = text(cast(int) windowSize.x),
868                 attr("height") = text(cast(int) windowSize.y),
869                 attr("style") = "background: #000",
870 
871                 canvas[].map!(a => a.match!(
872                     (DrawnLine line) => elem!"line"(
873                         attr("x1") = line.start.x.text,
874                         attr("y1") = line.start.y.text,
875                         attr("x2") = line.end.x.text,
876                         attr("y2") = line.end.y.text,
877                         attr("stroke") = line.color.toHex,
878                     ),
879                     (DrawnTriangle trig) => elem!"polygon"(
880                         attr("points") = [
881                             format!"%s,%s"(trig.a.tupleof),
882                             format!"%s,%s"(trig.b.tupleof),
883                             format!"%s,%s"(trig.c.tupleof),
884                         ],
885                         attr("fill") = trig.color.toHex,
886                     ),
887                     (DrawnCircle circle) => elems(), // TODO
888                     (DrawnTexture texture) {
889 
890                         auto url = texture.id in allocatedTextures;
891 
892                         // URL given, valid image
893                         if (url && *url)
894                             return elems(
895                                 useTint(texture.tint),
896                                 elem!"image"(
897                                     attr("x") = texture.rectangle.x.text,
898                                     attr("y") = texture.rectangle.y.text,
899                                     attr("width") = texture.rectangle.width.text,
900                                     attr("height") = texture.rectangle.height.text,
901                                     attr("href") = *url,
902                                     attr("style") = format!"filter:url(#%s)"(texture.tint.toHex!"t"),
903                                 ),
904                             );
905 
906                         // No image, draw a placeholder rect
907                         else
908                             return elem!"rect"(
909                                 attr("x") = texture.position.x.text,
910                                 attr("y") = texture.position.y.text,
911                                 attr("width") = texture.width.text,
912                                 attr("height") = texture.height.text,
913                                 attr("fill") = texture.tint.toHex,
914                             );
915 
916                     },
917                     (DrawnRectangle rect) => elem!"rect"(
918                         attr("x") = rect.x.text,
919                         attr("y") = rect.y.text,
920                         attr("width") = rect.width.text,
921                         attr("height") = rect.height.text,
922                         attr("fill") = rect.color.toHex,
923                     ),
924                 ))
925             );
926 
927         }
928 
929     }
930 
931 }
932 
933 unittest {
934 
935     auto backend = new HeadlessBackend(Vector2(800, 600));
936 
937     with (backend) {
938 
939         press(MouseButton.left);
940 
941         assert(isPressed(MouseButton.left));
942         assert(isDown(MouseButton.left));
943         assert(!isUp(MouseButton.left));
944         assert(!isReleased(MouseButton.left));
945 
946         press(KeyboardKey.enter);
947 
948         assert(isPressed(KeyboardKey.enter));
949         assert(isDown(KeyboardKey.enter));
950         assert(!isUp(KeyboardKey.enter));
951         assert(!isReleased(KeyboardKey.enter));
952         assert(!isRepeated(KeyboardKey.enter));
953 
954         nextFrame;
955 
956         assert(!isPressed(MouseButton.left));
957         assert(isDown(MouseButton.left));
958         assert(!isUp(MouseButton.left));
959         assert(!isReleased(MouseButton.left));
960 
961         assert(!isPressed(KeyboardKey.enter));
962         assert(isDown(KeyboardKey.enter));
963         assert(!isUp(KeyboardKey.enter));
964         assert(!isReleased(KeyboardKey.enter));
965         assert(!isRepeated(KeyboardKey.enter));
966 
967         nextFrame;
968 
969         press(KeyboardKey.enter);
970 
971         assert(!isPressed(KeyboardKey.enter));
972         assert(isDown(KeyboardKey.enter));
973         assert(!isUp(KeyboardKey.enter));
974         assert(!isReleased(KeyboardKey.enter));
975         assert(isRepeated(KeyboardKey.enter));
976 
977         nextFrame;
978 
979         release(MouseButton.left);
980 
981         assert(!isPressed(MouseButton.left));
982         assert(!isDown(MouseButton.left));
983         assert(isUp(MouseButton.left));
984         assert(isReleased(MouseButton.left));
985 
986         release(KeyboardKey.enter);
987 
988         assert(!isPressed(KeyboardKey.enter));
989         assert(!isDown(KeyboardKey.enter));
990         assert(isUp(KeyboardKey.enter));
991         assert(isReleased(KeyboardKey.enter));
992         assert(!isRepeated(KeyboardKey.enter));
993 
994         nextFrame;
995 
996         assert(!isPressed(MouseButton.left));
997         assert(!isDown(MouseButton.left));
998         assert(isUp(MouseButton.left));
999         assert(!isReleased(MouseButton.left));
1000 
1001         assert(!isPressed(KeyboardKey.enter));
1002         assert(!isDown(KeyboardKey.enter));
1003         assert(isUp(KeyboardKey.enter));
1004         assert(!isReleased(KeyboardKey.enter));
1005         assert(!isRepeated(KeyboardKey.enter));
1006 
1007     }
1008 
1009 }
1010 
1011 /// std.math.isClose adjusted for the most common use-case.
1012 private bool isClose(float a, float b) {
1013 
1014     return std.math.isClose(a, b, 0.0, 0.05);
1015 
1016 }
1017 
1018 unittest {
1019 
1020     assert(isClose(1, 1));
1021     assert(isClose(1.004, 1));
1022     assert(isClose(1.01, 1.008));
1023     assert(isClose(1.02, 1));
1024     assert(isClose(1.01, 1.03));
1025 
1026     assert(!isClose(1, 2));
1027     assert(!isClose(1, 1.1));
1028 
1029 }