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