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 }