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