1 /// 2 module fluid.node; 3 4 import std.math; 5 import std.traits; 6 import std.string; 7 import std.algorithm; 8 9 import fluid.backend; 10 import fluid.tree; 11 import fluid.style; 12 import fluid.utils; 13 import fluid.input; 14 import fluid.actions; 15 import fluid.structs; 16 17 18 @safe: 19 20 21 /// Represents a Fluid node. 22 abstract class Node { 23 24 public import fluid.structs : NodeAlign, Layout; 25 public import fluid.structs : Align = NodeAlign; 26 27 static class Extra { 28 29 private struct CacheKey { 30 31 size_t dataPtr; 32 FluidBackend backend; 33 34 } 35 36 /// Styling texture cache, by image pointer. 37 private TextureGC[CacheKey] cache; 38 39 /// Load a texture from the image. May return null if there's no valid image. 40 TextureGC* getTexture(FluidBackend backend, Image image) @trusted { 41 42 // No image 43 if (image.area == 0) return null; 44 45 const key = CacheKey(cast(size_t) image.data.ptr, backend); 46 47 // Find or create the entry 48 return &cache.require(key, TextureGC(backend, image)); 49 50 } 51 52 } 53 54 public { 55 56 /// Tree data for the node. Note: requires at least one draw before this will work. 57 LayoutTree* tree; 58 59 /// Layout for this node. 60 Layout layout; 61 62 /// Tags assigned for this node. 63 TagList tags; 64 65 /// If true, this node will be removed from the tree on the next draw. 66 bool toRemove; 67 68 /// If true, mouse focus will be disabled for this node, so mouse signals will "go through" to its parents, as 69 /// if the node wasn't there. The node will still detect hover like normal. 70 bool ignoreMouse; 71 72 } 73 74 /// Minimum size of the node. 75 protected auto minSize = Vector2(0, 0); 76 77 private { 78 79 /// If true, this node must update its size. 80 bool _resizePending = true; 81 82 /// If true, this node is hidden and won't be rendered. 83 bool _isHidden; 84 85 /// If true, this node is currently hovered. 86 bool _isHovered; 87 88 /// If true, this node is currently disabled. 89 bool _isDisabled; 90 91 /// Check if this node is disabled, or has inherited the status. 92 bool _isDisabledInherited; 93 94 /// Theme of this node. 95 Theme _theme; 96 97 /// Cached style for this node. 98 Style _style; 99 100 /// Attached styling delegates. 101 Rule.StyleDelegate[] _styleDelegates; 102 103 /// Actions queued for this node; only used for queueing actions before the first `resize`; afterwards, all 104 /// actions are queued directly into the tree. 105 TreeAction[] _queuedActions; 106 107 } 108 109 @property { 110 111 /// Get the current theme. 112 pragma(inline) 113 inout(Theme) theme() inout { return _theme; } 114 115 /// Set the theme. 116 Theme theme(Theme value) @trusted { 117 118 _theme = cast(Theme) value; 119 updateSize(); 120 return _theme; 121 122 } 123 124 /// Current style, used for sizing. Does not include any changes made by `when` clauses or callbacks. 125 /// 126 /// Direct changes are discouraged, and are likely to be discarded when reloading themes. Use themes instead. 127 ref inout(Style) style() inout { return _style; } 128 129 } 130 131 @property { 132 133 /// Check if the node is hidden. 134 bool isHidden() const return { return _isHidden; } 135 136 /// Set the visibility 137 bool isHidden(bool value) return { 138 139 // If changed, trigger resize 140 if (_isHidden != value) updateSize(); 141 142 return _isHidden = value; 143 144 } 145 146 } 147 148 /// Construct a new node. 149 /// 150 /// The typical approach to constructing new nodes is via `fluid.utils.simpleConstructor`. A node component would 151 /// provide an alias pointing to the `simpleConstructor` instance, which can then be used as a factory function. For 152 /// example, `Label` provides the `label` simpleConstructor. Using these has increased convenience by making it 153 /// possible to specify special properties while constructing the node, for example 154 /// 155 /// --- 156 /// auto myLabel = label(.layout!1, .theme, "Hello, World!"); 157 /// // Equivalent of: 158 /// auto myLabel = new Label("Hello, World!"); 159 /// myLabel.layout = .layout!1; 160 /// myLabel.theme = .theme; 161 /// --- 162 /// 163 /// See_Also: 164 /// `fluid.utils.simpleConstructor` 165 this() { } 166 167 bool opEquals(const Node otherNode) const { 168 169 return this is otherNode; 170 171 } 172 173 /// Show the node. 174 This show(this This = Node)() return { 175 176 // Note: The default value for This is necessary, otherwise virtual calls don't work 177 isHidden = false; 178 return cast(This) this; 179 180 } 181 182 /// Hide the node. 183 This hide(this This = Node)() return { 184 185 isHidden = true; 186 return cast(This) this; 187 188 } 189 190 unittest { 191 192 auto io = new HeadlessBackend; 193 auto root = new class Node { 194 195 override void resizeImpl(Vector2) { 196 minSize = Vector2(10, 10); 197 } 198 199 override void drawImpl(Rectangle outer, Rectangle inner) { 200 io.drawRectangle(inner, color!"123"); 201 } 202 203 }; 204 205 root.io = io; 206 root.theme = nullTheme; 207 root.draw(); 208 209 io.assertRectangle(Rectangle(0, 0, 10, 10), color!"123"); 210 io.nextFrame; 211 212 // Hide the node now 213 root.hide(); 214 root.draw(); 215 216 assert(io.rectangles.empty); 217 218 } 219 220 /// Disable this node. 221 This disable(this This = Node)() { 222 223 // `scope return` attribute on disable() and enable() is broken, `isDisabled` just can't get return for reasons 224 // unknown 225 226 isDisabled = true; 227 return cast(This) this; 228 229 } 230 231 /// Enable this node. 232 This enable(this This = Node)() { 233 234 isDisabled = false; 235 return cast(This) this; 236 237 } 238 239 unittest { 240 241 import fluid.space; 242 import fluid.button; 243 import fluid.text_input; 244 245 int submitted; 246 247 auto io = new HeadlessBackend; 248 auto button = fluid.button.button("Hello!", delegate { submitted++; }); 249 auto input = fluid.textInput("Placeholder", delegate { submitted++; }); 250 auto root = vspace(button, input); 251 252 root.io = io; 253 root.draw(); 254 255 // Press the button 256 { 257 io.nextFrame; 258 io.press(KeyboardKey.enter); 259 button.focus(); 260 root.draw(); 261 262 assert(submitted == 1); 263 } 264 265 // Press the button while disabled 266 { 267 io.nextFrame; 268 io.press(KeyboardKey.enter); 269 button.disable(); 270 root.draw(); 271 272 assert(button.isDisabled); 273 assert(submitted == 1, "Button shouldn't trigger again"); 274 } 275 276 // Enable the button and hit it again 277 { 278 io.nextFrame; 279 io.press(KeyboardKey.enter); 280 button.enable(); 281 root.draw(); 282 283 assert(!button.isDisabledInherited); 284 assert(submitted == 2); 285 } 286 287 // Try typing into the input box 288 { 289 io.nextFrame; 290 io.release(KeyboardKey.enter); 291 io.inputCharacter("Hello, "); 292 input.focus(); 293 root.draw(); 294 295 assert(input.value == "Hello, "); 296 } 297 298 // Disable the box and try typing again 299 { 300 io.nextFrame; 301 io.inputCharacter("World!"); 302 input.disable(); 303 root.draw(); 304 305 assert(input.value == "Hello, ", "Input should remain unchanged"); 306 } 307 308 // Attempt disabling the nodes recursively 309 { 310 io.nextFrame; 311 io.press(KeyboardKey.enter); 312 button.focus(); 313 input.enable(); 314 root.disable(); 315 root.draw(); 316 317 assert(root.isDisabled); 318 assert(!button.isDisabled); 319 assert(!input.isDisabled); 320 assert(button.isDisabledInherited); 321 assert(input.isDisabledInherited); 322 assert(submitted == 2); 323 } 324 325 // Check the input box 326 { 327 io.nextFrame; 328 io.press(KeyboardKey.enter); 329 io.inputCharacter("World!"); 330 input.focus(); 331 332 root.draw(); 333 334 assert(submitted == 2); 335 assert(input.value == "Hello, "); 336 } 337 338 // Enable input once again 339 { 340 io.nextFrame; 341 io.press(KeyboardKey.enter); 342 root.enable(); 343 root.draw(); 344 345 assert(submitted == 3); 346 assert(input.value == "Hello, "); 347 } 348 349 } 350 351 inout(FluidBackend) backend() inout { 352 353 return tree.backend; 354 355 } 356 357 FluidBackend backend(FluidBackend backend) { 358 359 // Create the tree if not present 360 if (tree is null) { 361 362 tree = new LayoutTree(this, backend); 363 return backend; 364 365 } 366 367 else return tree.backend = backend; 368 369 } 370 371 alias io = backend; 372 373 /// Toggle the node's visibility. 374 final void toggleShow() { 375 376 isHidden = !isHidden; 377 378 } 379 380 /// Remove this node from the tree before the next draw. 381 final void remove() { 382 383 isHidden = true; 384 toRemove = true; 385 386 } 387 388 /// Check if this node is hovered. 389 /// 390 /// Returns false if the node or, while the node is being drawn, some of its ancestors are disabled. 391 @property 392 bool isHovered() const { return _isHovered && !_isDisabled && !tree.isBranchDisabled; } 393 394 /// Check if this node is disabled. 395 ref inout(bool) isDisabled() inout { return _isDisabled; } 396 397 /// Checks if the node is disabled, either by self, or by any of its ancestors. Updated when drawn. 398 bool isDisabledInherited() const { return _isDisabledInherited; } 399 400 /// Queue an action to perform within this node's branch. 401 /// 402 /// This is recommended to use over `LayoutTree.queueAction`, as it can be used to limit the action to a specific 403 /// branch, and can also work before the first draw. 404 /// 405 /// This function is not safe to use while the tree is being drawn. 406 final void queueAction(TreeAction action) 407 in (action, "Invalid action queued (null)") 408 do { 409 410 // Set this node as the start for the given action 411 action.startNode = this; 412 413 // Insert the action into the tree's queue 414 if (tree) tree.queueAction(action); 415 416 // If there isn't a tree, wait for a resize 417 else _queuedActions ~= action; 418 419 } 420 421 unittest { 422 423 import fluid.space; 424 425 Node[4] allNodes; 426 Node[] visitedNodes; 427 428 auto io = new HeadlessBackend; 429 auto root = allNodes[0] = vspace( 430 allNodes[1] = hspace( 431 allNodes[2] = hspace(), 432 ), 433 allNodes[3] = hspace(), 434 ); 435 auto action = new class TreeAction { 436 437 override void beforeDraw(Node node, Rectangle) { 438 439 visitedNodes ~= node; 440 441 } 442 443 }; 444 445 // Queue the action before creating the tree 446 root.queueAction(action); 447 448 // Assign the backend; note this would create a tree 449 root.io = io; 450 451 root.draw(); 452 453 assert(visitedNodes == allNodes); 454 455 // Clear visited nodes 456 io.nextFrame; 457 visitedNodes = []; 458 action.toStop = false; 459 460 // Queue an action in a branch 461 allNodes[1].queueAction(action); 462 463 root.draw(); 464 465 assert(visitedNodes == allNodes[1..3]); 466 467 } 468 469 /// True if this node is pending a resize. 470 bool resizePending() const { 471 472 return _resizePending; 473 474 } 475 476 /// Recalculate the window size before next draw. 477 final void updateSize() scope { 478 479 if (tree) tree.root._resizePending = true; 480 // Tree might be null — if so, the node will be resized regardless 481 482 } 483 484 unittest { 485 486 int resizes; 487 488 auto io = new HeadlessBackend; 489 auto root = new class Node { 490 491 override void resizeImpl(Vector2) { 492 493 resizes++; 494 495 } 496 override void drawImpl(Rectangle, Rectangle) { } 497 498 }; 499 500 root.io = io; 501 assert(resizes == 0); 502 503 // Resizes are only done on request 504 foreach (i; 0..10) { 505 506 root.draw(); 507 assert(resizes == 1); 508 io.nextFrame; 509 510 } 511 512 // Perform such a request 513 root.updateSize(); 514 assert(resizes == 1); 515 516 // Resize will be done right before next draw 517 root.draw(); 518 assert(resizes == 2); 519 io.nextFrame; 520 521 // This prevents unnecessary resizes if multiple things change in a single branch 522 root.updateSize(); 523 root.updateSize(); 524 525 root.draw(); 526 assert(resizes == 3); 527 io.nextFrame; 528 529 // Another draw, no more resizes 530 root.draw(); 531 assert(resizes == 3); 532 533 } 534 535 /// Draw this node as a root node. 536 final void draw() @trusted { 537 538 // No tree set, create one 539 if (tree is null) { 540 541 tree = new LayoutTree(this); 542 543 } 544 545 // No theme set, set the default 546 if (!theme) { 547 548 import fluid.default_theme; 549 theme = fluidDefaultTheme; 550 551 } 552 553 assert(theme); 554 555 const space = tree.io.windowSize; 556 557 // Clear mouse hover if LMB is up 558 if (!isLMBHeld) tree.hover = null; 559 560 // Clear scroll 561 tree.scroll = null; 562 563 // Clear focus info 564 tree.focusDirection = FocusDirection(tree.focusBox); 565 tree.focusBox = Rectangle(float.nan); 566 567 // Update input 568 tree.poll(); 569 570 // Request a resize if the window was resized 571 if (tree.io.hasJustResized) updateSize(); 572 573 // Resize if required 574 if (resizePending) { 575 576 resize(tree, theme, space); 577 _resizePending = false; 578 579 } 580 581 /// Area to render on 582 const viewport = Rectangle(0, 0, space.x, space.y); 583 584 585 // Run beforeTree actions 586 foreach (action; tree.filterActions) { 587 588 action.beforeTree(this, viewport); 589 590 } 591 592 // Draw this node 593 draw(viewport); 594 595 // Run afterTree actions 596 foreach (action; tree.filterActions) { 597 598 action.afterTree(); 599 600 } 601 602 603 // Set mouse cursor to match hovered node 604 if (tree.hover) { 605 606 tree.io.mouseCursor = tree.hover.pickStyle().mouseCursor; 607 608 } 609 610 611 // Note: pressed, not released; released activates input events, pressed activates focus 612 const mousePressed = tree.io.isPressed(MouseButton.left) 613 || tree.io.isPressed(MouseButton.right) 614 || tree.io.isPressed(MouseButton.middle); 615 616 // Update scroll input 617 if (tree.scroll) tree.scroll.scrollImpl(io.scroll); 618 619 // Mouse is hovering an input node 620 // Note that nodes will remain in tree.hover if LMB is pressed to prevent "hover slipping" — actions should 621 // only trigger if the button was both pressed and released on the node. 622 if (auto hoverInput = cast(FluidHoverable) tree.hover) { 623 624 // Pass input to the node, unless it's disabled 625 if (!tree.hover.isDisabledInherited) { 626 627 // Check if the node is focusable 628 auto focusable = cast(FluidFocusable) tree.hover; 629 630 // If the left mouse button is pressed down, give the node focus 631 if (mousePressed && focusable) focusable.focus(); 632 633 // Pass the input to it 634 hoverInput.runMouseInputActions || hoverInput.mouseImpl; 635 636 } 637 638 } 639 640 // Mouse pressed over a non-focusable node, remove focus 641 else if (mousePressed) tree.focus = null; 642 643 644 // Pass keyboard input to the currently focused node 645 if (tree.focus && !tree.focus.asNode.isDisabledInherited) { 646 647 // TODO BUG: also fires for removed nodes 648 649 // Let it handle input 650 tree.keyboardHandled = either( 651 tree.focus.runFocusInputActions, 652 tree.focus.focusImpl, 653 ); 654 655 } 656 657 // Nothing has focus 658 else with (FluidInputAction) 659 tree.keyboardHandled = { 660 661 // Check the first focusable node 662 if (auto first = tree.focusDirection.first) { 663 664 // Check for focus action 665 const focusFirst = tree.isFocusActive!(FluidInputAction.focusNext) 666 || tree.isFocusActive!(FluidInputAction.focusDown) 667 || tree.isFocusActive!(FluidInputAction.focusRight) 668 || tree.isFocusActive!(FluidInputAction.focusLeft); 669 670 // Switch focus 671 if (focusFirst) { 672 673 first.focus(); 674 return true; 675 676 } 677 678 } 679 680 // Or maybe, get the last focusable node 681 if (auto last = tree.focusDirection.last) { 682 683 // Check for focus action 684 const focusLast = tree.isFocusActive!(FluidInputAction.focusPrevious) 685 || tree.isFocusActive!(FluidInputAction.focusUp); 686 687 // Switch focus 688 if (focusLast) { 689 690 last.focus(); 691 return true; 692 693 } 694 695 } 696 697 return false; 698 699 }(); 700 701 foreach (action; tree.filterActions) { 702 703 action.afterInput(tree.keyboardHandled); 704 705 } 706 707 } 708 709 unittest { 710 711 import fluid.space; 712 import fluid.button; 713 714 auto io = new HeadlessBackend; 715 auto root = vspace( 716 button("1", delegate { }), 717 button("2", delegate { }), 718 button("3", delegate { }), 719 ); 720 721 root.io = io; 722 723 root.draw(); 724 725 assert(root.tree.focus is null); 726 727 // Autofocus first 728 { 729 730 io.nextFrame; 731 io.press(KeyboardKey.tab); 732 root.draw(); 733 734 // Fluid will automatically try to find the first focusable node 735 assert(root.tree.focus.asNode is root.children[0]); 736 737 io.nextFrame; 738 io.release(KeyboardKey.tab); 739 root.draw(); 740 741 assert(root.tree.focus.asNode is root.children[0]); 742 743 } 744 745 // Tab into the next node 746 { 747 748 io.nextFrame; 749 io.press(KeyboardKey.tab); 750 root.draw(); 751 io.release(KeyboardKey.tab); 752 753 assert(root.tree.focus.asNode is root.children[1]); 754 755 } 756 757 // Autofocus last 758 { 759 root.tree.focus = null; 760 761 io.nextFrame; 762 io.press(KeyboardKey.leftShift); 763 io.press(KeyboardKey.tab); 764 root.draw(); 765 766 // If left-shift tab is pressed, the last focusable node will be used 767 assert(root.tree.focus.asNode is root.children[$-1]); 768 769 io.nextFrame; 770 io.release(KeyboardKey.leftShift); 771 io.release(KeyboardKey.tab); 772 root.draw(); 773 774 assert(root.tree.focus.asNode is root.children[$-1]); 775 776 } 777 778 } 779 780 /// Switch to the previous or next focused item 781 @(FluidInputAction.focusPrevious,FluidInputAction.focusNext) 782 protected void _focusPrevNext(FluidInputAction actionType) { 783 784 auto direction = tree.focusDirection; 785 786 // Get the node to switch to 787 auto node = actionType == FluidInputAction.focusPrevious 788 789 // Requesting previous item 790 ? either(direction.previous, direction.last) 791 792 // Requesting next 793 : either(direction.next, direction.first); 794 795 // Switch focus 796 if (node) node.focus(); 797 798 } 799 800 /// Switch focus towards a specified direction. 801 @(FluidInputAction.focusLeft, FluidInputAction.focusRight) 802 @(FluidInputAction.focusUp, FluidInputAction.focusDown) 803 protected void _focusDirection(FluidInputAction action) { 804 805 with (FluidInputAction) { 806 807 // Check which side we're going 808 const side = action.predSwitch( 809 focusLeft, Style.Side.left, 810 focusRight, Style.Side.right, 811 focusUp, Style.Side.top, 812 focusDown, Style.Side.bottom, 813 ); 814 815 // Get the node 816 auto node = tree.focusDirection.positional[side]; 817 818 // Switch focus to the node 819 if (node !is null) node.focus(); 820 821 } 822 823 } 824 825 /// Draw this node at the specified location from within of another (parent) node. 826 /// 827 /// The drawn node will be aligned according to the `layout` field within the box given. 828 /// 829 /// Params: 830 /// space = Space the node should be drawn in. It should be limited to space within the parent node. 831 /// If the node can't fit, it will be cropped. 832 final protected void draw(Rectangle space) @trusted { 833 834 import std.range; 835 836 assert(!toRemove, "A toRemove child wasn't removed from container."); 837 assert(tree !is null, toString ~ " wasn't resized prior to drawing. You might be missing an `updateSize`" 838 ~ " call!"); 839 840 // If hidden, don't draw anything 841 if (isHidden) return; 842 843 const spaceV = Vector2(space.width, space.height); 844 845 // Get parameters 846 const size = Vector2( 847 layout.nodeAlign[0] == NodeAlign.fill ? space.width : min(space.width, minSize.x), 848 layout.nodeAlign[1] == NodeAlign.fill ? space.height : min(space.height, minSize.y), 849 ); 850 const position = position(space, size); 851 852 // Calculate the boxes 853 const marginBox = Rectangle(position.tupleof, size.tupleof); 854 const borderBox = style.cropBox(marginBox, style.margin); 855 const paddingBox = style.cropBox(borderBox, style.border); 856 const contentBox = style.cropBox(paddingBox, style.padding); 857 const mainBox = borderBox; 858 859 const currentStyle = pickStyle(); 860 861 // Get the visible part of the padding box — so overflowed content doesn't get mouse focus 862 const visibleBox = tree.intersectScissors(paddingBox); 863 864 // Check if hovered 865 _isHovered = hoveredImpl(visibleBox, tree.io.mousePosition); 866 867 // Set tint 868 auto previousTint = io.tint; 869 io.tint = multiply(previousTint, currentStyle.tint); 870 scope (exit) io.tint = previousTint; 871 872 // If there's a border active, draw it 873 if (currentStyle.borderStyle) { 874 875 currentStyle.borderStyle.apply(io, borderBox, style.border); 876 // TODO wouldn't it be better to draw borders as background? 877 878 } 879 880 // Check if the mouse stroke started this node 881 const heldElsewhere = !tree.io.isPressed(MouseButton.left) 882 && isLMBHeld; 883 884 // Check for hover, unless ignored by this node 885 if (isHovered && !ignoreMouse) { 886 887 // Set global hover as long as the mouse isn't held down 888 if (!heldElsewhere) tree.hover = this; 889 890 // Update scroll 891 if (auto scrollable = cast(FluidScrollable) this) { 892 893 // Only if scrolling is possible 894 if (scrollable.canScroll(io.scroll)) { 895 896 tree.scroll = scrollable; 897 898 } 899 900 } 901 902 } 903 904 assert( 905 only(size.tupleof).all!isFinite, 906 format!"Node %s resulting size is invalid: %s; given space = %s, minSize = %s"( 907 typeid(this), size, space, minSize 908 ), 909 ); 910 assert( 911 only(mainBox.tupleof, contentBox.tupleof).all!isFinite, 912 format!"Node %s size is invalid: borderBox = %s, contentBox = %s"( 913 typeid(this), mainBox, contentBox 914 ) 915 ); 916 917 /// Descending into a disabled tree 918 const branchDisabled = isDisabled || tree.isBranchDisabled; 919 920 /// True if this node is disabled, and none of its ancestors are disabled 921 const disabledRoot = isDisabled && !tree.isBranchDisabled; 922 923 // Toggle disabled branch if we're owning the root 924 if (disabledRoot) tree.isBranchDisabled = true; 925 scope (exit) if (disabledRoot) tree.isBranchDisabled = false; 926 927 // Save disabled status 928 _isDisabledInherited = branchDisabled; 929 930 // Count depth 931 tree.depth++; 932 scope (exit) tree.depth--; 933 934 // Run beforeDraw actions 935 foreach (action; tree.filterActions) { 936 937 action.beforeDrawImpl(this, space, mainBox, contentBox); 938 939 } 940 941 // Draw the node cropped 942 // Note: minSize includes margin! 943 if (minSize.x > space.width || minSize.y > space.height) { 944 945 const lastScissors = tree.pushScissors(mainBox); 946 scope (exit) tree.popScissors(lastScissors); 947 948 drawImpl(mainBox, contentBox); 949 950 } 951 952 // Draw the node 953 else drawImpl(mainBox, contentBox); 954 955 956 // If not disabled 957 if (!branchDisabled) { 958 959 // Update focus info 960 tree.focusDirection.update(this, mainBox, tree.depth); 961 962 // If this node is focused 963 if (this is cast(Node) tree.focus) { 964 965 // Set the focus box 966 tree.focusBox = mainBox; 967 968 } 969 970 } 971 972 // Run afterDraw actions 973 foreach (action; tree.filterActions) { 974 975 action.afterDrawImpl(this, space, mainBox, contentBox); 976 977 } 978 979 } 980 981 /// Recalculate the minimum node size and update the `minSize` property. 982 /// Params: 983 /// tree = The parent's tree to pass down to this node. 984 /// theme = Theme to inherit from the parent. 985 /// space = Available space. 986 protected final void resize(LayoutTree* tree, Theme theme, Vector2 space) 987 in(tree, "Tree for Node.resize() must not be null.") 988 in(theme, "Theme for Node.resize() must not be null.") 989 do { 990 991 // Inherit tree and theme 992 this.tree = tree; 993 if (!this.theme) this.theme = theme; 994 995 // Load the theme 996 reloadStyles(); 997 998 // Queue actions into the tree 999 tree.actions ~= _queuedActions; 1000 _queuedActions = null; 1001 1002 1003 // The node is hidden, reset size 1004 if (isHidden) minSize = Vector2(0, 0); 1005 1006 // Otherwise perform like normal 1007 else { 1008 1009 import std.range; 1010 1011 const fullMargin = style.fullMargin; 1012 const spacingX = chain(fullMargin.sideX[], style.padding.sideX[]).sum; 1013 const spacingY = chain(fullMargin.sideY[], style.padding.sideY[]).sum; 1014 1015 // Reduce space by margins 1016 space.x = max(0, space.x - spacingX); 1017 space.y = max(0, space.y - spacingY); 1018 1019 assert( 1020 space.x.isFinite && space.y.isFinite, 1021 format!"Internal error — Node %s was given infinite space: %s; spacing(x = %s, y = %s)"(typeid(this), 1022 space, spacingX, spacingY) 1023 ); 1024 1025 // Run beforeResize actions 1026 foreach (action; tree.filterActions) { 1027 1028 action.beforeResize(this, space); 1029 1030 } 1031 1032 // Resize the node 1033 resizeImpl(space); 1034 1035 assert( 1036 minSize.x.isFinite && minSize.y.isFinite, 1037 format!"Node %s resizeImpl requested infinite minSize: %s"(typeid(this), minSize) 1038 ); 1039 1040 // Add margins 1041 minSize.x = ceil(minSize.x + spacingX); 1042 minSize.y = ceil(minSize.y + spacingY); 1043 1044 } 1045 1046 assert( 1047 minSize.x.isFinite && minSize.y.isFinite, 1048 format!"Internal error — Node %s returned invalid minSize %s"(typeid(this), minSize) 1049 ); 1050 1051 } 1052 1053 /// Ditto 1054 /// 1055 /// This is the implementation of resizing to be provided by children. 1056 /// 1057 /// If style margins/paddings are non-zero, they are automatically subtracted from space, so they are handled 1058 /// automatically. 1059 protected abstract void resizeImpl(Vector2 space); 1060 1061 /// Draw this node. 1062 /// 1063 /// Tip: Instead of directly accessing `style`, use `pickStyle` to enable temporarily changing styles as visual 1064 /// feedback. `resize` should still use the normal style. 1065 /// 1066 /// Params: 1067 /// paddingBox = Area which should be used by the node. It should include styling elements such as background, 1068 /// but no content. 1069 /// contentBox = Area which should be filled with content of the node, such as child nodes, text, etc. 1070 protected abstract void drawImpl(Rectangle paddingBox, Rectangle contentBox); 1071 1072 /// Check if the node is hovered. 1073 /// 1074 /// This will be called right before drawImpl for each node in order to determine the which node should handle mouse 1075 /// input. 1076 /// 1077 /// The default behavior considers the entire area of the node to be "hoverable". 1078 /// 1079 /// Params: 1080 /// rect = Area the node should be drawn in, as provided by drawImpl. 1081 /// mousePosition = Current mouse position within the window. 1082 protected bool hoveredImpl(Rectangle rect, Vector2 mousePosition) { 1083 1084 return rect.contains(mousePosition); 1085 1086 } 1087 1088 alias ImplHoveredRect = implHoveredRect; 1089 1090 deprecated("implHoveredRect is now the default behavior; implHoveredRect is to be removed in 0.8.0") 1091 protected mixin template implHoveredRect() { 1092 1093 private import fluid.backend : Rectangle, Vector2; 1094 1095 protected override bool hoveredImpl(Rectangle rect, Vector2 mousePosition) const { 1096 1097 import fluid.utils : contains; 1098 1099 return rect.contains(mousePosition); 1100 1101 } 1102 1103 } 1104 1105 /// Get the current style. 1106 Style pickStyle() { 1107 1108 // Pick the current style 1109 auto result = _style; 1110 1111 // Apply it 1112 foreach (dg; _styleDelegates) { 1113 1114 dg(this).apply(this, result); 1115 1116 } 1117 1118 return result; 1119 1120 } 1121 1122 /// Reload style from the current theme. 1123 protected void reloadStyles() { 1124 1125 import fluid.typeface; 1126 1127 // Reset style 1128 _style = Style.init; 1129 1130 // Apply theme to the given style 1131 _styleDelegates = theme.apply(this, _style); 1132 1133 // Update size 1134 updateSize(); 1135 1136 } 1137 1138 /// Get the node's position in its box. 1139 private Vector2 position(Rectangle space, Vector2 usedSpace) const { 1140 1141 float positionImpl(NodeAlign align_, lazy float spaceLeft) { 1142 1143 with (NodeAlign) 1144 final switch (align_) { 1145 1146 case start, fill: return 0; 1147 case center: return spaceLeft / 2; 1148 case end: return spaceLeft; 1149 1150 } 1151 1152 } 1153 1154 return Vector2( 1155 space.x + positionImpl(layout.nodeAlign[0], space.width - usedSpace.x), 1156 space.y + positionImpl(layout.nodeAlign[1], space.height - usedSpace.y), 1157 ); 1158 1159 } 1160 1161 @system // catching Error 1162 unittest { 1163 1164 import std.exception; 1165 import core.exception; 1166 import fluid.frame; 1167 1168 static class Square : Frame { 1169 @safe: 1170 Color color; 1171 this(Color color) { 1172 this.color = color; 1173 } 1174 override void resizeImpl(Vector2) { 1175 minSize = Vector2(100, 100); 1176 } 1177 override void drawImpl(Rectangle, Rectangle inner) { 1178 io.drawRectangle(inner, color); 1179 } 1180 } 1181 1182 alias square = simpleConstructor!Square; 1183 1184 auto io = new HeadlessBackend; 1185 auto colors = [ 1186 color!"7ff0a5", 1187 color!"17cccc", 1188 color!"a6a415", 1189 color!"cd24cf", 1190 ]; 1191 auto root = vframe( 1192 .layout!"fill", 1193 square(.layout!"start", colors[0]), 1194 square(.layout!"center", colors[1]), 1195 square(.layout!"end", colors[2]), 1196 square(.layout!"fill", colors[3]), 1197 ); 1198 1199 root.theme = Theme.init.derive( 1200 rule!Frame(Rule.backgroundColor = color!"1c1c1c") 1201 ); 1202 root.io = io; 1203 1204 // Test the layout 1205 { 1206 1207 root.draw(); 1208 1209 // Each square in order 1210 io.assertRectangle(Rectangle(0, 0, 100, 100), colors[0]); 1211 io.assertRectangle(Rectangle(350, 100, 100, 100), colors[1]); 1212 io.assertRectangle(Rectangle(700, 200, 100, 100), colors[2]); 1213 1214 // Except the last one, which is turned into a rectangle by "fill" 1215 // A proper rectangle class would change its target rectangles to keep aspect ratio 1216 io.assertRectangle(Rectangle(0, 300, 800, 100), colors[3]); 1217 1218 } 1219 1220 // Now do the same, but expand each node 1221 { 1222 1223 io.nextFrame; 1224 1225 foreach (child; root.children) { 1226 child.layout.expand = 1; 1227 } 1228 1229 root.draw().assertThrown!AssertError; // Oops, forgot to resize! 1230 root.updateSize; 1231 root.draw(); 1232 1233 io.assertRectangle(Rectangle(0, 0, 100, 100), colors[0]); 1234 io.assertRectangle(Rectangle(350, 175, 100, 100), colors[1]); 1235 io.assertRectangle(Rectangle(700, 350, 100, 100), colors[2]); 1236 io.assertRectangle(Rectangle(0, 450, 800, 150), colors[3]); 1237 1238 } 1239 1240 // Change Y alignment 1241 { 1242 1243 io.nextFrame; 1244 1245 root.children[0].layout = .layout!(1, "start", "end"); 1246 root.children[1].layout = .layout!(1, "center", "fill"); 1247 root.children[2].layout = .layout!(1, "end", "start"); 1248 root.children[3].layout = .layout!(1, "fill", "center"); 1249 1250 root.updateSize; 1251 root.draw(); 1252 1253 io.assertRectangle(Rectangle(0, 50, 100, 100), colors[0]); 1254 io.assertRectangle(Rectangle(350, 150, 100, 150), colors[1]); 1255 io.assertRectangle(Rectangle(700, 300, 100, 100), colors[2]); 1256 io.assertRectangle(Rectangle(0, 475, 800, 100), colors[3]); 1257 1258 } 1259 1260 // Try different expand values 1261 { 1262 1263 io.nextFrame; 1264 1265 root.children[0].layout = .layout!(0, "center", "fill"); 1266 root.children[1].layout = .layout!(1, "center", "fill"); 1267 root.children[2].layout = .layout!(2, "center", "fill"); 1268 root.children[3].layout = .layout!(3, "center", "fill"); 1269 1270 root.updateSize; 1271 root.draw(); 1272 1273 // The first rectangle doesn't expand so it should be exactly 100×100 in size 1274 io.assertRectangle(Rectangle(350, 0, 100, 100), colors[0]); 1275 1276 // The remaining space is 500px, so divided into 1+2+3=6 pieces, it should be about 83.33px per piece 1277 io.assertRectangle(Rectangle(350, 100.00, 100, 83.33), colors[1]); 1278 io.assertRectangle(Rectangle(350, 183.33, 100, 166.66), colors[2]); 1279 io.assertRectangle(Rectangle(350, 350.00, 100, 250.00), colors[3]); 1280 1281 } 1282 1283 } 1284 1285 private bool isLMBHeld() @trusted { 1286 1287 return tree.io.isDown(MouseButton.left) 1288 || tree.io.isReleased(MouseButton.left); 1289 1290 } 1291 1292 override string toString() const { 1293 1294 return format!"%s(%s)"(typeid(this), layout); 1295 1296 } 1297 1298 } 1299 1300 /// 1301 void run(Node node) { 1302 1303 // Mock run callback is available 1304 if (mockRun) { 1305 1306 mockRun()(node); 1307 1308 } 1309 1310 // TODO Create the event loop interface 1311 else assert(false, "Default backend does not expose an event loop interface."); 1312 1313 } 1314 1315 alias RunCallback = void delegate(Node node) @safe; 1316 1317 /// Set a new function to use instead of `run`. 1318 RunCallback mockRun(RunCallback callback) { 1319 1320 // Assign the callback 1321 mockRun() = callback; 1322 return mockRun(); 1323 1324 } 1325 1326 ref RunCallback mockRun() { 1327 1328 static RunCallback callback; 1329 return callback; 1330 1331 }