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