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