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