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