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