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