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 }