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