1 /// Module for testing Fluid nodes using the new I/O system. 2 /// 3 /// Bugs: 4 /// `auto` functions may generate incorrect mangling. This is worked-around 5 /// with `pragma(mangle)`. 6 module fluid.test_space; 7 8 version (Fluid_TestSpace): 9 10 debug (Fluid_BuildMessages) { 11 pragma(msg, "Fluid: Including TestSpace"); 12 13 version (Fluid_SVG) { 14 pragma(msg, "Fluid: Including SVG support in TestSpace"); 15 } 16 } 17 18 import core.exception; 19 20 import optional; 21 22 import std.conv : toText = text; 23 import std.range; 24 import std.string; 25 import std.typecons; 26 import std.algorithm; 27 import std.exception; 28 import std.digest.sha; 29 30 import fluid.node; 31 import fluid.tree; 32 import fluid.utils; 33 import fluid.space; 34 import fluid.input; 35 import fluid.backend; 36 37 import fluid.io.canvas; 38 import fluid.io.debug_signal; 39 40 import fluid.future.pipe; 41 import fluid.future.arena; 42 43 @safe: 44 45 alias testSpace = nodeBuilder!TestSpace; 46 alias vtestSpace = nodeBuilder!TestSpace; 47 alias htestSpace = nodeBuilder!(TestSpace, (a) { 48 a.isHorizontal = true; 49 }); 50 51 /// Node property for `TestSpace` that enables cropping. This will prevent overflowing content from being drawn. 52 /// This can be used to test nodes that rely on cropping information, for example to limit drawn content to what 53 /// is visible on the screen. 54 /// 55 /// To control size of the viewport, use `fluid.size_lock.SizeLock` and `fluid.size_lock.sizeLimit`. 56 /// 57 /// Params: 58 /// enabled = Controls if cropping should be enabled or disabled. Defaults to `true`. 59 /// See_Also: 60 /// `CanvasIO.cropArea` 61 auto cropViewport(bool enabled = true) { 62 63 static struct CropViewport { 64 65 bool enabled; 66 67 void apply(TestSpace node) { 68 node.cropViewport = enabled; 69 } 70 71 } 72 73 return CropViewport(enabled); 74 75 } 76 77 /// This node allows automatically testing if other nodes draw their contents as expected. 78 class TestSpace : Space, CanvasIO, DebugSignalIO { 79 80 public { 81 82 /// If true, test space will set the default crop area to its own viewport size. 83 /// By default `TestSpace` exposes an infinite crop area, disabling any clipping behavior. 84 /// Enabling this again is useful if testing a node's cropping behavior. 85 /// 86 /// The viewport size is controlled by the node's own size. The canvas available to its children 87 /// will be the same size as `TestSpace`'s contents would normally be. `fluid.size_lock.SizeLock` can be used 88 /// to set a specific size. 89 bool cropViewport; 90 91 } 92 93 private { 94 95 /// Probe the space will use to analyze the tree. 96 TestProbe _probe; 97 98 /// Current crop area. 99 Optional!Rectangle _cropArea; 100 101 /// Rectangle given to TestSpace when drawing. 102 Rectangle _viewport; 103 104 /// Current DPI. 105 Vector2 _dpi = Vector2(96, 96); 106 107 /// All presently loaded images. 108 ResourceArena!Image _loadedImages; 109 110 /// Map of image pointers (image.data.ptr) to indices in the resource arena 111 int[size_t] _imageIndices; 112 113 /// Track number of debug signals received per signal name. 114 int[string] _debugSignals; 115 116 } 117 118 this(Node[] nodes...) { 119 120 super(nodes); 121 122 // Create a probe for testing. 123 this._probe = new TestProbe(); 124 125 } 126 127 /// Returns: True if the given image is loaded. 128 /// Params: 129 /// image = Image to check. 130 bool isImageLoaded(DrawableImage image) nothrow { 131 132 const ptr = cast(size_t) image.data.ptr; 133 134 // Image is registered and up to date, OK 135 if (auto index = ptr in _imageIndices) { 136 return *index == image.id 137 && _loadedImages.isActive(*index); 138 } 139 140 // Not loaded 141 return false; 142 143 } 144 145 /// Returns: The number of images registered by the test runner. 146 int countLoadedImages() nothrow const { 147 148 return cast(int) _loadedImages.activeResources.walkLength; 149 150 } 151 152 override void resizeImpl(Vector2 space) { 153 154 auto frame = this.implementIO(); 155 156 _viewport = Rectangle(0, 0, space.tupleof); 157 resetCropArea(); 158 159 // Free resources 160 _loadedImages.startCycle((newIndex, ref image) { 161 162 const id = cast(size_t) image.data.ptr; 163 164 if (newIndex == -1) { 165 _imageIndices.remove(id); 166 } 167 else { 168 _imageIndices[id] = newIndex; 169 } 170 171 }); 172 173 // Resize contents 174 super.resizeImpl(space); 175 176 } 177 178 override void drawImpl(Rectangle outer, Rectangle inner) { 179 180 _viewport = inner; 181 resetCropArea(); 182 super.drawImpl(outer, inner); 183 184 } 185 186 override Vector2 dpi() const nothrow { 187 return _dpi; 188 } 189 190 Vector2 dpi(Vector2 value) { 191 _dpi = value; 192 updateSize(); 193 return value; 194 } 195 196 void setScale(float value) { 197 dpi = Vector2(96, 96) * value; 198 } 199 200 /// Returns: 201 /// The number of times a debug signal has been emitted. 202 /// Params: 203 /// name = Name of the debug signal. 204 int emitCount(string name) const { 205 return _debugSignals.get(name, 0); 206 } 207 208 override Optional!Rectangle cropArea() const nothrow { 209 return _cropArea; 210 } 211 212 override void emitSignal(string name) nothrow { 213 assertNotThrown(_debugSignals.require(name, 0)++); 214 _probe.runAssert(a => a.emitSignal(_probe.subject, name)); 215 } 216 217 override void cropArea(Rectangle area) nothrow { 218 _probe.runAssert(a => a.cropArea(_probe.subject, area)); 219 _cropArea = area; 220 } 221 222 override void resetCropArea() nothrow { 223 _probe.runAssert(a => a.resetCropArea(_probe.subject)); 224 if (cropViewport) { 225 _cropArea = _viewport; 226 } 227 else { 228 _cropArea = none; 229 } 230 } 231 232 override void drawTriangleImpl(Vector2 x, Vector2 y, Vector2 z, Color color) nothrow { 233 _probe.runAssert(a => a.drawTriangle(_probe.subject, x, y, z, color)); 234 } 235 236 override void drawCircleImpl(Vector2 center, float radius, Color color) nothrow { 237 _probe.runAssert(a => a.drawCircle(_probe.subject, center, radius, color)); 238 } 239 240 override void drawCircleOutlineImpl(Vector2 center, float radius, float width, Color color) nothrow { 241 _probe.runAssert(a => a.drawCircleOutline(_probe.subject, center, radius, width, color)); 242 } 243 244 override void drawRectangleImpl(Rectangle rectangle, Color color) nothrow { 245 _probe.runAssert(a => a.drawRectangle(_probe.subject, rectangle, color)); 246 } 247 248 override void drawLineImpl(Vector2 start, Vector2 end, float width, Color color) nothrow { 249 _probe.runAssert(a => a.drawLine(_probe.subject, start, end, width, color)); 250 } 251 252 override void drawImageImpl(DrawableImage image, Rectangle destination, Color tint) nothrow { 253 254 assert( 255 isImageLoaded(image), 256 "Trying to draw an image without loading"); 257 258 _probe.runAssert(a => a.drawImage(_probe.subject, image, destination, tint)); 259 260 } 261 262 override void drawHintedImageImpl(DrawableImage image, Rectangle destination, Color tint) nothrow { 263 264 assert( 265 isImageLoaded(image), 266 "Trying to draw an image without loading"); 267 268 _probe.runAssert(a => a.drawHintedImage(_probe.subject, image, destination, tint)); 269 270 } 271 272 override int load(Image image) nothrow { 273 274 const ptr = cast(size_t) image.data.ptr; 275 276 // If the image is already loaded, mark it as so 277 if (auto index = ptr in _imageIndices) { 278 _loadedImages.reload(*index, image); 279 return *index; 280 } 281 282 // If not, add it 283 else { 284 return _imageIndices[ptr] = _loadedImages.load(image); 285 } 286 287 } 288 289 /// Draw a single frame and save the output to an SVG file at given location. 290 /// 291 /// Requires Fluid to be built with SVG support. To do so, set version `Fluid_SVG` and include dependencies 292 /// `elemi` and `arsd-official:image_files`. 293 version (Fluid_SVG) 294 void drawToSVG(string filename) { 295 296 auto generator = dumpDrawsToSVG(null, filename); 297 _probe.asserts = [generator]; 298 _probe.allowFailure = true; 299 scope (exit) _probe.allowFailure = false; 300 queueAction(_probe); 301 draw(); 302 generator.saveSVG(); 303 304 } 305 306 /// Draw a single frame and test if the asserts can be fulfilled. 307 void drawAndAssert(Assert[] asserts...) { 308 309 _probe.asserts = asserts.dup; 310 queueAction(_probe); 311 draw(); 312 313 } 314 315 /// Draw a single frame and make sure the asserts are NOT fulfilled. 316 void drawAndAssertFailure(Assert[] asserts...) @trusted { 317 318 assertThrown!AssertError( 319 drawAndAssert(asserts) 320 ); 321 322 } 323 324 } 325 326 private class TestProbe : TreeAction { 327 328 import fluid.future.stack; 329 330 public { 331 332 /// Subject that is currently tested. 333 Node subject; 334 335 /// Asserts that need to pass before the end of iteration. Asserts that pass are popped off this array. 336 Assert[] asserts; 337 338 /// Number of asserts that passed since start of iteration. 339 int assertsPassed; 340 341 /// Disables throwing an error if the probe exited with incomplete asserts. 342 /// 343 /// Every assert needs to finish for a successful test run. `TestProbe` will throw an `AssertError` if it 344 /// finishes a run without completing all of the assigned assertions, but this behavior can be disabled 345 /// by setting this option to `true`. 346 bool allowFailure; 347 348 } 349 350 private { 351 352 /// Node draw stack 353 Stack!Node stack; 354 355 } 356 357 /// Check an assertion in the `asserts` queue. 358 /// Params: 359 /// dg = Function to run the assert. Returns true if the assert succeeds. 360 protected void runAssert(bool delegate(Assert a) @safe nothrow dg) nothrow { 361 362 // No tests remain 363 if (asserts.empty) return; 364 365 // Test passed, continue to the next one 366 if (dg(asserts.front)) { 367 nextAssert(); 368 } 369 370 } 371 372 /// Move to the next test. 373 protected void nextAssert() nothrow { 374 375 // Move to the next assert in the list 376 do { 377 asserts.popFront; 378 assertsPassed++; 379 } 380 381 // Call `resume` on the next item. Continue while tests pass 382 while (!asserts.empty && asserts.front.resume(subject)); 383 384 } 385 386 override void started() { 387 388 // Reset pass count 389 assertsPassed = 0; 390 391 } 392 393 override void beforeResize(Node node, Vector2) { 394 stack ~= node; 395 this.subject = node; 396 } 397 398 override void afterResize(Node node, Vector2) { 399 stack.pop(); 400 401 // Restore previous subject from the stack 402 if (!stack.empty) { 403 this.subject = stack.top; 404 } 405 else { 406 this.subject = null; 407 } 408 } 409 410 override void beforeDraw(Node node, Rectangle space, Rectangle outer, Rectangle inner) { 411 stack ~= node; 412 this.subject = node; 413 runAssert(a => a.beforeDraw(node, space, outer, inner)); 414 } 415 416 override void afterDraw(Node node, Rectangle space, Rectangle outer, Rectangle inner) { 417 418 stack.pop(); 419 runAssert(a => a.afterDraw(node, space, outer, inner)); 420 421 // Restore previous subject from the stack 422 if (!stack.empty) { 423 this.subject = stack.top; 424 } 425 else { 426 this.subject = null; 427 } 428 429 } 430 431 override void stopped() { 432 433 if (allowFailure) return; 434 435 // Make sure the asserts pass 436 assert(this.asserts.empty, format!"Assert[%s] failure: %s"( 437 assertsPassed, this.asserts.front.toString)); 438 439 } 440 441 } 442 443 /// Class to test I/O calls performed by Fluid nodes. Any I/O method of `TestSpace` will call this. 444 /// 445 /// If a tester method returns `pass` or `passNext`, the assert passes, and the next one is loaded. 446 /// It it returns `false`, the frame continues until all nodes are exhausted (and fails), 447 /// or a matching test is found. 448 /// 449 /// `beforeDraw` or `resume` is expected to be called before any of the I/O calls. 450 interface Assert { 451 452 /// After another test passes and this test is chosen, `resume` will be called to let the test 453 /// know the current position in the tree. This is important in situations where `resume` is immediately 454 /// followed by `beforeDraw`; the node passed to `resume` will be the parent of the one passed to `beforeDraw`. 455 bool resume(Node node) nothrow; 456 457 // Tree 458 bool beforeDraw(Node node, Rectangle space, Rectangle paddingBox, Rectangle contentBox) nothrow; 459 bool afterDraw(Node node, Rectangle space, Rectangle paddingBox, Rectangle contentBox) nothrow; 460 461 // DebugSignalIO 462 bool emitSignal(Node node, string name) nothrow; 463 464 // CanvasIO 465 bool cropArea(Node node, Rectangle area) nothrow; 466 bool resetCropArea(Node node) nothrow; 467 bool drawTriangle(Node node, Vector2 a, Vector2 b, Vector2 c, Color color) nothrow; 468 bool drawCircle(Node node, Vector2 center, float radius, Color color) nothrow; 469 bool drawCircleOutline(Node node, Vector2 center, float radius, float width, Color color) nothrow; 470 bool drawRectangle(Node node, Rectangle rectangle, Color color) nothrow; 471 bool drawLine(Node node, Vector2 start, Vector2 end, float width, Color color) nothrow; 472 bool drawImage(Node node, DrawableImage image, Rectangle destination, Color tint) nothrow; 473 bool drawHintedImage(Node node, DrawableImage image, Rectangle destination, Color tint) nothrow; 474 475 // Meta 476 string toString() const; 477 478 } 479 480 /// 481 pragma(mangle, "fluid__test_space_cropsTo_R_tuple") 482 auto cropsTo(Node subject, typeof(Rectangle.tupleof) rectangle) { 483 return cropsTo(subject, Rectangle(rectangle)); 484 } 485 486 /// ditto 487 pragma(mangle, "fluid__test_space_cropsTo_R") 488 auto cropsTo(Node subject, Rectangle rectangle) { 489 auto result = crops(subject); 490 result.isTestingArea = true; 491 result.targetArea = rectangle; 492 return result; 493 } 494 495 /// ditto 496 pragma(mangle, "fluid__test_space_crops") 497 auto crops(Node subject) { 498 499 return new class BlackHole!Assert { 500 501 bool isTestingArea; 502 Rectangle targetArea; 503 504 override bool cropArea(Node node, Rectangle area) nothrow { 505 506 if (isTestingArea) { 507 if (!equal(area.x, targetArea.x) 508 || !equal(area.y, targetArea.y) 509 || !equal(area.w, targetArea.w) 510 || !equal(area.h, targetArea.h)) return false; 511 } 512 513 return subject.opEquals(node).assumeWontThrow; 514 515 } 516 517 override string toString() const { 518 return toText(subject, " should set crop area") 519 ~ (isTestingArea ? toText(" to ", targetArea) : ""); 520 } 521 522 }; 523 524 } 525 526 /// 527 pragma(mangle, "fluid__test_space_resetsCrop") 528 auto resetsCrop(Node subject) { 529 530 return new class BlackHole!Assert { 531 532 override bool resetCropArea(Node node) nothrow { 533 return subject.opEquals(node).assumeWontThrow; 534 } 535 536 override string toString() const { 537 return toText(subject, " should reset crop area"); 538 } 539 540 }; 541 542 } 543 544 /// 545 pragma(mangle, "fluid__test_space_drawsRectangle_R_tuple") 546 auto drawsRectangle(Node subject, typeof(Rectangle.tupleof) rectangle) { 547 return drawsRectangle(subject, Rectangle(rectangle)); 548 } 549 550 /// ditto 551 pragma(mangle, "fluid__test_space_drawsRectangle_R") 552 auto drawsRectangle(Node subject, Rectangle rectangle) { 553 auto result = drawsRectangle(subject); 554 result.isTestingArea = true; 555 result.targetArea = rectangle; 556 return result; 557 } 558 559 pragma(mangle, "fluid__test_space_drawsRectangle") 560 auto drawsRectangle(Node subject) { 561 562 return new class BlackHole!Assert { 563 564 bool isTestingArea; 565 Rectangle targetArea; 566 bool isTestingColor; 567 Color targetColor; 568 569 override bool drawRectangle(Node node, Rectangle rect, Color color) nothrow { 570 571 // node != subject MAY throw 572 if (!node.opEquals(subject).assertNotThrown) return false; 573 574 if (isTestingArea) { 575 if (!equal(targetArea.x, rect.x) 576 || !equal(targetArea.y, rect.y) 577 || !equal(targetArea.width, rect.width) 578 || !equal(targetArea.height, rect.height)) return false; 579 } 580 581 if (isTestingColor) { 582 if (color != targetColor) return false; 583 } 584 585 return true; 586 587 } 588 589 typeof(this) ofColor(string color) @safe { 590 return ofColor(.color(color)); 591 } 592 593 typeof(this) ofColor(Color color) @safe { 594 isTestingColor = true; 595 targetColor = color; 596 return this; 597 } 598 599 override string toString() const { 600 return toText( 601 subject, " should draw a rectangle", 602 isTestingArea ? toText(" ", targetArea) : "", 603 isTestingColor ? toText(" of color ", targetColor.toHex) : "", 604 ); 605 } 606 607 }; 608 609 } 610 611 /// Test if the subject draws a line. 612 pragma(mangle, "fluid__test_space_drawsLine") 613 auto drawsLine(Node subject) { 614 615 return new class BlackHole!Assert { 616 617 bool isTestingStart; 618 Vector2 targetStart; 619 bool isTestingEnd; 620 Vector2 targetEnd; 621 bool isTestingWidth; 622 float targetWidth; 623 bool isTestingColor; 624 Color targetColor; 625 626 override bool drawLine(Node node, Vector2 start, Vector2 end, float width, Color color) nothrow { 627 628 // node != subject MAY throw 629 if (!node.opEquals(subject).assertNotThrown) return false; 630 631 if (isTestingStart) { 632 assert(equal(targetStart.x, start.x) 633 && equal(targetStart.y, start.y), 634 format!"Expected start %s, got %s"(targetStart, start).assertNotThrown); 635 } 636 637 if (isTestingEnd) { 638 assert(equal(targetEnd.x, end.x) 639 && equal(targetEnd.y, end.y), 640 format!"Expected end %s, got %s"(targetEnd, end).assertNotThrown); 641 } 642 643 if (isTestingWidth) { 644 assert(equal(targetWidth, width), 645 format!"Expected width %s, got %s"(targetWidth, width).assertNotThrown); 646 } 647 648 if (isTestingColor) { 649 assert(targetColor == color, 650 format!"Expected color %s, got %s"(targetColor, color).assertNotThrown); 651 } 652 653 return true; 654 655 } 656 657 typeof(this) from(float x, float y) @safe { 658 return from(Vector2(x, y)); 659 } 660 661 typeof(this) from(Vector2 start) @safe { 662 isTestingStart = true; 663 targetStart = start; 664 return this; 665 } 666 667 typeof(this) to(float x, float y) @safe { 668 return to(Vector2(x, y)); 669 } 670 671 typeof(this) to(Vector2 end) @safe { 672 isTestingEnd = true; 673 targetEnd = end; 674 return this; 675 } 676 677 typeof(this) ofWidth(float width) @safe { 678 isTestingWidth = true; 679 targetWidth = width; 680 return this; 681 } 682 683 typeof(this) ofColor(string color) @safe { 684 return ofColor(.color(color)); 685 } 686 687 typeof(this) ofColor(Color color) @safe { 688 isTestingColor = true; 689 targetColor = color; 690 return this; 691 } 692 693 override string toString() const { 694 return toText( 695 subject, " should draw a line", 696 isTestingStart ? toText(" from ", targetStart) : "", 697 isTestingEnd ? toText(" to ", targetEnd) : "", 698 isTestingWidth ? toText(" of width ", targetWidth) : "", 699 isTestingColor ? toText(" of color ", targetColor.toHex) : "", 700 ); 701 } 702 703 }; 704 705 } 706 707 /// Test if the subject draws a circle outline. 708 pragma(mangle, "fluid__test_space_drawsCircleOutline") 709 auto drawsCircleOutline(Node subject) { 710 auto a = drawsCircle(subject); 711 a.isOutline = true; 712 return a; 713 } 714 715 /// ditto 716 pragma(mangle, "fluid__test_space_drawsCircleOutline_float") 717 auto drawsCircleOutline(Node subject, float width) { 718 auto a = drawsCircleOutline(subject); 719 a.isTestingOutlineWidth = true; 720 a.targetOutlineWidth = width; 721 return a; 722 } 723 724 /// Test if the subject draws a circle. 725 pragma(mangle, "fluid__test_space_drawsCircle") 726 auto drawsCircle(Node subject) { 727 728 return new class BlackHole!Assert { 729 730 bool isOutline; 731 bool isTestingCenter; 732 Vector2 targetCenter; 733 bool isTestingRadius; 734 float targetRadius; 735 bool isTestingColor; 736 Color targetColor; 737 bool isTestingOutlineWidth; 738 float targetOutlineWidth; 739 740 override bool drawCircle(Node node, Vector2 center, float radius, Color color) nothrow { 741 if (isOutline) { 742 return false; 743 } 744 else { 745 return drawTargetCircle(node, center, radius, color); 746 } 747 } 748 749 override bool drawCircleOutline(Node node, Vector2 center, float radius, float width, Color color) nothrow { 750 if (isOutline) { 751 if (isTestingOutlineWidth) { 752 assert(equal(width, targetOutlineWidth), 753 format!"Expected outline width %s, got %s"(targetOutlineWidth, width).assertNotThrown); 754 } 755 return drawTargetCircle(node, center, radius, color); 756 } 757 else { 758 return false; 759 } 760 } 761 762 bool drawTargetCircle(Node node, Vector2 center, float radius, Color color) nothrow @safe { 763 764 if (!node.opEquals(subject).assertNotThrown) return false; 765 766 if (isTestingCenter) { 767 if (!equal(targetCenter.x, center.x) 768 || !equal(targetCenter.y, center.y)) return false; 769 } 770 771 if (isTestingRadius) { 772 if (!equal(targetRadius, radius)) return false; 773 } 774 775 if (isTestingColor) { 776 if (targetColor != color) return false; 777 } 778 779 return true; 780 781 } 782 783 typeof(this) at(float x, float y) @safe { 784 return at(Vector2(x, y)); 785 } 786 787 typeof(this) at(Vector2 center) @safe { 788 isTestingCenter = true; 789 targetCenter = center; 790 return this; 791 } 792 793 typeof(this) ofRadius(float radius) @safe { 794 isTestingRadius = true; 795 targetRadius = radius; 796 return this; 797 } 798 799 typeof(this) ofColor(string color) @safe { 800 return ofColor(.color(color)); 801 } 802 803 typeof(this) ofColor(Color color) @safe { 804 isTestingColor = true; 805 targetColor = color; 806 return this; 807 } 808 809 override string toString() const { 810 return toText( 811 subject, " should draw a circle", 812 isOutline ? "outline" : "", 813 isTestingCenter ? toText(" at ", targetCenter) : "", 814 isTestingRadius ? toText(" of radius ", targetRadius) : "", 815 isTestingOutlineWidth ? toText(" of width ", targetOutlineWidth) : "", 816 isTestingColor ? toText(" of color ", targetColor.toHex) : "", 817 ); 818 } 819 820 }; 821 822 } 823 824 /// Params: 825 /// subject = Test if this subject draws an image. 826 /// Returns: 827 /// An `Assert` that can be passed to `TestSpace.drawAndAssert` to test if a node draws an image. 828 pragma(mangle, "fluid__test_space_drawsImage_I") 829 auto drawsImage(Node subject, Image image) { 830 auto test = drawsImage(subject); 831 test.isTestingImage = true; 832 test.targetImage = image; 833 test.isTestingColor = true; 834 test.targetColor = color("#fff"); 835 return test; 836 } 837 838 /// ditto 839 pragma(mangle, "fluid__test_space_drawsHintedImage_I") 840 auto drawsHintedImage(Node subject, Image image) { 841 auto test = drawsImage(subject, image); 842 test.isTestingHint = true; 843 test.targetHint = true; 844 return test; 845 } 846 847 /// ditto 848 pragma(mangle, "fluid__test_space_drawsHintedImage") 849 auto drawsHintedImage(Node subject) { 850 auto test = drawsImage(subject); 851 test.isTestingHint = true; 852 test.targetHint = true; 853 return test; 854 } 855 856 /// ditto 857 pragma(mangle, "fluid__test_space_drawsImage") 858 auto drawsImage(Node subject) { 859 860 return new class BlackHole!Assert { 861 862 bool isTestingImage; 863 Image targetImage; 864 bool isTestingDataHash; 865 ubyte[] targetDataHash; 866 bool isTestingStart; 867 Vector2 targetStart; 868 bool isTestingSize; 869 Vector2 targetSize; 870 bool isTestingColor; 871 Color targetColor; 872 bool isTestingHint; 873 bool targetHint; 874 bool isTestingPalette; 875 Color[] targetPalette; 876 877 override bool drawImage(Node node, DrawableImage image, Rectangle rect, Color color) nothrow { 878 879 if (!node.opEquals(subject).assertNotThrown) return false; 880 881 if (isTestingImage) { 882 const bothEmpty = image.data.empty && targetImage.data.empty; 883 assert(image.format == targetImage.format); 884 assert(bothEmpty || image.data is targetImage.data, 885 format!"%s should draw image 0x%02x but draws 0x%02x"( 886 node, cast(size_t) targetImage.data.ptr, cast(size_t) image.data.ptr).assertNotThrown); 887 888 if (isTestingPalette) { 889 assert(image.format == Image.Format.palettedAlpha); 890 assert(image.palette == targetPalette, 891 format!"%s should draw image with palette %s but uses %s"( 892 node, targetPalette.map!(a => a.toHex), image.palette.map!(a => a.toHex)) 893 .assertNotThrown); 894 } 895 } 896 897 if (isTestingDataHash) { 898 assert(targetDataHash == sha256Of(image.data), 899 format!"%s should draw image with SHA256 hash %(%02x%), but draws %(%02x%)"( 900 node, targetDataHash, sha256Of(image.data)) 901 .assumeWontThrow); 902 } 903 904 if (isTestingStart) { 905 assert(equal(targetStart.x, rect.x) 906 && equal(targetStart.y, rect.y), 907 format!"%s should draw image at %s, but draws at %s"(node, targetStart, rect.start) 908 .assertNotThrown); 909 } 910 911 if (isTestingSize) { 912 assert(equal(targetSize.x, rect.w) 913 && equal(targetSize.y, rect.h), 914 format!"%s should draw image of size %s, but draws %s"(node, targetSize, rect.size) 915 .assertNotThrown); 916 } 917 918 if (isTestingColor) { 919 assert(color == targetColor); 920 } 921 922 if (isTestingHint) { 923 assert(!targetHint); 924 } 925 926 return true; 927 928 } 929 930 override bool drawHintedImage(Node node, DrawableImage image, Rectangle rect, Color color) nothrow { 931 932 targetHint = false; 933 scope (exit) targetHint = true; 934 935 return drawImage(node, image, rect, color); 936 937 } 938 939 /// Test if the image content (using the format it is stored in) matches the hex-encoded 940 /// SHA256 hash. 941 typeof(this) sha256(string content) @safe { 942 943 import std.conv : to; 944 945 isTestingDataHash = true; 946 targetDataHash = content 947 .chunks(2) 948 .map!(a => a.to!ubyte(16)) 949 .array; 950 951 return this; 952 953 } 954 955 typeof(this) at(Vector2 position) @safe { 956 isTestingStart = true; 957 targetStart = position; 958 // TODO DPI 959 return this; 960 961 } 962 963 typeof(this) at(typeof(Vector2.tupleof) position) @safe { 964 return at(Vector2(position)); 965 } 966 967 typeof(this) at(Rectangle area) @safe { 968 at(area.start); 969 isTestingSize = true; 970 targetSize = area.size; 971 return this; 972 973 } 974 975 typeof(this) at(typeof(Rectangle.tupleof) area) @safe { 976 return at(Rectangle(area)); 977 } 978 979 typeof(this) withPalette(Color[] colors...) @safe { 980 isTestingPalette = true; 981 targetPalette = colors.dup; 982 return this; 983 } 984 985 typeof(this) ofColor(string color) @safe { 986 return ofColor(.color(color)); 987 } 988 989 typeof(this) ofColor(Color color) @safe { 990 isTestingColor = true; 991 targetColor = color; 992 return this; 993 } 994 995 override string toString() const { 996 return toText( 997 subject, " should draw an image ", 998 isTestingImage ? toText(targetImage) : "", 999 isTestingStart ? toText(" at ", targetStart) : "", 1000 isTestingSize ? toText(" of size ", targetSize) : "", 1001 isTestingColor ? toText(" of color ", targetColor.toHex) : "", 1002 ); 1003 } 1004 1005 }; 1006 1007 } 1008 1009 /// Assert true if the node draws a child. 1010 /// Bugs: 1011 /// If testing with a specific child, it will not detect the action if resumed inside of a sibling node. 1012 /// In other words, this will fail: 1013 /// 1014 /// --- 1015 /// // tree 1016 /// parent = vspace( 1017 /// sibling = label("Sibling"), 1018 /// child = label("Target"), 1019 /// ) 1020 /// // test 1021 /// drawAndAssert( 1022 /// sibling.isDrawn, 1023 /// parent.drawsChild(child), 1024 /// ), 1025 /// --- 1026 /// 1027 /// Params: 1028 /// parent = Parent node, subject of the test. 1029 /// child = Child to test. Must be drawn directly. 1030 pragma(mangle, "fluid__test_space_drawsChild") 1031 auto drawsChild(Node parent, Node child = null) { 1032 1033 return new class BlackHole!Assert { 1034 1035 // 0 outside of parent, 1 inside, 2 in child, 3 in grandchild, etc. 1036 int parentDepth; 1037 1038 override bool resume(Node node) { 1039 if (parent.opEquals(node).assertNotThrown) { 1040 parentDepth = 1; 1041 } 1042 return false; 1043 } 1044 1045 override bool beforeDraw(Node node, Rectangle, Rectangle, Rectangle) { 1046 1047 // Found the parent 1048 if (parent.opEquals(node).assertNotThrown) { 1049 parentDepth = 1; 1050 } 1051 1052 // Parent drew a child, great! End the test if the child meets expectations. 1053 else if (parentDepth) { 1054 if (parentDepth++ == 1) { 1055 return child is null || node.opEquals(child).assertNotThrown; 1056 } 1057 } 1058 1059 return false; 1060 1061 } 1062 1063 override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) { 1064 1065 if (parentDepth) { 1066 parentDepth--; 1067 } 1068 1069 return false; 1070 1071 } 1072 1073 override string toString() const { 1074 if (child) 1075 return format!"%s must draw %s"(parent, child); 1076 else 1077 return format!"%s must draw a child"(parent); 1078 } 1079 1080 }; 1081 1082 } 1083 1084 /// 1085 @("drawsChild assert works as expected") 1086 unittest { 1087 1088 import fluid.structs; 1089 1090 Space child, grandchild; 1091 1092 auto root = testSpace( 1093 layout!1, 1094 child = vspace( 1095 layout!2, 1096 grandchild = vspace( 1097 layout!3 1098 ), 1099 ), 1100 ); 1101 1102 root.drawAndAssert( 1103 root.drawsChild(), 1104 child.drawsChild(), 1105 ); 1106 1107 root.drawAndAssert( 1108 root.drawsChild(child), 1109 child.drawsChild(grandchild), 1110 ); 1111 1112 root.drawAndAssert( 1113 root.drawsChild(child), 1114 child.drawsChild(grandchild), 1115 grandchild.doesNotDrawChildren(), 1116 root.doesNotDrawChildren(), 1117 ); 1118 1119 root.drawAndAssertFailure( 1120 root.doesNotDrawChildren(), 1121 ); 1122 1123 root.drawAndAssertFailure( 1124 child.doesNotDrawChildren(), 1125 ); 1126 1127 root.drawAndAssert( 1128 grandchild.doesNotDrawChildren(), 1129 ); 1130 1131 root.drawAndAssertFailure( 1132 grandchild.drawsChild(), 1133 ); 1134 1135 root.drawAndAssertFailure( 1136 root.drawsChild(grandchild), 1137 ); 1138 1139 } 1140 1141 /// Make sure the parent does not draw any children. 1142 pragma(mangle, "fluid__test_space_doesNotDrawChildren") 1143 auto doesNotDrawChildren(Node parent) { 1144 1145 return new class BlackHole!Assert { 1146 1147 bool inParent; 1148 1149 override bool resume(Node node) { 1150 if (parent.opEquals(node).assertNotThrown) { 1151 inParent = true; 1152 } 1153 return false; 1154 } 1155 1156 override bool beforeDraw(Node node, Rectangle, Rectangle, Rectangle) { 1157 1158 // Found the parent 1159 if (parent.opEquals(node).assertNotThrown) { 1160 inParent = true; 1161 } 1162 1163 // Parent drew a child 1164 else if (inParent) { 1165 assert(false, format!"%s must not draw children"(parent).assertNotThrown); 1166 } 1167 1168 return false; 1169 1170 } 1171 1172 override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) { 1173 return parent.opEquals(node).assertNotThrown; 1174 } 1175 1176 override string toString() const { 1177 return format!"%s must not draw children"(parent).assertNotThrown; 1178 } 1179 1180 }; 1181 1182 } 1183 1184 /// Assert true if a node is attempted to be drawn, 1185 /// but the node does not need to draw anything for the assert to succeed. 1186 pragma(mangle, "fluid__test_space_isDrawn") 1187 auto isDrawn(Node subject) { 1188 1189 return new class BlackHole!Assert { 1190 1191 bool isTestingSpaceStart; 1192 Vector2 targetSpaceStart; 1193 bool isTestingSpaceSize; 1194 Vector2 targetSpaceSize; 1195 1196 override bool resume(Node node) { 1197 return node.opEquals(subject).assertNotThrown 1198 && !isTestingSpaceStart 1199 && !isTestingSpaceSize; 1200 } 1201 1202 override bool beforeDraw(Node node, Rectangle space, Rectangle, Rectangle) { 1203 1204 if (isTestingSpaceStart) { 1205 if (!equal(space.start.x, targetSpaceStart.x) 1206 || !equal(space.start.y, targetSpaceStart.y)) return false; 1207 } 1208 1209 if (isTestingSpaceSize) { 1210 if (!equal(space.size.x, targetSpaceSize.x) 1211 || !equal(space.size.y, targetSpaceSize.y)) return false; 1212 } 1213 1214 return node.opEquals(subject).assertNotThrown; 1215 } 1216 1217 auto at(Rectangle space) @safe { 1218 isTestingSpaceStart = true; 1219 targetSpaceStart = space.start; 1220 isTestingSpaceSize = true; 1221 targetSpaceSize = space.size; 1222 return this; 1223 } 1224 1225 auto at(float x, float y, float width, float height) @safe { 1226 return at(Rectangle(x, y, width, height)); 1227 } 1228 1229 auto at(Vector2 start) @safe { 1230 isTestingSpaceStart = true; 1231 targetSpaceStart = start; 1232 return this; 1233 } 1234 1235 auto at(float x, float y) @safe { 1236 return at(Vector2(x, y)); 1237 } 1238 1239 override string toString() const { 1240 return toText( 1241 subject, " must be drawn", 1242 isTestingSpaceStart ? toText(" at ", targetSpaceStart) : "", 1243 isTestingSpaceSize ? toText(" with size ", targetSpaceSize) : "", 1244 ); 1245 } 1246 1247 }; 1248 1249 1250 } 1251 1252 /// Make sure the selected node draws, but doesn't matter what. 1253 pragma(mangle, "fluid__test_space_draws") 1254 auto draws(Node subject) { 1255 1256 return drawsWildcard!((node, methodName) { 1257 1258 return node.opEquals(subject).assertNotThrown 1259 && methodName.startsWith("draw"); 1260 1261 })(format!"%s should draw"(subject)); 1262 1263 } 1264 1265 /// Make sure the selected node doesn't draw anything until another node does. 1266 auto doesNotDraw(alias predicate = `a.startsWith("draw")`)(Node subject) { 1267 1268 import std.functional : unaryFun; 1269 1270 bool matched; 1271 string failedName; 1272 1273 alias fun = unaryFun!predicate; 1274 1275 return drawsWildcard!((node, methodName) { 1276 1277 // Test failed, skip checks 1278 if (failedName) return false; 1279 1280 const isSubject = node.opEquals(subject).assertNotThrown; 1281 1282 // Make sure the node is reached 1283 if (!matched) { 1284 if (!isSubject) { 1285 return false; 1286 } 1287 matched = true; 1288 } 1289 1290 // Switching to another node 1291 if (methodName == "beforeDraw" && !isSubject) { 1292 return true; 1293 } 1294 1295 // Ending this node 1296 if (methodName == "afterDraw" && isSubject) { 1297 return true; 1298 } 1299 1300 if (isSubject && fun(methodName)) { 1301 failedName = methodName; 1302 return false; 1303 } 1304 1305 return false; 1306 1307 })(matched ? format!"%s shouldn't draw, but calls %s"(subject, failedName) 1308 : format!"%s should be reached"(subject)); 1309 1310 } 1311 1312 alias doesNotDrawImages = doesNotDraw!`a.among("drawImage", "drawHintedImage")`; 1313 1314 /// Ensure the node emits a debug signal. 1315 pragma(mangle, "fluid__test_space_emits") 1316 auto emits(Node subject, string name) { 1317 1318 return new class BlackHole!Assert { 1319 1320 override bool emitSignal(Node node, string emittedName) { 1321 1322 return subject.opEquals(node).assertNotThrown 1323 && name == emittedName; 1324 1325 } 1326 1327 override string toString() const { 1328 return format!"%s should emit %s"(subject, name); 1329 } 1330 1331 }; 1332 1333 } 1334 1335 auto drawsWildcard(alias dg)(lazy string message) { 1336 1337 return new class Assert { 1338 1339 override bool resume(Node node) nothrow { 1340 return dg(node, "resume"); 1341 } 1342 1343 override bool beforeDraw(Node node, Rectangle, Rectangle, Rectangle) nothrow { 1344 return dg(node, "beforeDraw"); 1345 } 1346 1347 override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) nothrow { 1348 return dg(node, "afterDraw"); 1349 } 1350 1351 override bool cropArea(Node node, Rectangle) nothrow { 1352 return dg(node, "cropArea"); 1353 } 1354 1355 override bool resetCropArea(Node node) nothrow { 1356 return dg(node, "resetCropArea"); 1357 } 1358 1359 override bool emitSignal(Node node, string) nothrow { 1360 return dg(node, "emitSignal"); 1361 } 1362 1363 override bool drawTriangle(Node node, Vector2, Vector2, Vector2, Color) nothrow { 1364 return dg(node, "drawTriangle"); 1365 } 1366 1367 override bool drawCircle(Node node, Vector2, float, Color) nothrow { 1368 return dg(node, "drawCircle"); 1369 } 1370 1371 override bool drawCircleOutline(Node node, Vector2, float, float, Color) nothrow { 1372 return dg(node, "drawCircleOutline"); 1373 } 1374 1375 override bool drawRectangle(Node node, Rectangle, Color) nothrow { 1376 return dg(node, "drawRectangle"); 1377 } 1378 1379 override bool drawLine(Node node, Vector2, Vector2, float, Color) nothrow { 1380 return dg(node, "drawLine"); 1381 } 1382 1383 override bool drawImage(Node node, DrawableImage, Rectangle, Color) nothrow { 1384 return dg(node, "drawImage"); 1385 } 1386 1387 override bool drawHintedImage(Node node, DrawableImage, Rectangle, Color) nothrow { 1388 return dg(node, "drawHintedImage"); 1389 } 1390 1391 override string toString() const { 1392 return message; 1393 } 1394 1395 }; 1396 1397 } 1398 1399 /// Output every draw instruction to stdout (`dumpDraws`), and, optionally, to an SVG file (`dumpDrawsToSVG`). 1400 /// 1401 /// Note that `dumpDraws` is equivalent to an `isDrawn` assert. It cannot be mixed with any other asserts on the same 1402 /// node. 1403 /// 1404 /// SVG support has to be enabled by passing `Fluid_SVG`. 1405 /// It requires extra dependencies: [elemi](https://code.dlang.org/packages/elemi) 1406 /// and [arsd-official:image_files](https://code.dlang.org/packages/arsd-official%3Aimage_files). 1407 /// To create an SVG image, call `dumpDrawsToSVG`. 1408 /// SVG support is currently incomplete and unstable. Changes can be made to this feature without prior announcement. 1409 /// 1410 /// Params: 1411 /// subject = Subject the output of which should be captured. 1412 /// filename = Path to save the SVG output to. Requires version `Fluid_SVG` to be set, ignored otherwise. 1413 /// Returns: 1414 /// An assert object to pass to `TestSpace.drawAndAssert`. 1415 pragma(mangle, "fluid__test_space_dumpDrawsToSVG") 1416 auto dumpDrawsToSVG(Node subject, string filename = null) { 1417 auto a = dumpDraws(subject); 1418 a.generateSVG = true; 1419 a.svgFilename = filename; 1420 return a; 1421 } 1422 1423 /// ditto 1424 pragma(mangle, "fluid__test_space_dumpDraws") 1425 auto dumpDraws(Node subject) { 1426 1427 import std.stdio; 1428 1429 return new class BlackHole!Assert { 1430 1431 bool generateSVG; 1432 string svgFilename; 1433 1434 version (Fluid_SVG) { 1435 import elemi.xml; 1436 Element svg; 1437 bool[Color] tints; 1438 } 1439 1440 version (Fluid_SVG) 1441 Element exportSVG() nothrow @safe { 1442 1443 return assumeWontThrow( 1444 elems( 1445 Element.XMLDeclaration1_0, 1446 elem!"svg"( 1447 attr("xmlns") = "http://www.w3.org/2000/svg", 1448 attr("version") = "1.1", 1449 svg, 1450 ), 1451 ), 1452 ); 1453 1454 } 1455 1456 void saveSVG() nothrow @safe { 1457 1458 import std.file : write; 1459 1460 version (Fluid_SVG) { 1461 if (generateSVG && svgFilename !is null) { 1462 assumeWontThrow( 1463 write(svgFilename, exportSVG) 1464 ); 1465 } 1466 } 1467 1468 } 1469 1470 bool isSubject(Node node) nothrow @trusted { 1471 return subject is null || node.opEquals(subject).assertNotThrown; 1472 } 1473 1474 void dump(string fmt, Arguments...)(Node node, Arguments arguments) nothrow @trusted { 1475 if (isSubject(node)) { 1476 writefln!fmt(arguments).assertNotThrown; 1477 } 1478 } 1479 1480 override bool beforeDraw(Node node, Rectangle space, Rectangle, Rectangle) nothrow { 1481 dump!"node.isDrawn().at(%s, %s, %s, %s),"(node, space.tupleof); 1482 return false; 1483 } 1484 1485 override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) nothrow { 1486 if (subject && isSubject(node)) { 1487 saveSVG(); 1488 return true; 1489 } 1490 return false; 1491 } 1492 1493 override bool cropArea(Node node, Rectangle rectangle) nothrow { 1494 dump!"node.cropsTo(%s, %s, %s, %s),"(node, rectangle.tupleof); 1495 return false; 1496 } 1497 1498 override bool resetCropArea(Node node) nothrow { 1499 dump!"node.resetsCrop(),"(node); 1500 return false; 1501 } 1502 1503 override bool emitSignal(Node node, string text) nothrow { 1504 dump!"node.emits(%(%s%)),"(node, text.only); 1505 return false; 1506 } 1507 1508 override bool drawTriangle(Node node, Vector2 a, Vector2 b, Vector2 c, Color color) nothrow { 1509 1510 if (isSubject(node)) { 1511 dump!"drawTriangle(%s, %s, %s, %s),"(node, a, b, c, color.toHex.assumeWontThrow); 1512 1513 version (Fluid_SVG) if (generateSVG) { 1514 assumeWontThrow( 1515 svg ~= elem!"polygon"( 1516 attr("points") = [ 1517 toText(a.x, a.y), 1518 toText(b.x, b.y), 1519 toText(c.x, c.y), 1520 ], 1521 attr("fill") = color.toHex, 1522 ), 1523 ); 1524 } 1525 } 1526 1527 return false; 1528 } 1529 1530 override bool drawCircle(Node node, Vector2 center, float radius, Color color) nothrow { 1531 1532 if (isSubject(node)) { 1533 dump!`node.drawsCircle().at(%s, %s).ofRadius(%s).ofColor("%s"),` 1534 (node, center.x, center.y, radius, color.toHex.assumeWontThrow); 1535 1536 version (Fluid_SVG) if (generateSVG) { 1537 assumeWontThrow( 1538 svg ~= elem!"circle"( 1539 attr("cx") = toText(center.x), 1540 attr("cy") = toText(center.y), 1541 attr("r") = toText(radius), 1542 attr("fill") = color.toHex, 1543 ), 1544 ); 1545 } 1546 } 1547 1548 return false; 1549 } 1550 1551 override bool drawCircleOutline(Node node, Vector2 center, float radius, float width, Color color) nothrow { 1552 1553 if (isSubject(node)) { 1554 dump!`node.drawsCircleOutline().at(%s).ofRadius(%s).ofColor("%s"),` 1555 (node, center, radius, color.toHex.assumeWontThrow); 1556 1557 version (Fluid_SVG) if (generateSVG) { 1558 assumeWontThrow( 1559 svg ~= elem!"circle"( 1560 attr("cx") = toText(center.x), 1561 attr("cy") = toText(center.y), 1562 attr("r") = toText(radius), 1563 attr("fill") = "none", 1564 attr("stroke") = color.toHex, 1565 attr("stroke-width") = toText(width), 1566 ), 1567 ); 1568 } 1569 } 1570 1571 return false; 1572 } 1573 1574 override bool drawRectangle(Node node, Rectangle area, Color color) nothrow { 1575 1576 if (isSubject(node)) { 1577 dump!`node.drawsRectangle(%s, %s, %s, %s).ofColor("%s"),` 1578 (node, area.tupleof, color.toHex.assumeWontThrow); 1579 1580 version (Fluid_SVG) if (generateSVG) { 1581 assumeWontThrow( 1582 svg ~= elem!"rect"( 1583 attr("x") = toText(area.x), 1584 attr("y") = toText(area.y), 1585 attr("width") = toText(area.width), 1586 attr("height") = toText(area.height), 1587 attr("fill") = color.toHex, 1588 ), 1589 ); 1590 } 1591 } 1592 1593 return false; 1594 } 1595 1596 override bool drawLine(Node node, Vector2 start, Vector2 end, float width, Color color) nothrow { 1597 1598 if (isSubject(node)) { 1599 dump!`node.drawsLine().from(%s, %s).to(%s, %s).ofWidth(%s).ofColor("%s"),` 1600 (node, start.tupleof, end.tupleof, width, color.toHex.assumeWontThrow); 1601 1602 version (Fluid_SVG) if (generateSVG) { 1603 assumeWontThrow( 1604 svg ~= elem!"line"( 1605 attr("x1") = toText(start.x), 1606 attr("y1") = toText(start.y), 1607 attr("x2") = toText(end.x), 1608 attr("y2") = toText(end.y), 1609 attr("stroke") = color.toHex, 1610 attr("stroke-width") = toText(width), 1611 ), 1612 ); 1613 } 1614 } 1615 1616 return false; 1617 } 1618 1619 override bool drawImage(Node node, DrawableImage image, Rectangle area, Color color) 1620 nothrow { 1621 dumpImage(node, image, area, color, false); 1622 return false; 1623 } 1624 1625 override bool drawHintedImage(Node node, DrawableImage image, Rectangle area, Color color) 1626 nothrow { 1627 dumpImage(node, image, area, color, true); 1628 return false; 1629 } 1630 1631 private void dumpImage(Node node, DrawableImage image, Rectangle area, Color tint, 1632 bool isHinted) 1633 nothrow @trusted { 1634 1635 if (!isSubject(node)) return; 1636 1637 dump!(`node.draws%sImage().at(%s, %s, %s, %s).ofColor("%s")` ~ "\n" 1638 ~ ` .sha256("%(%02x%)"),`) 1639 (node, isHinted ? "Hinted" : "", area.tupleof, tint.toHex.assumeWontThrow, 1640 sha256Of(image.data)[]); 1641 1642 if (image.area == 0) return; 1643 1644 version (Fluid_SVG) if (generateSVG) { 1645 1646 import std.base64; 1647 import arsd.png; 1648 import arsd.image; 1649 1650 ubyte[] data = cast(ubyte[]) image.toRGBA.data; 1651 1652 // Load the image 1653 auto arsdImage = new TrueColorImage(image.width, image.height, data); 1654 1655 // Encode as a PNG in a data URL 1656 const png = arsdImage.writePngToArray().assumeWontThrow; 1657 const string base64 = Base64.encode(png); 1658 const url = "data:image/png;base64," ~ base64; 1659 1660 assumeWontThrow( 1661 svg ~= elems( 1662 useTint(tint), 1663 elem!"image"( 1664 attr("x") = toText(area.x), 1665 attr("y") = toText(area.y), 1666 attr("width") = toText(area.width), 1667 attr("height") = toText(area.height), 1668 attr("href") = url, 1669 attr("style") = format!"filter:url(#%s)"(tint.toHex!"t"), 1670 ), 1671 ), 1672 ); 1673 1674 } 1675 1676 } 1677 1678 /// Generate a tint filter for the given color 1679 version (Fluid_SVG) 1680 private Element useTint(Color color) { 1681 1682 // Ignore if the given filter already exists 1683 if (color in tints) return elems(); 1684 1685 tints[color] = true; 1686 1687 // <pain> 1688 return elem!"filter"( 1689 1690 // Use the color as the filter ID, prefixed with "t" instead of "#" 1691 attr("id") = color.toHex!"t", 1692 1693 // Create a layer full of that color 1694 elem!"feFlood"( 1695 attr("x") = "0", 1696 attr("y") = "0", 1697 attr("width") = "100%", 1698 attr("height") = "100%", 1699 attr("flood-color") = color.toHex, 1700 ), 1701 1702 // Blend in with the original image 1703 elem!"feBlend"( 1704 attr("in2") = "SourceGraphic", 1705 attr("mode") = "multiply", 1706 ), 1707 1708 // Use the source image for opacity 1709 elem!"feComposite"( 1710 attr("in2") = "SourceGraphic", 1711 attr("operator") = "in", 1712 ), 1713 1714 ); 1715 // </pain> 1716 1717 } 1718 1719 override string toString() const { 1720 return format!"%s must be reached"(subject); 1721 } 1722 1723 }; 1724 1725 } 1726 1727 private bool equal(float a, float b) nothrow { 1728 1729 const diff = a - b; 1730 1731 return diff >= -0.01 1732 && diff <= +0.01; 1733 1734 }