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