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