1 /// Definitions for common tree actions; This is the Fluid tree equivalent to std.algorithm.
2 module fluid.actions;
3 
4 import fluid.node;
5 import fluid.tree;
6 import fluid.input;
7 import fluid.scroll;
8 import fluid.backend;
9 
10 import fluid.io.focus;
11 
12 import fluid.future.pipe;
13 
14 
15 @safe:
16 
17 
18 /// Abstract class for tree actions that find and return a node.
19 abstract class NodeSearchAction : TreeAction, Publisher!Node {
20 
21     public {
22 
23         /// Node this action has found.
24         Node result;
25 
26     }
27 
28     private {
29 
30         /// Event that runs when the tree action finishes.
31         Event!Node finished;
32 
33     }
34 
35     alias then = typeof(super).then;
36     alias then = Publisher!Node.then;
37     alias subscribe = typeof(super).subscribe;
38     alias subscribe = Publisher!Node.subscribe;
39 
40     override void clearSubscribers() {
41         super.clearSubscribers();
42         finished.clearSubscribers();
43     }
44 
45     override void subscribe(Subscriber!Node subscriber) {
46         finished.subscribe(subscriber);
47     }
48 
49     override void beforeTree(Node node, Rectangle rect) {
50         super.beforeTree(node, rect);
51         result = null;
52     }
53 
54     override void stopped() {
55         super.stopped();
56         finished(result);
57     }
58 
59 }
60 
61 abstract class FocusSearchAction : NodeSearchAction, Publisher!Focusable {
62 
63     private {
64 
65         /// Event that runs when the tree action finishes.
66         Event!Focusable finished;
67 
68     }
69 
70     alias then = typeof(super).then;
71     alias then = Publisher!Focusable.then;
72     alias subscribe = typeof(super).subscribe;
73     alias subscribe = Publisher!Focusable.subscribe;
74 
75     override void clearSubscribers() {
76         super.clearSubscribers();
77         finished.clearSubscribers();
78     }
79 
80     override void subscribe(Subscriber!Focusable subscriber) {
81         finished.subscribe(subscriber);
82     }
83 
84     override void stopped() {
85         super.stopped();
86         finished(cast(Focusable) result);
87     }
88 
89 }
90 
91 /// Set focus on the given node, if focusable, or the first of its focusable children. This will be done lazily during
92 /// the next draw.
93 ///
94 /// If focusing the given node is not desired, use `focusRecurseChildren`.
95 ///
96 /// Params:
97 ///     parent = Container node to search in.
98 FocusRecurseAction focusRecurse(Node parent) {
99 
100     auto action = new FocusRecurseAction;
101 
102     // Perform a tree action to find the child
103     parent.queueAction(action);
104 
105     return action;
106 
107 }
108 
109 unittest {
110 
111     import fluid.space;
112     import fluid.label;
113     import fluid.button;
114 
115     auto io = new HeadlessBackend;
116     auto root = vspace(
117         label(""),
118         button("", delegate { }),
119         button("", delegate { }),
120         button("", delegate { }),
121     );
122 
123     // First paint: no node focused
124     root.io = io;
125     root.draw();
126 
127     assert(root.tree.focus is null, "No focus assigned on the first frame");
128 
129     io.nextFrame;
130 
131     // Recurse into the tree to focus on the first node
132     root.focusRecurse();
133     root.draw();
134 
135     assert(root.tree.focus.asNode is root.children[1], "First child is now focused");
136     assert((cast(FluidFocusable) root.children[1]).isFocused);
137 
138 }
139 
140 /// Set focus on the first of the node's focusable children. This will be done lazily during the next draw.
141 ///
142 /// Params:
143 ///     parent = Container node to search in.
144 FocusRecurseAction focusRecurseChildren(Node parent) {
145 
146     auto action = new FocusRecurseAction;
147     action.excludeStartNode = true;
148     parent.queueAction(action);
149 
150     return action;
151 
152 }
153 
154 /// ditto
155 FocusRecurseAction focusChild(Node parent) {
156 
157     return focusRecurseChildren(parent);
158 
159 }
160 
161 @("FocusRecurse works")
162 unittest {
163 
164     import fluid.space;
165     import fluid.button;
166 
167     auto root = vframeButton(
168         button("", delegate { }),
169         button("", delegate { }),
170         delegate { }
171     );
172 
173     // Typical focusRecurse call will focus the button
174     root.focusRecurse;
175     root.draw();
176 
177     assert(root.tree.focus is root);
178 
179     // If we want to make sure the action descends below the root, we must
180     root.focusRecurseChildren;
181     root.draw();
182 
183     assert(root.tree.focus.asNode is root.children[0]);
184 
185 }
186 
187 class FocusRecurseAction : FocusSearchAction {
188 
189     public {
190 
191         bool excludeStartNode;
192         bool isReverse;
193         void delegate(FluidFocusable) @safe finished;
194 
195     }
196 
197     override void beforeDraw(Node node, Rectangle) {
198 
199         // Ignore if the branch is disabled
200         if (node.isDisabledInherited) return;
201 
202         // Ignore the start node if excluded
203         if (excludeStartNode && node is startNode) return;
204 
205         // Check if the node is focusable
206         if (auto focusable = cast(FluidFocusable) node) {
207 
208             // Mark the node
209             result = node;
210 
211             // Stop here if selecting the first
212             if (!isReverse) stop;
213 
214         }
215 
216     }
217 
218     override void stopped() {
219 
220         if (auto focusable = cast(FluidFocusable) result) {
221             focusable.focus();
222             if (finished) finished(focusable);
223         }
224         super.stopped();
225 
226     }
227 
228 }
229 
230 /// Scroll so the given node becomes visible.
231 /// Params:
232 ///     node = Node to scroll to.
233 ///     alignToTop = If true, the top of the element will be aligned to the top of the scrollable area.
234 ScrollIntoViewAction scrollIntoView(Node node, bool alignToTop = false) {
235 
236     auto action = new ScrollIntoViewAction;
237     node.queueAction(action);
238     action.alignToTop = alignToTop;
239 
240     return action;
241 
242 }
243 
244 /// Scroll so that the given node appears at the top, if possible.
245 ScrollIntoViewAction scrollToTop(Node node) {
246 
247     return scrollIntoView(node, true);
248 
249 }
250 
251 @("Legacy: ScrollIntoViewAction works (migrated)")
252 unittest {
253 
254     import fluid;
255     import std.math;
256     import std.array;
257     import std.range;
258     import std.algorithm;
259 
260     const viewportHeight = 10;
261 
262     auto io = new HeadlessBackend(Vector2(10, viewportHeight));
263     auto root = vscrollFrame(
264         layout!(1, "fill"),
265         nullTheme,
266 
267         label("a"),
268         label("b"),
269         label("c"),
270     );
271 
272     root.io = io;
273     root.scrollBar.width = 0;  // TODO replace this with scrollBar.hide()
274 
275     // Prepare scrolling
276     // Note: Changes made when scrolling will be visible during the next frame
277     root.children[1].scrollIntoView;
278     root.draw();
279 
280     auto getPositions() {
281         return io.textures.map!(a => a.position).array;
282     }
283 
284     // Find label positions
285     auto positions = getPositions();
286 
287     // No theme so everything is as compact as it can be: the first label should be at the very top
288     assert(positions[0].y.isClose(0));
289 
290     // It is reasonable to assume the text will be larger than 10 pixels (viewport height)
291     // Other text will not render, since it's offscreen
292     assert(positions.length == 1);
293 
294     io.nextFrame;
295     root.draw();
296 
297     // TODO Because the label was hidden below the viewport, Fluid will align the bottom of the selected node with the
298     // viewport which probably isn't appropriate in case *like this* where it should reveal the top of the node.
299     auto texture1 = io.textures.front;
300     assert(isClose(texture1.position.y + texture1.height, viewportHeight));
301     assert(isClose(root.scroll, (root.scrollMax + 10) * 2/3 - 10));
302 
303     io.nextFrame;
304     root.draw();
305 
306     auto scrolledPositions = getPositions();
307 
308     // TODO more tests. Scrolling while already in the viewport, scrolling while partially out of the view, etc.
309 
310 }
311 
312 class ScrollIntoViewAction : TreeAction {
313 
314     public {
315 
316         /// If true, try to display the child at the top.
317         bool alignToTop;
318 
319     }
320 
321     private {
322 
323         /// The node this action attempts to put into view.
324         Node target;
325 
326         Vector2 viewport;
327         Rectangle childBox;
328 
329     }
330 
331     void reset(bool alignToTop = false) {
332 
333         this.toStop = false;
334         this.alignToTop = alignToTop;
335 
336     }
337 
338     override void afterDraw(Node node, Rectangle, Rectangle paddingBox, Rectangle contentBox) {
339 
340         // Target node was drawn
341         if (node is startNode) {
342 
343             // Make sure the action reaches the end of the tree
344             target = node;
345             startNode = null;
346 
347             // Get viewport size
348             viewport = node.tree.io.windowSize;
349 
350             // Get the node's padding box
351             childBox = node.focusBoxImpl(contentBox);
352 
353 
354         }
355 
356         // Ignore children of the target node
357         // Note: startNode is set until reached
358         else if (startNode !is null) return;
359 
360         // Reached a scroll node
361         // TODO What if the container isn't an ancestor
362         else if (auto scrollable = cast(FluidScrollable) node) {
363 
364             // Perform the scroll
365             childBox = scrollable.shallowScrollTo(target, paddingBox, childBox);
366 
367             // Aligning to top, make sure the child aligns with the parent
368             if (alignToTop && childBox.y > paddingBox.y) {
369 
370                 const offset = childBox.y - paddingBox.y;
371 
372                 scrollable.scroll = scrollable.scroll + cast(size_t) offset;
373 
374             }
375 
376         }
377 
378     }
379 
380 }
381 
382 /// Wait for the next frame. This action is a polyfill that can be used in tree action chains to make sure they're
383 /// added during `beforeTree`.
384 NextFrameAction nextFrame(Node node) {
385 
386     auto action = new NextFrameAction;
387     node.startAction(action);
388     return action;
389 
390 }
391 
392 class NextFrameAction : TreeAction {
393 
394     // Yes! It's that simple!
395 
396 }