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