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