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