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