1 /// This module handles input/output facilities Fluid requires to operate. It connects backends like Raylib, exposing
2 /// them under a common interface so they can be changed at will.
3 ///
4 /// Fluid comes with a built-in interface for Raylib.
5 module fluid.backend;
6 
7 import std.meta;
8 import std.range;
9 import std.traits;
10 import std.datetime;
11 import std.algorithm;
12 
13 import fluid.io.mouse;
14 import fluid.io.keyboard;
15 
16 public import fluid.types;
17 public import fluid.backend.raylib5;
18 public import fluid.backend.headless;
19 
20 alias KeyboardKey = KeyboardIO.Key;
21 alias MouseButton = MouseIO.Button;
22 
23 version (Have_raylib_d) public static import raylib;
24 
25 
26 @safe:
27 
28 
29 alias VoidDelegate = void delegate() @safe;
30 
31 FluidBackend defaultFluidBackend();
32 
33 /// `FluidBackend` is an interface making it possible to bind Fluid to a library other than Raylib.
34 ///
35 /// The default unit in graphical space is a **pixel** (`px`), here defined as **1/96 of an inch**. This is unless
36 /// stated otherwise, as in `Texture`.
37 ///
38 /// Warning: Backend API is unstable and functions may be added or removed with no prior warning.
39 interface FluidBackend {
40 
41     /// Get system's double click time.
42     final Duration doubleClickTime() const {
43 
44         // TODO This should be overridable
45 
46         return 500.msecs;
47 
48     }
49 
50     bool opEquals(FluidBackend) const;
51 
52     /// Check if the given mouse button has just been pressed/released or, if it's held down or not (up).
53     bool isPressed(MouseButton) const;
54     bool isReleased(MouseButton) const;
55     bool isDown(MouseButton) const;
56     bool isUp(MouseButton) const;
57 
58     /// Check if the given keyboard key has just been pressed/released or, if it's held down or not (up).
59     bool isPressed(KeyboardKey) const;
60     bool isReleased(KeyboardKey) const;
61     bool isDown(KeyboardKey) const;
62     bool isUp(KeyboardKey) const;
63 
64     /// If true, the given keyboard key has been virtually pressed again, through a long-press.
65     bool isRepeated(KeyboardKey) const;
66 
67     /// Get next queued character from user's input. The queue should be cleared every frame. Return null if no
68     /// character was pressed.
69     dchar inputCharacter();
70 
71     /// Check if the given gamepad button has been pressed/released or, if it's held down or not (up) on any of the
72     /// connected gamepads.
73     ///
74     /// Returns: 0 if the event isn't taking place on any controller, or number of the controller.
75     int isPressed(GamepadButton button) const;
76     int isReleased(GamepadButton button) const;
77     int isDown(GamepadButton button) const;
78     int isUp(GamepadButton button) const;
79 
80     /// If true, the given gamepad button has been virtually pressed again, through a long-press.
81     ///
82     /// Returns: 0 if no controller had a button repeat this frame, or number of the controller.
83     int isRepeated(GamepadButton button) const;
84 
85     /// Get/set mouse position
86     Vector2 mousePosition(Vector2);
87     Vector2 mousePosition() const;
88 
89     /// Get scroll value on both axes.
90     Vector2 scroll() const;
91 
92     /// Get or set system clipboard value.
93     string clipboard(string);
94     string clipboard() const;
95 
96     /// Get time elapsed since last frame in seconds.
97     float deltaTime() const;
98 
99     /// True if the user has just resized the window.
100     bool hasJustResized() const;
101 
102     /// Get or set the size of the window.
103     Vector2 windowSize(Vector2);
104     Vector2 windowSize() const;  /// ditto
105 
106     /// Set scale to apply to whatever is drawn next.
107     ///
108     /// Suggested implementation is to increase return value of `dpi`.
109     float scale() const;
110 
111     /// ditto
112     float scale(float);
113 
114     /// Get horizontal and vertical DPI of the window.
115     Vector2 dpi() const;
116 
117     /// Get the DPI value for the window as a scale relative to 96 DPI.
118     final Vector2 hidpiScale() const {
119 
120         const dpi = this.dpi;
121         return Vector2(dpi.x / 96f, dpi.y / 96f);
122 
123     }
124 
125     /// Set area within the window items will be drawn to; any pixel drawn outside will be discarded.
126     Rectangle area(Rectangle rect);
127     Rectangle area() const;
128 
129     /// Restore the capability to draw anywhere in the window.
130     void restoreArea();
131 
132     /// Get or set mouse cursor icon.
133     FluidMouseCursor mouseCursor(FluidMouseCursor);
134     FluidMouseCursor mouseCursor() const;
135 
136     /// Texture reaper used by this backend. May be null.
137     ///
138     /// Highly recommended for OpenGL-based backends.
139     TextureReaper* reaper() return scope;
140 
141     /// Load a texture from memory or file.
142     Texture loadTexture(Image image) @system;
143     Texture loadTexture(string filename) @system;
144 
145     /// Update a texture from an image. The texture must be valid and must be of the same size and format as the image.
146     void updateTexture(Texture texture, Image image) @system
147     in (texture.format == image.format)
148     in (texture.width == image.width)
149     in (texture.height == image.height);
150 
151     /// Destroy a texture created by this backend. Always use `texture.destroy()` to ensure thread safety and invoking
152     /// the correct backend.
153     protected void unloadTexture(uint id) @system;
154 
155     /// ditto
156     final void unloadTexture(Texture texture) @system {
157 
158         unloadTexture(texture.id);
159 
160     }
161 
162     /// Set tint for all newly drawn shapes. The input color for every shape should be multiplied by this color.
163     Color tint(Color);
164 
165     /// Get current tint color.
166     Color tint() const;
167 
168     /// Draw a line.
169     void drawLine(Vector2 start, Vector2 end, Color color);
170 
171     /// Draw a triangle, consisting of 3 vertices with counter-clockwise winding.
172     void drawTriangle(Vector2 a, Vector2 b, Vector2 c, Color color);
173 
174     /// Draw a circle.
175     void drawCircle(Vector2 center, float radius, Color color);
176 
177     /// Draw a circle, but outline only.
178     void drawCircleOutline(Vector2 center, float radius, Color color);
179 
180     /// Draw a rectangle.
181     void drawRectangle(Rectangle rectangle, Color color);
182 
183     /// Draw a texture.
184     void drawTexture(Texture texture, Rectangle rectangle, Color tint)
185     in (texture.backend is this, "Given texture comes from a different backend");
186 
187     /// Draw a texture, but ensure it aligns with pixel boundaries, recommended for text.
188     void drawTextureAlign(Texture texture, Rectangle rectangle, Color tint)
189     in (texture.backend is this, "Given texture comes from a different backend");
190 
191 }
192 
193 /// To simplify setup in some scenarios, Fluid provides a uniform `run` function to immediately display UI and start 
194 /// the event loop. This function is provided by the backend using this optional interface.
195 ///
196 /// For `run` to use this backend, it has to be configured as the default backend or be passed explicitly to the `run`
197 /// function.
198 interface FluidEntrypointBackend : FluidBackend {
199 
200     import fluid.node : Node;
201 
202     /// Start a Fluid GUI app using this backend.
203     ///
204     /// This will draw the user interface and respond to input events in a loop, until the root node is marked for 
205     /// removal (`remove()`).
206     ///
207     /// See_Also: `fluid.node.run`
208     /// Params:
209     ///     root = Node to function as the root of the user interface.
210     void run(Node root);
211 
212 }
213 
214 /// Struct that maintains a registry of all allocated textures. It's used to finalize textures once they have been
215 /// marked for destruction. This makes it possible to mark them from any thread, while the reaper runs only on the main
216 /// thread, ensuring thread safety in OpenGL backends.
217 struct TextureReaper {
218 
219     /// Number of cycles between runs of the reaper.
220     int period = 60 * 5;
221 
222     int cycleAccumulator;
223 
224     @system shared(TextureTombstone)*[uint] textures;
225 
226     @disable this(ref TextureReaper);
227     @disable this(this);
228 
229     ~this() @trusted {
230 
231         destroyAll();
232 
233     }
234 
235     /// Create a tombstone.
236     shared(TextureTombstone)* makeTombstone(FluidBackend backend, uint textureID) @trusted {
237 
238         return textures[textureID] = TextureTombstone.make(backend);
239 
240     }
241 
242     /// Count number of cycles since last collection and collect if configured period has passed.
243     void check() {
244 
245         // Count cycles
246         if (++cycleAccumulator >= period) {
247 
248             // Run collection
249             collect();
250 
251         }
252 
253     }
254 
255     /// Collect all destroyed textures immediately.
256     void collect() @trusted {
257 
258         // Reset the cycle accumulator
259         cycleAccumulator = 0;
260 
261         // Find all destroyed textures
262         foreach (id, tombstone; textures) {
263 
264             if (!tombstone.isDestroyed) continue;
265 
266             auto backend = cast() tombstone.backend;
267 
268             // Unload the texture
269             backend.unloadTexture(id);
270 
271             // Disown the tombstone and remove it from the registry
272             tombstone.markDisowned();
273             textures.remove(id);
274 
275         }
276 
277     }
278 
279     /// Destroy all textures.
280     void destroyAll() @system {
281 
282         cycleAccumulator = 0;
283         scope (exit) textures.clear();
284 
285         // Find all textures
286         foreach (id, tombstone; textures) {
287 
288             auto backend = cast() tombstone.backend;
289 
290             // Unload the texture, even if it wasn't marked for deletion
291             backend.unloadTexture(id);
292             // TODO Should this be done? The destructor may be called from the GC. Maybe check if it was?
293             //      Test this!
294 
295             // Disown all textures
296             tombstone.markDisowned();
297 
298         }
299 
300     }
301 
302 }
303 
304 /// Tombstones are used to ensure textures are freed on the same thread they have been created on.
305 ///
306 /// Tombstones are kept alive until the texture is explicitly destroyed and then finalized (disowned) from the main
307 /// thread by a periodically-running `TextureReaper`. This is necessary to make Fluid safe in multithreaded
308 /// environments.
309 shared struct TextureTombstone {
310 
311     import core.memory;
312     import core.atomic;
313     import core.stdc.stdlib;
314 
315     /// Backend that created this texture.
316     private FluidBackend _backend;
317 
318     private int _references = 1;
319     private bool _disowned;
320 
321     @disable this(this);
322 
323     static TextureTombstone* make(FluidBackend backend) @system {
324 
325         import core.exception;
326 
327         // Allocate the tombstone
328         auto data = malloc(TextureTombstone.sizeof);
329         if (data is null) throw new OutOfMemoryError("Failed to allocate a tombstone");
330 
331         // Initialize the tombstone
332         shared tombstone = cast(shared TextureTombstone*) data;
333         *tombstone = TextureTombstone.init;
334         tombstone._backend = cast(shared) backend;
335 
336         assert(tombstone.references == 1);
337 
338         // Make sure the backend isn't freed while the tombstone is alive
339         GC.addRoot(cast(void*) backend);
340 
341         return tombstone;
342 
343     }
344 
345     /// Check if a request for destruction has been made for the texture.
346     bool isDestroyed() @system {
347         return _references.atomicLoad == 0;
348     }
349 
350     /// Check if the texture has been disowned by the backend. A disowned tombstone refers to a texture that has been
351     /// freed.
352     private bool isDisowned() @system {
353         return _disowned.atomicLoad;
354     }
355 
356     /// Get number of references to this tombstone.
357     private int references() @system {
358         return _references.atomicLoad;
359     }
360 
361     /// Get the backend owning this texture.
362     inout(shared FluidBackend) backend() inout {
363         return _backend;
364     }
365 
366     /// Mark the texture as destroyed.
367     void markDestroyed() @system {
368 
369         assert(!isDisowned || !isDestroyed, "Texture: Double destroy()");
370 
371         _references.atomicFetchSub(1);
372         tryDestroy();
373 
374     }
375 
376     /// Mark the texture as disowned.
377     private void markDisowned() @system {
378 
379         assert(!isDisowned || !isDestroyed);
380 
381         _disowned.atomicStore(true);
382         tryDestroy();
383 
384     }
385 
386     /// Mark the texture as copied.
387     private void markCopied() @system {
388 
389         _references.atomicFetchAdd(1);
390 
391     }
392 
393     /// As soon as the texture is both marked for destruction and disowned, the tombstone controlling its life is
394     /// destroyed.
395     ///
396     /// There are two relevant scenarios:
397     ///
398     /// * The texture is marked for destruction via a tombstone, then finalized from the main thread and disowned.
399     /// * The texture is finalized after the backend (for example, if they are both destroyed during the same GC
400     ///   collection). The backend disowns and frees the texture. The tombstone, however, remains alive to
401     ///   witness marking the texture as deleted.
402     ///
403     /// In both scenarios, this behavior ensures the tombstone will be freed.
404     private void tryDestroy() @system {
405 
406         // Destroyed and disowned
407         if (isDestroyed && isDisowned) {
408 
409             GC.removeRoot(cast(void*) _backend);
410             free(cast(void*) &this);
411 
412         }
413 
414     }
415 
416 }
417 
418 @system
419 unittest {
420 
421     // This unittest checks if textures will be correctly destroyed, even if the destruction call comes from another
422     // thread.
423 
424     import std.concurrency;
425     import fluid.space;
426     import fluid.image_view;
427 
428     auto io = new HeadlessBackend;
429     auto image = imageView("logo.png");
430     auto root = vspace(image);
431 
432     // Draw the frame once to let everything load
433     root.io = io;
434     root.draw();
435 
436     // Tune the reaper to run every frame
437     io.reaper.period = 1;
438 
439     // Get the texture
440     auto texture = image.release();
441     auto textureID = texture.id;
442     auto tombstone = texture.tombstone;
443 
444     // Texture should be allocated and assigned a tombstone
445     assert(texture.backend is io);
446     assert(!texture.tombstone.isDestroyed);
447     assert(io.isTextureValid(texture));
448 
449     // Destroy the texture on another thread
450     spawn((shared Texture sharedTexture) {
451 
452         auto texture = cast() sharedTexture;
453         texture.destroy();
454         ownerTid.send(true);
455 
456     }, cast(shared) texture);
457 
458     // Wait for confirmation
459     receiveOnly!bool;
460 
461     // The texture should be marked for deletion but remain alive
462     assert(texture.tombstone.isDestroyed);
463     assert(io.isTextureValid(texture));
464 
465     // Draw a frame, during which the reaper should destroy the texture
466     io.nextFrame;
467     root.children = [];
468     root.updateSize();
469     root.draw();
470 
471     assert(!io.isTextureValid(texture));
472     // There is no way to test if the tombstone has been freed
473 
474 }
475 
476 @system
477 unittest {
478 
479     // This unittest checks if tombstones work correctly even if the backend is destroyed before the texture.
480 
481     import std.concurrency;
482     import core.atomic;
483     import fluid.image_view;
484 
485     auto io = new HeadlessBackend;
486     auto root = imageView("logo.png");
487 
488     // Load the texture and draw
489     root.io = io;
490     root.draw();
491 
492     // Destroy the backend
493     destroy(io);
494 
495     auto texture = root.release();
496 
497     // The texture should have been automatically freed, but not marked for destruction
498     assert(!texture.tombstone.isDestroyed);
499     assert(texture.tombstone._disowned.atomicLoad);
500 
501     // Now, destroy the image
502     // If this operation succeeds, we're good
503     destroy(root);
504     // There is no way to test if the tombstone and texture have truly been freed
505 
506 }
507 
508 struct FluidMouseCursor {
509 
510     enum SystemCursors {
511 
512         systemDefault,     // Default system cursor.
513         none,              // No pointer.
514         pointer,           // Pointer indicating a link or button, typically a pointing hand. 👆
515         crosshair,         // Cross cursor, often indicating selection inside images.
516         text,              // Vertical beam indicating selectable text.
517         allScroll,         // Omnidirectional scroll, content can be scrolled in any direction (panned).
518         resizeEW,          // Cursor indicating the content underneath can be resized horizontally.
519         resizeNS,          // Cursor indicating the content underneath can be resized vertically.
520         resizeNESW,        // Diagonal resize cursor, top-right + bottom-left.
521         resizeNWSE,        // Diagonal resize cursor, top-left + bottom-right.
522         notAllowed,        // Indicates a forbidden action.
523 
524     }
525 
526     enum {
527 
528         systemDefault = FluidMouseCursor(SystemCursors.systemDefault),
529         none          = FluidMouseCursor(SystemCursors.none),
530         pointer       = FluidMouseCursor(SystemCursors.pointer),
531         crosshair     = FluidMouseCursor(SystemCursors.crosshair),
532         text          = FluidMouseCursor(SystemCursors.text),
533         allScroll     = FluidMouseCursor(SystemCursors.allScroll),
534         resizeEW      = FluidMouseCursor(SystemCursors.resizeEW),
535         resizeNS      = FluidMouseCursor(SystemCursors.resizeNS),
536         resizeNESW    = FluidMouseCursor(SystemCursors.resizeNESW),
537         resizeNWSE    = FluidMouseCursor(SystemCursors.resizeNWSE),
538         notAllowed    = FluidMouseCursor(SystemCursors.notAllowed),
539 
540     }
541 
542     /// Use a system-provided cursor.
543     SystemCursors system;
544     // TODO user-provided cursor image
545 
546 }
547 
548 enum GamepadButton {
549 
550     none,                // No such button
551     dpadUp,              // Dpad up button.
552     dpadRight,           // Dpad right button
553     dpadDown,            // Dpad down button
554     dpadLeft,            // Dpad left button
555     triangle,            // Triangle (PS) or Y (Xbox)
556     circle,              // Circle (PS) or B (Xbox)
557     cross,               // Cross (PS) or A (Xbox)
558     square,              // Square (PS) or X (Xbox)
559     leftButton,          // Left button behind the controlller (LB).
560     leftTrigger,         // Left trigger (LT).
561     rightButton,         // Right button behind the controller (RB).
562     rightTrigger,        // Right trigger (RT)
563     select,              // "Select" button.
564     vendor,              // Button with the vendor logo.
565     start,               // "Start" button.
566     leftStick,           // Left joystick press.
567     rightStick,          // Right joystick press.
568 
569     y = triangle,
570     x = square,
571     a = cross,
572     b = circle,
573 
574 }
575 
576 enum GamepadAxis {
577 
578     leftX,         // Left joystick, X axis.
579     leftY,         // Left joystick, Y axis.
580     rightX,        // Right joystick, X axis.
581     rightY,        // Right joystick, Y axis.
582     leftTrigger,   // Analog input for the left trigger.
583     rightTrigger,  // Analog input for the right trigger.
584 
585 }
586 
587 /// Image or texture can be rendered by Fluid, for example, a texture stored in VRAM.
588 ///
589 /// Textures make use of manual memory management. See `TextureGC` for a GC-managed texture.
590 struct Texture {
591 
592     /// Tombstone for this texture
593     shared(TextureTombstone)* tombstone;
594 
595     /// Format of the texture.
596     Image.Format format;
597 
598     /// GPU/backend ID of the texture.
599     uint id;
600 
601     /// Width and height of the texture, **in dots**. The meaning of a dot is defined by `dpiX` and `dpiY`
602     int width, height;
603 
604     /// Dots per inch for the X and Y axis. Defaults to 96, thus making a dot in the texture equivalent to a pixel.
605     int dpiX = 96, dpiY = 96;
606 
607     /// If relevant, the texture is to use this palette.
608     Color[] palette;
609 
610     bool opEquals(const Texture other) const {
611         return id == other.id
612             && width == other.width
613             && height == other.height
614             && dpiX == other.dpiX
615             && dpiY == other.dpiY;
616 
617     }
618 
619     version (Have_raylib_d)
620     void opAssign(raylib.Texture rayTexture) @system {
621         this = rayTexture.toFluid();
622     }
623 
624     /// Get the backend for this texture. Doesn't work after freeing the tombstone.
625     inout(FluidBackend) backend() inout @trusted {
626         return cast(inout FluidBackend) tombstone.backend;
627     }
628 
629     /// DPI value of the texture.
630     Vector2 dpi() const {
631         return Vector2(dpiX, dpiY);
632     }
633 
634     /// Get texture size as a vector.
635     Vector2 canvasSize() const {
636         return Vector2(width, height);
637     }
638 
639     /// Get the size the texture will occupy within the viewport.
640     Vector2 viewportSize() const {
641         return Vector2(
642             width * 96 / dpiX,
643             height * 96 / dpiY
644         );
645     }
646 
647     /// Update the texture to match the given image.
648     void update(Image image) @system {
649 
650         backend.updateTexture(this, image);
651 
652     }
653 
654     /// Draw this texture.
655     void draw(Vector2 position, Color tint = Color(0xff, 0xff, 0xff, 0xff)) {
656 
657         auto rectangle = Rectangle(position.tupleof, viewportSize.tupleof);
658 
659         backend.drawTexture(this, rectangle, tint);
660 
661     }
662 
663     void draw(Rectangle rectangle, Color tint = Color(0xff, 0xff, 0xff, 0xff)) {
664 
665         backend.drawTexture(this, rectangle, tint);
666 
667     }
668 
669     /// Destroy this texture. This function is thread-safe.
670     void destroy() @system {
671 
672         if (tombstone is null) return;
673 
674         tombstone.markDestroyed();
675         tombstone = null;
676         id = 0;
677 
678     }
679 
680 }
681 
682 /// Wrapper over `Texture` that automates destruction via GC or RAII.
683 struct TextureGC {
684 
685     /// Underlying texture. Lifetime is bound to this struct.
686     Texture texture;
687 
688     alias texture this;
689 
690     /// Load a texture from filename.
691     this(FluidBackend backend, string filename) @trusted {
692 
693         this.texture = backend.loadTexture(filename);
694 
695     }
696 
697     /// Load a texture from image data.
698     this(FluidBackend backend, Image data) @trusted {
699 
700         this.texture = backend.loadTexture(data);
701 
702     }
703 
704     /// Move constructor for TextureGC; increment the reference counter for the texture.
705     ///
706     /// While I originally did not intend to implement reference counting, it is necessary to make TextureGC work in
707     /// dynamic arrays. Changing the size of the array will copy the contents without performing a proper move of the
708     /// old items. The postblit is the only kind of move constructor that will be called in this case, and a copy
709     /// constructor does not do its job.
710     this(this) @trusted {
711 
712         if (tombstone)
713         tombstone.markCopied();
714 
715     }
716 
717     @system
718     unittest {
719 
720         import std.string;
721 
722         // This tests using TextureGC inside of a dynamic array, especially after resizing. See documentation for
723         // the postblit above.
724 
725         // Test two variants:
726         // * One, where we rely on the language to finalize the copied value
727         // * And one, where we manually destroy the value
728         foreach (explicitDestruction; [false, true]) {
729 
730             void makeCopy(TextureGC[] arr) {
731 
732                 // Create the copy
733                 auto copy = arr;
734 
735                 assert(sameHead(arr, copy));
736 
737                 // Expand the array, creating another
738                 copy.length = 1024;
739 
740                 assert(!sameHead(arr, copy));
741 
742                 // References to tombstones exist in both arrays now
743                 assert(!copy[0].tombstone.isDestroyed);
744                 assert(!arr[0].tombstone.isDestroyed);
745 
746                 // The copy should be marked as moved
747                 assert(copy[0].tombstone.references == 2);
748                 assert(arr[0].tombstone.references == 2);
749 
750                 // Destroy the tombstone
751                 if (explicitDestruction) {
752 
753                     auto tombstone = copy[0].tombstone;
754 
755                     copy[0].destroy();
756                     assert(tombstone.references == 1);
757                     assert(!tombstone.isDestroyed);
758 
759                 }
760 
761                 // Forget about the copy
762                 copy = null;
763 
764             }
765 
766             static void trashStack() {
767 
768                 import core.memory;
769 
770                 // Destroy the stack to get rid of any references to `copy`
771                 ubyte[2048] garbage;
772 
773                 // Collect it, make sure the tombstone gets eaten
774                 GC.collect();
775 
776             }
777 
778             auto io = new HeadlessBackend;
779             auto image = generateColorImage(10, 10, color("#fff"));
780             auto arr = [
781                 TextureGC(io, image),
782                 TextureGC.init,
783             ];
784 
785             makeCopy(arr);
786             trashStack();
787 
788             assert(!arr[0].tombstone.isDestroyed, "Tombstone of a live texture was destroyed after copying an array"
789                 ~ format!" (explicitDestruction %s)"(explicitDestruction));
790 
791             io.reaper.collect();
792 
793             assert(io.isTextureValid(arr[0]));
794             assert(!arr[0].tombstone.isDestroyed);
795             assert(!arr[0].tombstone.isDisowned);
796             assert(arr[0].tombstone.references == 1);
797 
798         }
799 
800     }
801 
802     @system
803     unittest {
804 
805         auto io = new HeadlessBackend;
806         auto image = generateColorImage(10, 10, color("#fff"));
807         auto arr = [
808             TextureGC(io, image),
809             TextureGC.init,
810         ];
811         auto copy = arr.dup;
812 
813         assert(arr[0].tombstone.references == 2);
814 
815         io.reaper.collect();
816 
817         assert(io.isTextureValid(arr[0]));
818 
819     }
820 
821     ~this() @trusted {
822 
823         texture.destroy();
824 
825     }
826 
827     /// Release the texture, moving it to manual management.
828     Texture release() @system {
829 
830         auto result = texture;
831         texture = texture.init;
832         return result;
833 
834     }
835 
836 }
837 
838 version (unittest) {
839 
840     debug (Fluid_BuildMessages) {
841         pragma(msg, "Fluid: Using headless as the default backend (unittest)");
842     }
843 
844     FluidBackend defaultFluidBackend() {
845 
846         return new HeadlessBackend;
847 
848     }
849 
850 }
851 
852 else version (Have_raylib_d) {
853 
854     debug (Fluid_BuildMessages) {
855         pragma(msg, "Fluid: Using Raylib 5 as the default backend");
856     }
857 
858     FluidBackend defaultFluidBackend() {
859 
860         static Raylib5Backend backend;
861 
862         if (backend)
863             return backend;
864         else
865             return new Raylib5Backend;
866 
867     }
868 
869 }
870 
871 else {
872 
873     debug (Fluid_BuildMessages) {
874         pragma(msg, "Fluid: No built-in backend in use");
875     }
876 
877     FluidBackend defaultFluidBackend() {
878 
879         return null;
880 
881     }
882 
883 }