1 /// 2 module fluid.input; 3 4 import std.meta; 5 import std.format; 6 import std.traits; 7 import std.algorithm; 8 9 import fluid.node; 10 import fluid.tree; 11 import fluid.style; 12 import fluid.backend; 13 14 15 @safe: 16 17 18 /// Make a InputAction handler react to every frame as long as the action is being held (mouse button held down, 19 /// key held down, etc.). 20 enum whileDown; 21 22 /// Default input actions one can listen to. 23 @InputAction 24 enum FluidInputAction { 25 26 // Basic 27 press, /// Press the input. Used for example to activate buttons. 28 submit, /// Submit input, eg. finish writing in textInput. 29 cancel, /// Cancel the input. 30 31 // Focus 32 focusPrevious, /// Focus previous input. 33 focusNext, /// Focus next input. 34 focusLeft, /// Focus input on the left. 35 focusRight, /// Focus input on the right. 36 focusUp, /// Focus input above. 37 focusDown, /// Focus input below. 38 39 // Input 40 backspace, /// Erase last character in an input. 41 backspaceWord, /// Erase last a word in an input. 42 entryPrevious, /// Navigate to the previous list entry. 43 entryNext, /// Navigate to the next list entry. 44 entryUp, /// Navigate up in a tree, eg. in the file picker. 45 46 // Scrolling 47 scrollLeft, /// Scroll left a bit. 48 scrollRight, /// Scroll right a bit. 49 scrollUp, /// Scroll up a bit. 50 scrollDown, /// Scroll down a bit 51 pageLeft, /// Scroll left by a page. Unbound by default. 52 pageRight, /// Scroll right by a page. Unbound by default. 53 pageUp, /// Scroll up by a page. 54 pageDown, /// Scroll down by a page. 55 56 } 57 58 /// ID of an input action. 59 immutable struct InputActionID { 60 61 /// Unique ID of the action. 62 size_t id; 63 64 /// Action name. Only emitted when debugging. 65 debug string name; 66 67 /// Get ID of an input action. 68 this(IA : InputAction!actionType, alias actionType)(IA) immutable { 69 70 this.id = cast(size_t) &IA._id; 71 debug this.name = fullyQualifiedName!actionType; 72 73 } 74 75 static InputActionID from(alias item)() { 76 77 return InputAction!item.id; 78 79 } 80 81 bool opEqual(InputActionID other) { 82 83 return id == other.id; 84 85 } 86 87 } 88 89 /// Check if the given symbol is an input action type. 90 /// 91 /// The symbol symbol must be a member of an enum marked with `@InputAction`. The enum $(B must not) be a manifest 92 /// constant (eg. `enum foo = 123;`). 93 template isInputActionType(alias actionType) { 94 95 // Require the action type to be an enum 96 static if (is(typeof(actionType) == enum)) { 97 98 // Search through the enum attributes 99 static foreach (attribute; __traits(getAttributes, typeof(actionType))) { 100 101 // Not yet found 102 static if (!is(typeof(isInputActionType) == bool)) { 103 104 // Check if this is the attribute we're looking for 105 static if (__traits(isSame, attribute, InputAction)) { 106 107 enum isInputActionType = true; 108 109 } 110 111 } 112 113 } 114 115 } 116 117 // Not found 118 static if (!is(typeof(isInputActionType) == bool)) { 119 120 // Respond as false 121 enum isInputActionType = false; 122 123 } 124 125 } 126 127 unittest { 128 129 enum MyEnum { 130 foo = 123, 131 } 132 133 @InputAction 134 enum MyAction { 135 foo, 136 } 137 138 static assert(isInputActionType!(FluidInputAction.entryUp)); 139 static assert(isInputActionType!(MyAction.foo)); 140 141 static assert(!isInputActionType!InputNode); 142 static assert(!isInputActionType!InputAction); 143 static assert(!isInputActionType!(InputAction!(FluidInputAction.entryUp))); 144 static assert(!isInputActionType!FluidInputAction); 145 static assert(!isInputActionType!MyEnum); 146 static assert(!isInputActionType!(MyEnum.foo)); 147 static assert(!isInputActionType!MyAction); 148 149 150 } 151 152 /// Represents a key or button input combination. 153 struct InputStroke { 154 155 import std.sumtype; 156 157 alias Item = SumType!(KeyboardKey, MouseButton, GamepadButton); 158 159 Item[] input; 160 161 this(T...)(T items) 162 if (!is(items : Item[])) { 163 164 input.length = items.length; 165 static foreach (i, item; items) { 166 167 input[i] = Item(item); 168 169 } 170 171 } 172 173 this(Item[] items) { 174 175 input = items; 176 177 } 178 179 /// Get number of items in the stroke. 180 size_t length() const => input.length; 181 182 /// Get a copy of the input stroke with the last item removed, if any. 183 /// 184 /// For example, for a `leftShift+w` stroke, this will return `leftShift`. 185 InputStroke modifiers() { 186 187 return input.length 188 ? InputStroke(input[0..$-1]) 189 : InputStroke(); 190 191 } 192 193 /// Check if the last item of this input stroke is done with a mouse 194 bool isMouseStroke() const { 195 196 return isMouseItem(input[$-1]); 197 198 } 199 200 unittest { 201 202 assert(!InputStroke(KeyboardKey.leftControl).isMouseStroke); 203 assert(!InputStroke(KeyboardKey.w).isMouseStroke); 204 assert(!InputStroke(KeyboardKey.leftControl, KeyboardKey.w).isMouseStroke); 205 206 assert(InputStroke(MouseButton.left).isMouseStroke); 207 assert(InputStroke(KeyboardKey.leftControl, MouseButton.left).isMouseStroke); 208 209 assert(!InputStroke(GamepadButton.triangle).isMouseStroke); 210 assert(!InputStroke(KeyboardKey.leftControl, GamepadButton.triangle).isMouseStroke); 211 212 } 213 214 /// Check if the given item is done with a mouse. 215 static bool isMouseItem(Item item) { 216 217 return item.match!( 218 (MouseButton _) => true, 219 (_) => false, 220 ); 221 222 } 223 224 /// Check if all keys or buttons required for the stroke are held down. 225 bool isDown(const FluidBackend backend) const { 226 227 return input.all!(a => isItemDown(backend, a)); 228 229 } 230 231 /// 232 unittest { 233 234 auto stroke = InputStroke(KeyboardKey.leftControl, KeyboardKey.w); 235 auto io = new HeadlessBackend; 236 237 // No keys pressed 238 assert(!stroke.isDown(io)); 239 240 // Control pressed 241 io.press(KeyboardKey.leftControl); 242 assert(!stroke.isDown(io)); 243 244 // Both keys pressed 245 io.press(KeyboardKey.w); 246 assert(stroke.isDown(io)); 247 248 // Still pressed, but not immediately 249 io.nextFrame; 250 assert(stroke.isDown(io)); 251 252 // W pressed 253 io.release(KeyboardKey.leftControl); 254 assert(!stroke.isDown(io)); 255 256 } 257 258 /// Check if the stroke has been triggered during this frame. 259 /// 260 /// If the last item of the action is a mouse button, the action will be triggered on release. If it's a keyboard 261 /// key or gamepad button, it'll be triggered on press. All previous items, if present, have to be held down at the 262 /// time. 263 bool isActive(const FluidBackend backend) const @trusted { 264 265 // For all but the last item, check if it's held down 266 return input[0 .. $-1].all!(a => isItemDown(backend, a)) 267 268 // For the last item, check if it's pressed or released, depending on the type 269 && isItemActive(backend, input[$-1]); 270 271 } 272 273 unittest { 274 275 auto singleKey = InputStroke(KeyboardKey.w); 276 auto stroke = InputStroke(KeyboardKey.leftControl, KeyboardKey.leftShift, KeyboardKey.w); 277 auto io = new HeadlessBackend; 278 279 // No key pressed 280 assert(!singleKey.isActive(io)); 281 assert(!stroke.isActive(io)); 282 283 io.press(KeyboardKey.w); 284 285 // Just pressed the "W" key 286 assert(singleKey.isActive(io)); 287 assert(!stroke.isActive(io)); 288 289 io.nextFrame; 290 291 // The stroke stops being active on the next frame 292 assert(!singleKey.isActive(io)); 293 assert(!stroke.isActive(io)); 294 295 io.press(KeyboardKey.leftControl); 296 io.press(KeyboardKey.leftShift); 297 298 assert(!singleKey.isActive(io)); 299 assert(!stroke.isActive(io)); 300 301 // The last key needs to be pressed during the current frame 302 io.press(KeyboardKey.w); 303 304 assert(singleKey.isActive(io)); 305 assert(stroke.isActive(io)); 306 307 io.release(KeyboardKey.w); 308 309 assert(!singleKey.isActive(io)); 310 assert(!stroke.isActive(io)); 311 312 } 313 314 /// Mouse actions are activated on release 315 unittest { 316 317 auto stroke = InputStroke(KeyboardKey.leftControl, MouseButton.left); 318 auto io = new HeadlessBackend; 319 320 assert(!stroke.isActive(io)); 321 322 io.press(KeyboardKey.leftControl); 323 io.press(MouseButton.left); 324 325 assert(!stroke.isActive(io)); 326 327 io.release(MouseButton.left); 328 329 assert(stroke.isActive(io)); 330 331 // The action won't trigger if previous keys aren't held down 332 io.release(KeyboardKey.leftControl); 333 334 assert(!stroke.isActive(io)); 335 336 } 337 338 /// Check if the given is held down. 339 static bool isItemDown(const FluidBackend backend, Item item) { 340 341 return item.match!( 342 343 // Keyboard 344 (KeyboardKey key) => backend.isDown(key), 345 346 // A released mouse button also counts as down for our purposes, as it might trigger the action 347 (MouseButton button) => backend.isDown(button) || backend.isReleased(button), 348 349 // Gamepad 350 (GamepadButton button) => backend.isDown(button) != 0 351 ); 352 353 } 354 355 /// Check if the given item is triggered. 356 /// 357 /// If the item is a mouse button, it will be triggered on release. If it's a keyboard key or gamepad button, it'll 358 /// be triggered on press. 359 static bool isItemActive(const FluidBackend backend, Item item) { 360 361 return item.match!( 362 (KeyboardKey key) => backend.isPressed(key) || backend.isRepeated(key), 363 (MouseButton button) => backend.isReleased(button), 364 (GamepadButton button) => backend.isPressed(button) || backend.isRepeated(button), 365 ); 366 367 } 368 369 string toString() const { 370 371 return format!"InputStroke(%(%s + %))"(input); 372 373 } 374 375 } 376 377 /// Binding of an input stroke to an input action. 378 struct InputBinding { 379 380 InputActionID action; 381 InputStroke.Item trigger; 382 383 } 384 385 /// A layer groups input bindings by common key modifiers. 386 struct InputLayer { 387 388 InputStroke modifiers; 389 InputBinding[] bindings; 390 391 /// When sorting ascending, the lowest value is given to the InputLayer with greatest number of bindings 392 int opCmp(const InputLayer other) const { 393 394 // You're not going to put 2,147,483,646 modifiers in a single input stroke, are you? 395 return cast(int) (other.modifiers.length - modifiers.length); 396 397 } 398 399 } 400 401 /// This meta-UDA can be attached to an enum, so Fluid would recognize members of said enum as an UDA defining input 402 /// actions. As an UDA, this template should be used without instantiating. 403 /// 404 /// This template also serves to provide unique identifiers for each action type, generated on startup. For example, 405 /// `InputAction!(FluidInputAction.press).id` will have the same value anywhere in the program. 406 /// 407 /// Action types are resolved at compile-time using symbols, so you can supply any `@InputAction`-marked enum defining 408 /// input actions. All built-in enums are defined in `FluidInputAction`. 409 /// 410 /// If the method returns `true`, it is understood that the action has been processed and no more actions will be 411 /// emitted during the frame. If it returns `false`, other actions and keyboardImpl will be tried until any call returns 412 /// `true` or no handlers are left. 413 struct InputAction(alias actionType) 414 if (isInputActionType!actionType) { 415 416 alias type = actionType; 417 418 alias id this; 419 420 /// **The pointer** to `_id` serves as ID of the input actions. 421 /// 422 /// Note: we could be directly getting the address of the ID function itself (`&id`), but it's possible some linkers 423 /// would merge declarations, so we're using `&_id` for safety. Example of such behavior can be achieved using 424 /// `ld.gold` with `--icf=all`. It's possible the linker could be aware we're checking the function address 425 // (`--icf=safe` works correctly), but again, we prefer to play it safe. Alternatively, we could test for this 426 /// behavior when the program starts, but it probably isn't worth it. 427 align(1) 428 private static immutable bool _id; 429 430 static InputActionID id() { 431 432 return InputActionID(typeof(this)()); 433 434 } 435 436 } 437 438 unittest { 439 440 assert(InputAction!(FluidInputAction.press).id == InputAction!(FluidInputAction.press).id); 441 assert(InputAction!(FluidInputAction.press).id != InputAction!(FluidInputAction.entryUp).id); 442 443 // IDs should have the same equality as the enum members, within the same enum 444 // This will not be the case for enum values with explicitly assigned values (but probably should be!) 445 foreach (left; EnumMembers!FluidInputAction) { 446 447 foreach (right; EnumMembers!FluidInputAction) { 448 449 if (left == right) 450 assert(InputAction!left.id == InputAction!right.id); 451 else 452 assert(InputAction!left.id != InputAction!right.id); 453 454 } 455 456 } 457 458 // Enum values don't have to have globally unique 459 @InputAction 460 enum FooActions { 461 action = 0, 462 } 463 464 @InputAction 465 enum BarActions { 466 action = 0, 467 } 468 469 assert(InputAction!(FooActions.action).id == InputAction!(FooActions.action).id); 470 assert(InputAction!(FooActions.action).id != InputAction!(BarActions.action).id); 471 assert(InputAction!(FooActions.action).id != InputAction!(FluidInputAction.press).id); 472 assert(InputAction!(BarActions.action).id != InputAction!(FluidInputAction.press).id); 473 474 } 475 476 @system 477 unittest { 478 479 import std.concurrency; 480 481 // IDs are global across threads 482 auto t0 = InputAction!(FluidInputAction.press).id; 483 484 spawn({ 485 486 ownerTid.send(InputAction!(FluidInputAction.press).id); 487 488 spawn({ 489 490 ownerTid.send(InputAction!(FluidInputAction.press).id); 491 492 }); 493 494 ownerTid.send(receiveOnly!InputActionID); 495 496 ownerTid.send(InputAction!(FluidInputAction.cancel).id); 497 498 }); 499 500 auto t1 = receiveOnly!InputActionID; 501 auto t2 = receiveOnly!InputActionID; 502 503 auto c0 = InputAction!(FluidInputAction.cancel).id; 504 auto c1 = receiveOnly!InputActionID; 505 506 assert(t0 == t1); 507 assert(t1 == t2); 508 509 assert(c0 != t0); 510 assert(c1 != t1); 511 assert(c0 != t1); 512 assert(c1 != t0); 513 514 assert(t0 == t1); 515 516 } 517 518 /// Check if any stroke bound to this action is being held. 519 bool isDown(alias type)(LayoutTree* tree) 520 if (isInputActionType!type) { 521 522 return tree.downActions[].canFind!"a.action == b"(InputActionID.from!type); 523 524 } 525 526 unittest { 527 528 import fluid.space; 529 530 auto io = new HeadlessBackend; 531 auto tree = new LayoutTree(vspace(), io); 532 533 // Nothing pressed, action not activated 534 assert(!tree.isDown!(FluidInputAction.backspaceWord)); 535 536 io.press(KeyboardKey.leftControl); 537 io.press(KeyboardKey.backspace); 538 tree.poll(); 539 540 // The action is now held down with the ctrl+blackspace stroke 541 assert(tree.isDown!(FluidInputAction.backspaceWord)); 542 543 io.release(KeyboardKey.backspace); 544 io.press(KeyboardKey.w); 545 tree.poll(); 546 547 // ctrl+W also activates the stroke 548 assert(tree.isDown!(FluidInputAction.backspaceWord)); 549 550 io.release(KeyboardKey.leftControl); 551 tree.poll(); 552 553 // Control up, won't match any stroke now 554 assert(!tree.isDown!(FluidInputAction.backspaceWord)); 555 556 } 557 558 /// Check if a mouse stroke bound to this action is being held. 559 bool isMouseDown(alias type)(LayoutTree* tree) 560 if (isInputActionType!type) { 561 562 return tree.downActions[].canFind!(a 563 => a.action == InputActionID.from!type 564 && InputStroke.isMouseItem(a.trigger)); 565 566 } 567 568 unittest { 569 570 import fluid.space; 571 572 auto io = new HeadlessBackend; 573 auto tree = new LayoutTree(vspace(), io); 574 575 assert(!tree.isDown!(FluidInputAction.press)); 576 577 io.press(MouseButton.left); 578 tree.poll(); 579 580 // Pressing with a mouse 581 assert(tree.isDown!(FluidInputAction.press)); 582 assert(tree.isMouseDown!(FluidInputAction.press)); 583 584 io.release(MouseButton.left); 585 tree.poll(); 586 587 // Releasing a mouse key still counts as holding it down 588 // This is important — a released mouse is used to trigger the action 589 assert(tree.isDown!(FluidInputAction.press)); 590 assert(tree.isMouseDown!(FluidInputAction.press)); 591 592 // Need to wait a frame 593 io.nextFrame; 594 tree.poll(); 595 596 assert(!tree.isDown!(FluidInputAction.press)); 597 598 io.press(KeyboardKey.enter); 599 tree.poll(); 600 601 // Pressing with a keyboard 602 assert(tree.isDown!(FluidInputAction.press)); 603 assert(!tree.isMouseDown!(FluidInputAction.press)); 604 605 io.release(KeyboardKey.enter); 606 tree.poll(); 607 608 assert(!tree.isDown!(FluidInputAction.press)); 609 610 } 611 612 /// Check if a keyboard or gamepad stroke bound to this action is being held. 613 bool isFocusDown(alias type)(LayoutTree* tree) 614 if (isInputActionType!type) { 615 616 return tree.downActions[].canFind!(a 617 => a.action == InputActionID.from!type 618 && !InputStroke.isMouseItem(a.trigger)); 619 620 } 621 622 unittest { 623 624 import fluid.space; 625 626 auto io = new HeadlessBackend; 627 auto tree = new LayoutTree(vspace(), io); 628 629 assert(!tree.isDown!(FluidInputAction.press)); 630 631 io.press(KeyboardKey.enter); 632 tree.poll(); 633 634 // Pressing with a keyboard 635 assert(tree.isDown!(FluidInputAction.press)); 636 assert(tree.isFocusDown!(FluidInputAction.press)); 637 638 io.release(KeyboardKey.enter); 639 io.press(MouseButton.left); 640 tree.poll(); 641 642 // Pressing with a mouse 643 assert(tree.isDown!(FluidInputAction.press)); 644 assert(!tree.isFocusDown!(FluidInputAction.press)); 645 646 } 647 648 /// Check if any stroke bound to this action is active. 649 bool isActive(alias type)(const(LayoutTree)* tree) 650 if (isInputActionType!type) { 651 652 return tree.activeActions[].canFind!(a 653 => a.action == InputActionID.from!type); 654 655 } 656 657 /// Check if a mouse stroke bound to this action is active 658 bool isMouseActive(alias type)(LayoutTree* tree) 659 if (isInputActionType!type) { 660 661 return tree.activeActions[].canFind!(a 662 => a.action == InputActionID.from!type 663 && InputStroke.isMouseItem(a.trigger)); 664 665 } 666 667 /// Check if a keyboard or gamepad stroke bound to this action is active. 668 bool isFocusActive(alias type)(LayoutTree* tree) 669 if (isInputActionType!type) { 670 671 return tree.activeActions[].canFind!(a 672 => a.action == InputActionID.from!type 673 && !InputStroke.isMouseItem(a.trigger)); 674 675 } 676 677 /// An interface to be implemented by all nodes that can perform actions when hovered (eg. on click) 678 interface FluidHoverable { 679 680 /// Handle mouse input on the node. 681 void mouseImpl(); 682 683 /// Check if the node is disabled. `mixin makeHoverable` to implement. 684 ref inout(bool) isDisabled() inout; 685 686 /// Check if the node is hovered. 687 bool isHovered() const; 688 689 /// Get the underlying node. 690 final inout(Node) asNode() inout { 691 692 return cast(inout Node) this; 693 694 } 695 696 /// Run input actions for the node. 697 /// 698 /// Internal. `Node` calls this for the focused node every frame, falling back to `mouseImpl` if this returns 699 /// false. 700 /// 701 /// Implement by adding `mixin enableInputActions` in your class. 702 bool runMouseInputActions(); 703 704 mixin template makeHoverable() { 705 706 import fluid.node; 707 import std.format; 708 709 static assert(is(typeof(this) : Node), format!"%s : FluidHoverable must inherit from a Node"(typeid(this))); 710 711 override ref inout(bool) isDisabled() inout { 712 713 return super.isDisabled; 714 715 } 716 717 } 718 719 mixin template enableInputActions() { 720 721 import fluid.node; 722 723 static assert(is(typeof(this) : Node), 724 format!"%s : FluidHoverable must inherit from Node"(typeid(this))); 725 726 override bool runMouseInputActions() { 727 728 return runInputActionsImpl!(true, typeof(this)); 729 730 } 731 732 /// Please use enableInputActions instead of this. 733 private bool runInputActionsImpl(bool mouse, This)() { 734 735 import std.string, std.traits, std.algorithm; 736 737 // Check if this class has implemented this method 738 assert(typeid(this) is typeid(This), 739 format!"%s is missing `mixin enableInputActions;`"(typeid(this))); 740 741 // Check each member 742 static foreach (memberName; __traits(allMembers, This)) { 743 744 static foreach (overload; __traits(getOverloads, This, memberName)) {{ 745 746 alias member = __traits(getMember, This, memberName); 747 748 // Filter out to functions only, also ignore deprecated functions 749 enum isMethod = !__traits(isDeprecated, member) 750 && __traits(compiles, isFunction!member) 751 && isFunction!member; 752 // TODO maybe somehow issue an error if an input action is marked deprecated? 753 754 static if (isMethod) { 755 756 // Make sure no method is marked `@InputAction`, that's invalid usage 757 alias inputActionUDAs = getUDAs!(overload, InputAction); 758 759 // Check for `@whileDown` 760 enum activateWhileDown = hasUDA!(overload, whileDown); 761 762 static assert(inputActionUDAs.length == 0, 763 format!"Please use @(%s) instead of @InputAction!(%1$s)"(inputActionUDAs[0].type)); 764 765 // Find all bound actions 766 static foreach (actionType; __traits(getAttributes, overload)) { 767 768 static if (isInputActionType!actionType) {{ 769 770 // Find out if there's any 771 const down = mouse 772 ? tree.isMouseDown!actionType 773 : tree.isFocusDown!actionType; 774 775 // Check if the stroke is being held down 776 if (down) { 777 778 // Find the correct trigger condition 779 // Ignore mouse release events if the node is not hovered anymore 780 const condition 781 = activateWhileDown ? down 782 : mouse ? tree.isMouseActive!actionType && isHovered 783 : tree.isFocusActive!actionType; 784 785 // Run the action if the stroke was performed 786 if (condition) { 787 788 // Pass the action type if applicable 789 static if (__traits(compiles, overload(actionType))) { 790 791 overload(actionType); 792 793 } 794 795 // Run empty 796 else overload(); 797 798 } 799 800 // Mark as handled 801 return true; 802 803 } 804 805 }} 806 807 } 808 809 } 810 811 }} 812 813 } 814 815 return false; 816 817 } 818 819 } 820 821 } 822 823 /// An interface to be implemented by all nodes that can take focus. 824 /// 825 /// Note: Input nodes often have many things in common. If you want to create an input-taking node, you're likely better 826 /// off extending from `FluidInput`. 827 interface FluidFocusable : FluidHoverable { 828 829 /// Handle input. Called each frame when focused. 830 bool focusImpl(); 831 832 /// Set focus to this node. 833 /// 834 /// Implementation would usually assign `tree.focus` to self for this to take effect. It is legal, however, for this 835 /// method to redirect the focus to another node (by calling its `focus()` method), or ignore the request. 836 void focus(); 837 838 /// Check if this node has focus. Recommended implementation: `return tree.focus is this`. Proxy nodes, such as 839 /// `FluidFilePicker` might choose to return the value of the node they hold. 840 bool isFocused() const; 841 842 /// Run input actions for the node. 843 /// 844 /// Internal. `Node` calls this for the focused node every frame, falling back to `keyboardImpl` if this returns 845 /// false. 846 /// 847 /// Implement by adding `mixin enableInputActions` in your class. 848 bool runFocusInputActions(); 849 850 /// Mixin template to enable input actions in this class. 851 mixin template enableInputActions() { 852 853 private import fluid.input; 854 855 mixin FluidHoverable.enableInputActions; 856 857 // Implement the interface method 858 override bool runFocusInputActions() { 859 860 return runInputActionsImpl!(false, typeof(this)); 861 862 } 863 864 } 865 866 } 867 868 /// Represents a general input node. 869 /// 870 /// Styles: $(UL 871 /// $(LI `styleKey` = Default style for the input.) 872 /// $(LI `focusStyleKey` = Style for when the input is focused.) 873 /// $(LI `disabledStyleKey` = Style for when the input is disabled.) 874 /// ) 875 abstract class InputNode(Parent : Node) : Parent, FluidFocusable { 876 877 mixin defineStyles!( 878 "focusStyle", q{ style }, 879 "hoverStyle", q{ style }, 880 "disabledStyle", q{ style }, 881 ); 882 mixin makeHoverable; 883 mixin enableInputActions; 884 885 /// Callback to run when the input value is altered. 886 void delegate() changed; 887 888 /// Callback to run when the input is submitted. 889 void delegate() submitted; 890 891 this(T...)(T sup) { 892 893 super(sup); 894 895 } 896 897 override inout(Style) pickStyle() inout { 898 899 // Disabled 900 if (isDisabledInherited) return disabledStyle; 901 902 // Focused 903 else if (isFocused) return focusStyle; 904 905 // Hovered 906 else if (isHovered) return hoverStyle; 907 908 // Other 909 else return style; 910 911 } 912 913 /// Handle mouse input. 914 /// 915 /// Usually, you'd prefer to define a method marked with an `InputAction` enum. This function is preferred for more 916 /// advanced usage. 917 /// 918 /// Only one node can run its `mouseImpl` callback per frame, specifically, the last one to register its input. 919 /// This is to prevent parents or overlapping children to take input when another node is drawn on top. 920 protected override void mouseImpl() { } 921 922 protected bool keyboardImpl() { 923 924 return false; 925 926 } 927 928 /// Handle keyboard and gamepad input. 929 /// 930 /// Usually, you'd prefer to define a method marked with an `InputAction` enum. This function is preferred for more 931 /// advanced usage. 932 /// 933 /// This will be called each frame as long as this node has focus, unless an `InputAction` was triggered first. 934 /// 935 /// Returns: True if the input was handled, false if not. 936 override bool focusImpl() { 937 938 return keyboardImpl(); 939 940 } 941 942 /// Check if the node is being pressed. Performs action lookup. 943 /// 944 /// This is a helper for nodes that might do something when pressed, for example, buttons. 945 /// 946 /// Preferrably, the node should implement an `isPressed` property and cache the result of this! 947 protected bool checkIsPressed() { 948 949 return (isHovered && tree.isMouseDown!(FluidInputAction.press)) 950 || (isFocused && tree.isFocusDown!(FluidInputAction.press)); 951 952 } 953 954 /// Change the focus to this node. 955 void focus() { 956 957 import fluid.actions; 958 959 // Ignore if disabled 960 if (isDisabled) return; 961 962 // Switch the scroll 963 tree.focus = this; 964 965 // Ensure this node gets focus 966 this.scrollIntoView(); 967 968 } 969 970 @property { 971 972 /// Check if the node has focus. 973 bool isFocused() const { 974 975 return tree.focus is this; 976 977 } 978 979 /// Set or remove focus from this node. 980 bool isFocused(bool enable) { 981 982 if (enable) focus(); 983 else if (isFocused) tree.focus = null; 984 985 return enable; 986 987 } 988 989 } 990 991 } 992 993 unittest { 994 995 import fluid.label; 996 997 // This test checks triggering and running actions bound via UDAs, including reacting to keyboard and mouse input. 998 999 int pressCount; 1000 int cancelCount; 1001 1002 auto io = new HeadlessBackend; 1003 auto root = new class InputNode!Label { 1004 1005 @safe: 1006 1007 mixin enableInputActions; 1008 1009 this() { 1010 super(NodeParams(), ""); 1011 } 1012 1013 override void resizeImpl(Vector2 space) { 1014 1015 minSize = Vector2(10, 10); 1016 1017 } 1018 1019 @(FluidInputAction.press) 1020 void _pressed() { 1021 1022 pressCount++; 1023 1024 } 1025 1026 @(FluidInputAction.cancel) 1027 void _cancelled() { 1028 1029 cancelCount++; 1030 1031 } 1032 1033 }; 1034 1035 root.io = io; 1036 root.theme = nullTheme; 1037 root.focus(); 1038 1039 // Press the node via focus 1040 io.press(KeyboardKey.enter); 1041 1042 root.draw(); 1043 1044 assert(root.tree.isFocusActive!(FluidInputAction.press)); 1045 assert(pressCount == 1); 1046 1047 io.nextFrame; 1048 1049 // Holding shouldn't trigger the callback multiple times 1050 root.draw(); 1051 1052 assert(pressCount == 1); 1053 1054 // Hover the node and press it with the mouse 1055 io.nextFrame; 1056 io.release(KeyboardKey.enter); 1057 io.mousePosition = Vector2(5, 5); 1058 io.press(MouseButton.left); 1059 1060 root.draw(); 1061 root.tree.focus = null; 1062 1063 // This shouldn't be enough to activate the action 1064 assert(pressCount == 1); 1065 1066 // If we now drag away from the button and release... 1067 io.nextFrame; 1068 io.mousePosition = Vector2(15, 15); 1069 io.release(MouseButton.left); 1070 1071 root.draw(); 1072 1073 // ...the action shouldn't trigger 1074 assert(pressCount == 1); 1075 1076 // But if we release the mouse on the button 1077 io.nextFrame; 1078 io.mousePosition = Vector2(5, 5); 1079 io.release(MouseButton.left); 1080 1081 root.draw(); 1082 1083 assert(pressCount == 2); 1084 assert(cancelCount == 0); 1085 1086 // Focus the node again 1087 root.focus(); 1088 1089 // Press escape to cancel 1090 io.nextFrame; 1091 io.press(KeyboardKey.escape); 1092 1093 root.draw(); 1094 1095 assert(pressCount == 2); 1096 assert(cancelCount == 1); 1097 1098 } 1099 1100 unittest { 1101 1102 import fluid.space; 1103 import fluid.button; 1104 1105 // This test checks if "hover slipping" happens; namely, if the user clicks and holds on an object, then hovers on 1106 // something else and releases, the click should be cancelled, and no other object should react to the same click. 1107 1108 class SquareButton : Button!() { 1109 1110 mixin enableInputActions; 1111 1112 this(T...)(T t) { 1113 super(NodeParams(), t); 1114 } 1115 1116 override void resizeImpl(Vector2) { 1117 minSize = Vector2(10, 10); 1118 } 1119 1120 } 1121 1122 int[2] pressCount; 1123 SquareButton[2] buttons; 1124 1125 auto io = new HeadlessBackend; 1126 auto root = hspace( 1127 .nullTheme, 1128 buttons[0] = new SquareButton("", delegate { pressCount[0]++; }), 1129 buttons[1] = new SquareButton("", delegate { pressCount[1]++; }), 1130 ); 1131 1132 root.io = io; 1133 1134 // Press the left button 1135 io.mousePosition = Vector2(5, 5); 1136 io.press(MouseButton.left); 1137 1138 root.draw(); 1139 1140 // Release it 1141 io.release(MouseButton.left); 1142 1143 root.draw(); 1144 1145 assert(root.tree.hover is buttons[0]); 1146 assert(pressCount == [1, 0], "Left button should trigger"); 1147 1148 // Press the right button 1149 io.nextFrame; 1150 io.mousePosition = Vector2(15, 5); 1151 io.press(MouseButton.left); 1152 1153 root.draw(); 1154 1155 // Release it 1156 io.release(MouseButton.left); 1157 1158 root.draw(); 1159 1160 assert(pressCount == [1, 1], "Right button should trigger"); 1161 1162 // Press the left button, but don't release 1163 io.nextFrame; 1164 io.mousePosition = Vector2(5, 5); 1165 io.press(MouseButton.left); 1166 1167 root.draw(); 1168 1169 assert( buttons[0].isPressed); 1170 assert(!buttons[1].isPressed); 1171 1172 // Move the cursor over the right button 1173 io.nextFrame; 1174 io.mousePosition = Vector2(15, 5); 1175 1176 root.draw(); 1177 1178 // Left button should have tree-scope hover, but isHovered status is undefined. At the time of writing, only the 1179 // right button will be isHovered and neither will be isPressed. 1180 // 1181 // TODO It might be a good idea to make neither isHovered. Consider new condition: 1182 // 1183 // (_isHovered && tree.hover is this && !_isDisabled && !tree.isBranchDisabled) 1184 // 1185 // This should also fix having two nodes visually hovered in case they overlap. 1186 // 1187 // Other frameworks might retain isPressed status on the left button, but it might good idea to keep current 1188 // behavior as a visual clue it wouldn't trigger. 1189 assert(root.tree.hover is buttons[0]); 1190 1191 // Release the button on the next frame 1192 io.nextFrame; 1193 io.release(MouseButton.left); 1194 1195 root.draw(); 1196 1197 assert(pressCount == [1, 1], "Neither button should trigger on lost hover"); 1198 1199 // Things should go to normal next frame 1200 io.nextFrame; 1201 io.press(MouseButton.left); 1202 1203 root.draw(); 1204 1205 // So we can expect the right button to trigger now 1206 io.nextFrame; 1207 io.release(MouseButton.left); 1208 1209 root.draw(); 1210 1211 assert(root.tree.hover is buttons[1]); 1212 assert(pressCount == [1, 2]); 1213 1214 }