1 module fluid.scroll_input;
2 
3 import std.math;
4 import std.algorithm;
5 
6 import fluid.node;
7 import fluid.utils;
8 import fluid.input;
9 import fluid.style;
10 import fluid.backend;
11 
12 
13 @safe:
14 
15 
16 /// Create a new vertical scroll bar.
17 alias vscrollInput = simpleConstructor!ScrollInput;
18 
19 /// Create a new horizontal scroll bar.
20 alias hscrollInput = simpleConstructor!(ScrollInput, (a) {
21 
22     a.isHorizontal = true;
23 
24 });
25 
26 ///
27 class ScrollInput : InputNode!Node {
28 
29     // TODO Hiding a scrollbar makes it completely unusable, since it cannot scan the viewport. Perhaps override
30     // `isHidden` to virtually hide the scrollbar, and keep it always "visible" as such?
31 
32     mixin enableInputActions;
33 
34     public {
35 
36         /// Mouse scroll speed; Pixels per event in Scrollable.
37         enum scrollSpeed = 60.0;
38 
39         /// Keyboard/gamepad scroll speed in pixels per event.
40         enum actionScrollSpeed = 60.0;
41 
42         /// If true, the scrollbar will be horizontal.
43         bool isHorizontal;
44 
45         alias horizontal = isHorizontal;
46 
47         /// Amount of pixels the page is scrolled down.
48         float position = 0;
49 
50         /// Available space to scroll.
51         ///
52         /// Note: visible box size, and therefore scrollbar handle length, are determined from the space occupied by the
53         /// scrollbar.
54         float availableSpace = 0;
55 
56         /// Width of the scrollbar.
57         float width = 10;
58 
59         /// Handle of the scrollbar.
60         ScrollInputHandle handle;
61 
62     }
63 
64     protected {
65 
66         /// True if the scrollbar is pressed.
67         bool _isPressed;
68 
69         /// If true, the inner part of the scrollbar is hovered.
70         bool innerHovered;
71 
72         /// Page length as determined in resizeImpl.
73         double pageLength;
74 
75         /// Length of the scrollbar as determined in drawImpl.
76         double length;
77 
78     }
79 
80     this() {
81 
82         handle = new ScrollInputHandle(this);
83 
84     }
85 
86     bool isPressed() const {
87 
88         return _isPressed;
89 
90     }
91 
92     /// Scroll page length used for `pageUp` and `pageDown` navigation.
93     float scrollPageLength() const {
94 
95         return length * 0.75;
96 
97     }
98 
99     /// Set the scroll to a value clamped between start and end. Doesn't trigger the `changed` event.
100     void setScroll(float value) {
101 
102         assert(scrollMax.isFinite);
103 
104         position = value.clamp(0, scrollMax);
105 
106         assert(position.isFinite);
107 
108     }
109 
110     /// Get the maximum value this container can be scrolled to. Requires at least one draw.
111     float scrollMax() const {
112 
113         return max(0, availableSpace - pageLength);
114 
115     }
116 
117     /// Set the total size of the scrollbar. Will always fill the available space in the target direction.
118     override protected void resizeImpl(Vector2 space) {
119 
120         // Get minSize
121         minSize = isHorizontal
122             ? Vector2(space.x, width)
123             : Vector2(width, space.y);
124 
125         // Get the expected page length
126         pageLength = isHorizontal
127             ? space.x + style.padding.sideX[].sum + style.margin.sideX[].sum
128             : space.y + style.padding.sideY[].sum + style.margin.sideY[].sum;
129 
130         // Resize the handle
131         handle.resize(tree, theme, minSize);
132 
133     }
134 
135     override protected void drawImpl(Rectangle paddingBox, Rectangle contentBox) @trusted {
136 
137         _isPressed = checkIsPressed;
138 
139         const style = pickStyle();
140 
141         // Clamp the values first
142         setScroll(position);
143 
144         // Draw the background
145         style.drawBackground(tree.io, paddingBox);
146 
147         // Ignore if we can't scroll
148         if (scrollMax == 0) return;
149 
150         // Calculate the size of the scrollbar
151         length = isHorizontal ? contentBox.width : contentBox.height;
152         handle.length = availableSpace
153             ? max(handle.minimumLength, length^^2 / availableSpace)
154             : 0;
155 
156         const handlePosition = (length - handle.length) * position / scrollMax;
157 
158         // Now create a rectangle for the handle
159         auto handleRect = contentBox;
160 
161         if (isHorizontal) {
162 
163             handleRect.x += handlePosition;
164             handleRect.w  = handle.length;
165 
166         }
167 
168         else {
169 
170             handleRect.y += handlePosition;
171             handleRect.h  = handle.length;
172 
173         }
174 
175         handle.draw(handleRect);
176 
177     }
178 
179     @(FluidInputAction.pageLeft, FluidInputAction.pageRight)
180     @(FluidInputAction.pageUp, FluidInputAction.pageDown)
181     protected void scrollPage(FluidInputAction action) {
182 
183         with (FluidInputAction) {
184 
185             // Check if we're moving horizontally
186             const forHorizontal = action == pageLeft || action == pageRight;
187 
188             // Check direction
189             const direction = action == pageLeft || action == pageUp
190                 ? -1
191                 : 1;
192 
193             // Change
194             if (isHorizontal == forHorizontal) emitChange(direction * scrollPageLength);
195 
196         }
197 
198     }
199 
200     @(FluidInputAction.scrollLeft, FluidInputAction.scrollRight)
201     @(FluidInputAction.scrollUp, FluidInputAction.scrollDown)
202     protected void scroll(FluidInputAction action) @trusted {
203 
204         const isPlus = isHorizontal
205             ? action == FluidInputAction.scrollRight
206             : action == FluidInputAction.scrollDown;
207         const isMinus = isHorizontal
208             ? action == FluidInputAction.scrollLeft
209             : action == FluidInputAction.scrollUp;
210 
211         const change
212             = isPlus  ? +actionScrollSpeed
213             : isMinus ? -actionScrollSpeed
214             : 0;
215 
216         emitChange(change);
217 
218 
219     }
220 
221     /// Change the value and run the `changed` callback.
222     protected void emitChange(float move) {
223 
224         // Ignore if nothing changed.
225         if (move == 0) return;
226 
227         // Update scroll
228         setScroll(position + move);
229 
230         // Run the callback
231         if (changed) changed();
232 
233     }
234 
235 }
236 
237 unittest {
238 
239     import std.range;
240     import fluid.label;
241     import fluid.button;
242     import fluid.scroll;
243     import fluid.structs;
244 
245     Button btn;
246 
247     auto io = new HeadlessBackend(Vector2(200, 100));
248     auto root = vscrollFrame(
249         layout!"fill",
250         btn = button(layout!"fill", "Button to test hover slipping", delegate { assert(false); }),
251         label("Text long enough to overflow this very small viewport and create a scrollbar"),
252     );
253 
254     root.io = io;
255     root.draw();
256 
257     // Grab the scrollbar
258     io.nextFrame;
259     io.mousePosition = Vector2(195, 10);
260     io.press;
261     root.draw();
262 
263     // Drag the scrollbar 10 pixels lower
264     io.nextFrame;
265     io.mousePosition = Vector2(195, 20);
266     root.draw();
267 
268     // Note down the difference
269     const scrollDiff = root.scroll;
270 
271     // Drag the scrollbar 10 pixels lower, but also move it out of the scrollbar's area
272     io.nextFrame;
273     io.mousePosition = Vector2(150, 30);
274     root.draw();
275 
276     const target = scrollDiff*2;
277 
278     assert(target-1 <= root.scroll && root.scroll <= target+1,
279         "Scrollbar should operate at the same rate, even if the cursor is outside");
280 
281     // Make sure the button is hovered
282     io.nextFrame;
283     io.mousePosition = Vector2(150, 20);
284     root.draw();
285     assert(root.tree.hover is root.scrollBar.handle, "The scrollbar should retain hover control");
286     assert(btn.isHovered, "The button has to be hovered");
287 
288     // Release the mouse while it's hovering the button
289     io.nextFrame;
290     io.release;
291     root.draw();
292     assert(btn.isHovered);
293     // No event should trigger
294 
295 }
296 
297 class ScrollInputHandle : Node, FluidHoverable {
298 
299     mixin makeHoverable;
300     mixin enableInputActions;
301 
302     public {
303 
304         enum minimumLength = 50;
305 
306         ScrollInput parent;
307 
308     }
309 
310     protected {
311 
312         /// Length of the handle.
313         double length;
314 
315         /// True if the handle was pressed this frame.
316         bool justPressed;
317 
318         /// Position of the mouse when dragging started.
319         Vector2 startMousePosition;
320 
321         /// Scroll value when dragging started.
322         float startScrollPosition;
323 
324     }
325 
326     private {
327 
328         bool _isPressed;
329 
330     }
331 
332     this(ScrollInput parent) {
333 
334         import fluid.structs : layout;
335 
336         this.layout = layout!"fill";
337         this.parent = parent;
338 
339     }
340 
341     bool isPressed() const {
342 
343         return _isPressed;
344 
345     }
346 
347     bool isFocused() const {
348 
349         return parent.isFocused;
350 
351     }
352 
353     override bool isHovered() const {
354 
355         return this is tree.hover || super.isHovered();
356 
357     }
358 
359     override protected void resizeImpl(Vector2 space) {
360 
361         if (parent.isHorizontal)
362             minSize = Vector2(minimumLength, parent.width);
363         else
364             minSize = Vector2(parent.width, minimumLength);
365 
366     }
367 
368     override protected void drawImpl(Rectangle paddingBox, Rectangle contentBox) @trusted {
369 
370         // Check if pressed
371         const pressed = isHovered && tree.isMouseDown!(FluidInputAction.press);
372         justPressed = pressed && !_isPressed;
373         _isPressed = pressed;
374 
375         auto style = pickStyle();
376         style.drawBackground(io, paddingBox);
377 
378     }
379 
380     @(FluidInputAction.press, fluid.input.WhileDown)
381     protected void whileDown() @trusted {
382 
383         const mousePosition = io.mousePosition;
384 
385         // Just pressed, save data
386         if (justPressed) {
387 
388             startMousePosition = mousePosition;
389             startScrollPosition = parent.position;
390             return;
391 
392         }
393 
394         const totalMove = parent.isHorizontal
395             ? mousePosition.x - startMousePosition.x
396             : mousePosition.y - startMousePosition.y;
397 
398         const scrollDifference = totalMove * parent.scrollMax / (parent.length - length);
399 
400         assert(totalMove.isFinite);
401         assert(parent.length.isFinite);
402         assert(length.isFinite);
403         assert(scrollDifference.isFinite);
404 
405         // Move the scrollbar
406         parent.setScroll(startScrollPosition + scrollDifference);
407 
408         // Emit signal
409         if (parent.changed) parent.changed();
410 
411     }
412 
413     protected void mouseImpl() {
414 
415     }
416 
417 }