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 }