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 }