StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use super::{ |
| 2 | AfterLayoutContext, AppContext, Axis, Element, Event, EventContext, Fill, LayoutContext, |
| 3 | PaintContext, Point, SizeConstraint, Vector2FExt, ZIndex, |
| 4 | }; |
| 5 | use crate::elements::F32Ext; |
| 6 | use crate::event::ModifiersState; |
| 7 | pub use crate::scene::CornerRadius; |
| 8 | use crate::units::{IntoPixels, Pixels}; |
| 9 | use crate::ClipBounds; |
| 10 | use crate::{event::DispatchedEvent, scene::Radius}; |
| 11 | |
| 12 | use pathfinder_color::ColorU; |
| 13 | use pathfinder_geometry::{ |
| 14 | rect::RectF, |
| 15 | vector::{vec2f, Vector2F}, |
| 16 | }; |
| 17 | use std::mem; |
| 18 | use std::sync::{Arc, Mutex, MutexGuard}; |
| 19 | |
| 20 | pub const LEFT_PADDING: f32 = 2.; |
| 21 | const RIGHT_PADDING: f32 = 2.; |
| 22 | const MINIMUM_HEIGHT: f32 = 20.; |
| 23 | |
| 24 | /// The number of pixels-per-line when dealing with a cocoa scroll event |
| 25 | /// that lacks precision (i.e. [`hasPreciseScrollingDeltas`](https://developer.apple.com/documentation/appkit/nsevent/1525758-hasprecisescrollingdeltas?language=objc)) |
| 26 | /// is false. While some mouse devices provide finer scroll deltas |
| 27 | /// (in pixels), other generic devices don't and we thus have to convert the |
| 28 | /// provided non-precise scroll deltas (which are in terms of lines) into pixels. |
| 29 | /// |
| 30 | /// While we could use the application line-height to calculate the number of pixels, |
| 31 | /// this requires us to couple the scrolling APIs with `Lines`, which doesn't apply |
| 32 | /// for horizontal scrolling. |
| 33 | /// |
| 34 | /// We also decided to not use [`CGEventSourceGetPixelsPerLine`](https://developer.apple.com/documentation/coregraphics/1408775-cgeventsourcegetpixelsperline) |
| 35 | /// because it defaults to ~10 pixels per line, which makes scrolling feel slow compared to other applications. |
| 36 | /// |
| 37 | /// The value we chose is inspired by the value that Chromium and Flutter use: |
| 38 | /// - https://chromium.googlesource.com/chromium/src/+/9306606fbbd1ebf51cfe23ea6bcfa19a1ff43363/ui/events/cocoa/events_mac.mm#158 |
| 39 | /// - https://github.com/flutter/engine/blob/cc925b0021330759e18960e1ccbd7e55dec3c375/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm#L768-L775. |
| 40 | /// |
| 41 | /// TODO: currently, this constant reflects the value that makes sense for MacOS (cocoa) scroll events. |
| 42 | /// Ideally, we should hide this implementation detail at the platform level and have consumers |
| 43 | /// solely operate with pixel-based scroll events. |
| 44 | const NUM_PIXELS_PER_LINE: Pixels = Pixels::new(40.); |
| 45 | |
| 46 | #[derive(Clone, Default)] |
| 47 | pub struct ScrollState { |
| 48 | pub started: Option<f32>, |
| 49 | pub hovered: bool, |
| 50 | pub child_hovered: bool, |
| 51 | } |
| 52 | |
| 53 | pub type ScrollStateHandle = Arc<Mutex<ScrollState>>; |
| 54 | |
| 55 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 56 | pub struct ScrollData { |
| 57 | /// The number of pixels that the child element has been scrolled from its start. |
| 58 | /// For a vertically scrollable element, this is equivalent to |
| 59 | /// [`scrollTop`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTop). |
| 60 | /// For a horizontally scrollable element, this is equivalent to |
| 61 | /// [`scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft). |
| 62 | pub scroll_start: Pixels, |
| 63 | |
| 64 | /// The number of pixels of the child element that are visible in the currently scrolled region. |
| 65 | pub visible_px: Pixels, |
| 66 | |
| 67 | /// The size of the scrollable element's content. |
| 68 | /// This is not necessarily the child element's size (e.g. if the child is viewported). |
| 69 | /// For a vertically scrollable element, this is equivalent to |
| 70 | /// [`scrollHeight`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight). |
| 71 | /// For a horizontally scrollable element, this is equivalent to |
| 72 | /// [`scrollWidth`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollWidth). |
| 73 | pub total_size: Pixels, |
| 74 | } |
| 75 | |
| 76 | pub trait ScrollableElement: Element { |
| 77 | /// Returns scrolling data that the child computes and that the [`Scrollable`] |
| 78 | /// uses to update its internal state. If the child is scrollable |
| 79 | /// (i.e. the child has been laid out), this must be [`Some`]. |
| 80 | fn scroll_data(&self, app: &AppContext) -> Option<ScrollData>; |
| 81 | |
| 82 | /// Scrolls the element by the given `delta` (in pixels). |
| 83 | fn scroll(&mut self, delta: Pixels, ctx: &mut EventContext); |
| 84 | |
| 85 | /// By default, scrollable elements are responsible for their own wheel handling. |
| 86 | /// Override to return true if you want the parent scrollable to handle the wheel. |
| 87 | fn should_handle_scroll_wheel(&self) -> bool { |
| 88 | false |
| 89 | } |
| 90 | |
| 91 | fn finish_scrollable(self) -> Box<dyn ScrollableElement> |
| 92 | where |
| 93 | Self: 'static + Sized, |
| 94 | { |
| 95 | Box::new(self) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /// An enum inspired by scrollbar-width css property. |
| 100 | /// It includes 2 basic sizes. |
| 101 | /// |
| 102 | /// See [mdn](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width). |
| 103 | /// |
| 104 | /// # Examples |
| 105 | /// ``` |
| 106 | /// use strato_ui_core::elements::ScrollbarWidth; |
| 107 | /// |
| 108 | /// // Default width of 8. |
| 109 | /// let y = ScrollbarWidth::Auto; |
| 110 | /// |
| 111 | /// // Width of 0. to make the scrollbar invisible |
| 112 | /// let z = ScrollbarWidth::None; |
| 113 | /// ``` |
| 114 | #[derive(Default, Clone, Copy, Debug)] |
| 115 | pub enum ScrollbarWidth { |
| 116 | #[default] |
| 117 | Auto, |
| 118 | None, |
| 119 | Custom(f32), |
| 120 | } |
| 121 | |
| 122 | impl ScrollbarWidth { |
| 123 | pub const fn as_f32(&self) -> f32 { |
| 124 | match *self { |
| 125 | ScrollbarWidth::Auto => 8., |
| 126 | ScrollbarWidth::None => 0., |
| 127 | ScrollbarWidth::Custom(width) => width, |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /// A generic element to handle scrolling of an underlying element. |
| 133 | /// Delegates to the underlying child element to update child-specific |
| 134 | /// scrolling parameters. |
| 135 | /// |
| 136 | /// Supports both vertical and horizontal scrolling via the [`Scrollable::vertical`] |
| 137 | /// and [`Scrollable::horizontal`] APIs, respectively. |
| 138 | pub struct Scrollable { |
| 139 | axis: Axis, |
| 140 | child: Box<dyn ScrollableElement>, |
| 141 | state: ScrollStateHandle, |
| 142 | origin: Option<Point>, |
| 143 | |
| 144 | /// The size of the [`Scrollable`], as determined during layout. |
| 145 | scrollable_size: Option<Vector2F>, |
| 146 | |
| 147 | /// The color of the scrollbar thumb when not hovered/active. |
| 148 | nonactive_scrollbar_thumb_background: Fill, |
| 149 | /// The color of the scrollbar thumb when hovered/active. |
| 150 | active_scrollbar_thumb_background: Fill, |
| 151 | /// The color of the scrollbar track. |
| 152 | scrollbar_track_background: Fill, |
| 153 | |
| 154 | /// The size of the scrollbar in pixels. |
| 155 | scrollbar_size: ScrollbarWidth, |
| 156 | /// The bounds of the whole scrollbar gutter. |
| 157 | scrollbar_track_bounds: Option<RectF>, |
| 158 | /// The relative position of the thumb within the scrollbar. |
| 159 | scrollbar_position_percentage: Option<f32>, |
| 160 | /// The relative height of the thumb compared to the whole scrollbar. |
| 161 | scrollbar_size_percentage: Option<f32>, |
| 162 | /// The bounds for the scrollbar thumb. |
| 163 | scrollbar_thumb_bounds: Option<RectF>, |
| 164 | /// The origin for the scrollbar thumb. |
| 165 | scrollbar_thumb_origin: Option<Vector2F>, |
| 166 | /// Padding between child element and the scrollbar. |
| 167 | padding_between_child_and_scrollbar: f32, |
| 168 | /// Padding after the scrollbar. |
| 169 | padding_after_scrollbar: f32, |
| 170 | |
| 171 | // This is a short-term solution for properly handling events on stacks. A stack will always |
| 172 | // put its children on higher z-indexes than its origin, so a hit test using the standard |
| 173 | // `z_index` method would always result in the event being covered (by the children of the |
| 174 | // stack). Instead, we track the upper-bound of z-indexes _contained by_ the child element. |
| 175 | // Then we use that upper bound to do the hit testing, which means a parent will always get |
| 176 | // events from its children, regardless of whether they are stacks or not. |
| 177 | child_max_z_index: Option<ZIndex>, |
| 178 | |
| 179 | // The scrollbar is the runway for the draggable scrollbar. By default the scollbox renders to |
| 180 | // the side of the child element. This setting makes the scrollbar render over the child instead. |
| 181 | overlayed_scrollbar: bool, |
| 182 | } |
| 183 | |
| 184 | impl Scrollable { |
| 185 | #[allow(clippy::too_many_arguments)] |
| 186 | fn new( |
| 187 | axis: Axis, |
| 188 | state: ScrollStateHandle, |
| 189 | child: Box<dyn ScrollableElement>, |
| 190 | scrollbar_size: ScrollbarWidth, |
| 191 | nonactive_scrollbar_thumb_background: Fill, |
| 192 | active_scrollbar_thumb_background: Fill, |
| 193 | scrollbar_track_background: Fill, |
| 194 | ) -> Self { |
| 195 | Self { |
| 196 | axis, |
| 197 | child, |
| 198 | scrollbar_size, |
| 199 | nonactive_scrollbar_thumb_background, |
| 200 | active_scrollbar_thumb_background, |
| 201 | scrollbar_track_background, |
| 202 | state, |
| 203 | origin: None, |
| 204 | scrollable_size: None, |
| 205 | scrollbar_track_bounds: None, |
| 206 | scrollbar_position_percentage: None, |
| 207 | scrollbar_size_percentage: None, |
| 208 | scrollbar_thumb_bounds: None, |
| 209 | scrollbar_thumb_origin: None, |
| 210 | padding_between_child_and_scrollbar: LEFT_PADDING, |
| 211 | padding_after_scrollbar: RIGHT_PADDING, |
| 212 | child_max_z_index: None, |
| 213 | overlayed_scrollbar: false, |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | /// Creates a vertically scrollable element. |
| 218 | #[allow(clippy::too_many_arguments)] |
| 219 | pub fn vertical( |
| 220 | state: ScrollStateHandle, |
| 221 | child: Box<dyn ScrollableElement>, |
| 222 | scrollbar_size: ScrollbarWidth, |
| 223 | nonactive_scrollbar_thumb_background: Fill, |
| 224 | active_scrollbar_thumb_background: Fill, |
| 225 | scrollbar_track_background: Fill, |
| 226 | ) -> Self { |
| 227 | Self::new( |
| 228 | Axis::Vertical, |
| 229 | state, |
| 230 | child, |
| 231 | scrollbar_size, |
| 232 | nonactive_scrollbar_thumb_background, |
| 233 | active_scrollbar_thumb_background, |
| 234 | scrollbar_track_background, |
| 235 | ) |
| 236 | } |
| 237 | |
| 238 | /// Creates a horizontally scrollable element. |
| 239 | #[allow(clippy::too_many_arguments)] |
| 240 | pub fn horizontal( |
| 241 | state: ScrollStateHandle, |
| 242 | child: Box<dyn ScrollableElement>, |
| 243 | scrollbar_size: ScrollbarWidth, |
| 244 | nonactive_scrollbar_thumb_background: Fill, |
| 245 | active_scrollbar_thumb_background: Fill, |
| 246 | scrollbar_track_background: Fill, |
| 247 | ) -> Self { |
| 248 | Self::new( |
| 249 | Axis::Horizontal, |
| 250 | state, |
| 251 | child, |
| 252 | scrollbar_size, |
| 253 | nonactive_scrollbar_thumb_background, |
| 254 | active_scrollbar_thumb_background, |
| 255 | scrollbar_track_background, |
| 256 | ) |
| 257 | } |
| 258 | |
| 259 | /// Sets the padding between the child element and the scrollbar. |
| 260 | pub fn with_padding_start(mut self, padding_start: f32) -> Self { |
| 261 | self.padding_between_child_and_scrollbar = padding_start; |
| 262 | self |
| 263 | } |
| 264 | |
| 265 | /// Sets the padding after the scrollbar. |
| 266 | pub fn with_padding_end(mut self, padding_end: f32) -> Self { |
| 267 | self.padding_after_scrollbar = padding_end; |
| 268 | self |
| 269 | } |
| 270 | |
| 271 | pub fn with_overlayed_scrollbar(mut self) -> Self { |
| 272 | self.overlayed_scrollbar = true; |
| 273 | self |
| 274 | } |
| 275 | |
| 276 | fn state(&mut self) -> MutexGuard<'_, ScrollState> { |
| 277 | self.state.lock().unwrap() |
| 278 | } |
| 279 | |
| 280 | fn mouse_dragged(&mut self, position: Vector2F, ctx: &mut EventContext, app: &AppContext) { |
| 281 | let previous_dragging_position = self.state().started; |
| 282 | if let Some(previous_dragging_position) = previous_dragging_position { |
| 283 | let position_along_axis = position.along(self.axis); |
| 284 | self.start_scrolling(position); |
| 285 | self.jump_to_position( |
| 286 | previous_dragging_position.into_pixels(), |
| 287 | position_along_axis.into_pixels(), |
| 288 | ctx, |
| 289 | app, |
| 290 | ); |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | fn jump_to_position( |
| 295 | &mut self, |
| 296 | previous_position_along_axis: Pixels, |
| 297 | new_position_along_axis: Pixels, |
| 298 | ctx: &mut EventContext, |
| 299 | app: &AppContext, |
| 300 | ) { |
| 301 | let total_size = self.total_size(app); |
| 302 | let scroll_start = self.scroll_start(app); |
| 303 | let scroll_remaining = self.scroll_remaining(app); |
| 304 | |
| 305 | // We need to use the original scrollbar size before resizing to calculate the scroll speed. |
| 306 | let scrollbar_size_percentage_before_resize = |
| 307 | (total_size - scroll_start - scroll_remaining) / total_size; |
| 308 | |
| 309 | // We don't want to update the scroll position if you're scrolled to the top and the cursor is above |
| 310 | // the element or if you're scrolled to the bottom and the cursor is below the element. |
| 311 | // TODO(kevin): Do we need the scroll_start <= 0 check? |
| 312 | if (scroll_remaining <= Pixels::zero() |
| 313 | && new_position_along_axis > previous_position_along_axis) |
| 314 | || (scroll_start <= Pixels::zero() |
| 315 | && previous_position_along_axis > new_position_along_axis) |
| 316 | { |
| 317 | return; |
| 318 | } |
| 319 | |
| 320 | let delta = previous_position_along_axis - new_position_along_axis; |
| 321 | |
| 322 | // The scroll speed should be proportional to the total number of lines. |
| 323 | // Assume we have moved the scrollbar by a distance x, the number of lines scrolled |
| 324 | // should be calculated by x / total_height * total_number_of_lines. |
| 325 | self.child |
| 326 | .scroll(delta / scrollbar_size_percentage_before_resize, ctx); |
| 327 | } |
| 328 | |
| 329 | fn mousewheel(&mut self, delta: Vector2F, precise: bool, ctx: &mut EventContext) { |
| 330 | if self |
| 331 | .scrollbar_size_percentage |
| 332 | .expect("should be set at event dispatching time") |
| 333 | < 1. |
| 334 | { |
| 335 | let delta_along_axis = delta.along(self.axis); |
| 336 | if precise { |
| 337 | self.child.scroll(delta_along_axis.into_pixels(), ctx); |
| 338 | } else { |
| 339 | // If the scroll was not `precise`, we need to convert the delta (which is |
| 340 | // actually in terms of `Lines`) to the right number of `Pixels`. |
| 341 | // See the comment on [`SCROLLBAR_PIXELS_PER_COCOA_TICK`] for more details. |
| 342 | self.child.scroll( |
| 343 | (delta_along_axis * NUM_PIXELS_PER_LINE.as_f32()).into_pixels(), |
| 344 | ctx, |
| 345 | ); |
| 346 | } |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | /// Returns the child's [`ScrollData`], assuming the child has been laid out. |
| 351 | fn scroll_data(&self, app: &AppContext) -> ScrollData { |
| 352 | self.child |
| 353 | .scroll_data(app) |
| 354 | .expect("ScrollData should be some to be scrollable") |
| 355 | } |
| 356 | |
| 357 | fn scroll_start(&self, app: &AppContext) -> Pixels { |
| 358 | self.scroll_data(app).scroll_start |
| 359 | } |
| 360 | |
| 361 | /// The number of pixels that the child is still scrollable (biased towards its end). |
| 362 | /// For example, for a vertically scrollable element, this would be the number of pixels |
| 363 | /// that the child can still be scrolled down. |
| 364 | fn scroll_remaining(&self, app: &AppContext) -> Pixels { |
| 365 | let scroll_data = self.scroll_data(app); |
| 366 | scroll_data.total_size - scroll_data.scroll_start - scroll_data.visible_px |
| 367 | } |
| 368 | |
| 369 | fn total_size(&self, app: &AppContext) -> Pixels { |
| 370 | self.scroll_data(app).total_size |
| 371 | } |
| 372 | |
| 373 | fn start_scrolling(&mut self, position: Vector2F) { |
| 374 | self.state().started = Some(position.along(self.axis)); |
| 375 | } |
| 376 | |
| 377 | fn end_scrolling(&mut self) { |
| 378 | self.state().started = None |
| 379 | } |
| 380 | |
| 381 | /// Returns the `original_size` that has its inverted axis dimension changed to `dimension_along_inverted_axis`. |
| 382 | fn size_along_inverted_axis( |
| 383 | &self, |
| 384 | original_size: Vector2F, |
| 385 | dimension_along_inverted_axis: f32, |
| 386 | ) -> Vector2F { |
| 387 | match self.axis { |
| 388 | Axis::Horizontal => vec2f(original_size.x(), dimension_along_inverted_axis), |
| 389 | Axis::Vertical => vec2f(dimension_along_inverted_axis, original_size.y()), |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | impl Element for Scrollable { |
| 395 | fn layout( |
| 396 | &mut self, |
| 397 | constraint: SizeConstraint, |
| 398 | ctx: &mut LayoutContext, |
| 399 | app: &AppContext, |
| 400 | ) -> Vector2F { |
| 401 | let scrollbar_size = self.scrollbar_size.as_f32().along(self.axis.invert()); |
| 402 | let padding = (self.padding_between_child_and_scrollbar + self.padding_after_scrollbar) |
| 403 | .along(self.axis.invert()); |
| 404 | |
| 405 | let child_constraint = if self.overlayed_scrollbar { |
| 406 | // If the scrollbar is overlayed, the child can span the entire constraint. |
| 407 | SizeConstraint { |
| 408 | min: constraint.min.max(Vector2F::zero()), |
| 409 | max: constraint.max.max(Vector2F::zero()), |
| 410 | } |
| 411 | } else { |
| 412 | // If the scrollbar is not overlayed, we must save room for the scrollbar. |
| 413 | SizeConstraint { |
| 414 | min: (constraint.min - scrollbar_size - padding).max(Vector2F::zero()), |
| 415 | max: (constraint.max - scrollbar_size - padding).max(Vector2F::zero()), |
| 416 | } |
| 417 | }; |
| 418 | |
| 419 | let child_size = self.child.layout(child_constraint, ctx, app); |
| 420 | debug_assert!( |
| 421 | child_size.y().is_finite(), |
| 422 | "Scrollable's child should not have infinite height" |
| 423 | ); |
| 424 | debug_assert!( |
| 425 | child_size.x().is_finite(), |
| 426 | "Scrollable's child should not have infinite width" |
| 427 | ); |
| 428 | |
| 429 | // If the scrollbar is not overlayed, we add back its size to get the overall size |
| 430 | // of the scrollable element. |
| 431 | let size = if self.overlayed_scrollbar { |
| 432 | child_size |
| 433 | } else { |
| 434 | child_size + scrollbar_size + padding |
| 435 | }; |
| 436 | |
| 437 | self.scrollable_size = Some(size); |
| 438 | size |
| 439 | } |
| 440 | |
| 441 | fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) { |
| 442 | self.child.after_layout(ctx, app); |
| 443 | |
| 444 | let scroll_data = self.scroll_data(app); |
| 445 | let total_size = scroll_data.total_size; |
| 446 | |
| 447 | let minimum_size_percentage = |
| 448 | (MINIMUM_HEIGHT / self.scrollable_size.unwrap().along(self.axis)).min(1.); |
| 449 | let size_percentage = |
| 450 | (scroll_data.visible_px / total_size).max(minimum_size_percentage.into_pixels()); |
| 451 | |
| 452 | self.scrollbar_size_percentage = Some(size_percentage.as_f32()); |
| 453 | |
| 454 | // The scrollbar position is calculated with the ratio between scroll top and scroll bottom. |
| 455 | let scroll_start = self.scroll_start(app); |
| 456 | let scroll_remaining = self.scroll_remaining(app); |
| 457 | |
| 458 | let scrollbar_position_percentage = scroll_start / (scroll_start + scroll_remaining); |
| 459 | self.scrollbar_position_percentage = Some(scrollbar_position_percentage.as_f32()); |
| 460 | } |
| 461 | |
| 462 | fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) { |
| 463 | self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index())); |
| 464 | self.child.paint(origin, ctx, app); |
| 465 | let scrollable_size = self |
| 466 | .scrollable_size |
| 467 | .expect("size should have been set during layout"); |
| 468 | |
| 469 | // The origin of the scrollbar track is the maximum coordinate (along the inverted axis) |
| 470 | // subtracted by the size of the scrollbar. For example, for a vertically scrollable element, |
| 471 | // the origin will be the maximum x coordinate subtracted by the size of the scrollbar. |
| 472 | let scrollbar_track_length = self.scrollbar_size.as_f32() |
| 473 | + self.padding_between_child_and_scrollbar |
| 474 | + self.padding_after_scrollbar; |
| 475 | let scrollbar_track_origin = origin + scrollable_size.project_onto(self.axis.invert()) |
| 476 | - scrollbar_track_length.along(self.axis.invert()); |
| 477 | let scrollbar_track_size = |
| 478 | self.size_along_inverted_axis(scrollable_size, scrollbar_track_length); |
| 479 | |
| 480 | let scrollbar_track_bounds = RectF::new(scrollbar_track_origin, scrollbar_track_size); |
| 481 | self.scrollbar_track_bounds = Some(scrollbar_track_bounds); |
| 482 | |
| 483 | // If the scrollbar is overlayed over the child, it should be at a higher z-index. |
| 484 | if self.overlayed_scrollbar { |
| 485 | ctx.scene |
| 486 | .start_layer(ClipBounds::BoundedBy(scrollbar_track_bounds)); |
| 487 | } |
| 488 | let scrollbar = ctx |
| 489 | .scene |
| 490 | .draw_rect_with_hit_recording(scrollbar_track_bounds); |
| 491 | |
| 492 | // If the scrollbar is overlayed, make it transparent. If neither the scrollbar nor the child |
| 493 | // is hovered, make it have no fill. |
| 494 | if !self.state().hovered && !self.state().child_hovered { |
| 495 | scrollbar.with_background(Fill::None); |
| 496 | } else if self.overlayed_scrollbar { |
| 497 | scrollbar.with_background(Fill::Solid(ColorU::transparent_black())); |
| 498 | } else { |
| 499 | scrollbar.with_background(self.scrollbar_track_background); |
| 500 | } |
| 501 | |
| 502 | let scrollbar_size_percentage = self.scrollbar_size_percentage.unwrap(); |
| 503 | let scrollbar_position_percentage = self.scrollbar_position_percentage.unwrap(); |
| 504 | if scrollbar_size_percentage < 1. { |
| 505 | let scrollbar_thumb_size = self.size_along_inverted_axis( |
| 506 | scrollable_size * scrollbar_size_percentage, |
| 507 | self.scrollbar_size.as_f32(), |
| 508 | ); |
| 509 | let scrollbar_thumb_origin = scrollbar_track_origin |
| 510 | + self.size_along_inverted_axis( |
| 511 | (scrollable_size - scrollbar_thumb_size) * scrollbar_position_percentage, |
| 512 | self.padding_between_child_and_scrollbar, |
| 513 | ); |
| 514 | |
| 515 | self.scrollbar_thumb_bounds = |
| 516 | Some(RectF::new(scrollbar_thumb_origin, scrollbar_thumb_size)); |
| 517 | self.scrollbar_thumb_origin = Some(scrollbar_thumb_origin); |
| 518 | |
| 519 | let hovered = self.state().hovered; |
| 520 | let child_hovered = self.state().child_hovered; |
| 521 | let background = if hovered { |
| 522 | self.active_scrollbar_thumb_background |
| 523 | } else if child_hovered { |
| 524 | self.nonactive_scrollbar_thumb_background |
| 525 | } else { |
| 526 | Fill::None |
| 527 | }; |
| 528 | |
| 529 | ctx.scene |
| 530 | .draw_rect_with_hit_recording(RectF::new( |
| 531 | scrollbar_thumb_origin, |
| 532 | scrollbar_thumb_size, |
| 533 | )) |
| 534 | .with_background(background) |
| 535 | .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))); |
| 536 | } else { |
| 537 | self.scrollbar_thumb_origin = Some(origin); |
| 538 | self.scrollbar_thumb_bounds = Some(RectF::new(vec2f(0., 0.), vec2f(0., 0.))); |
| 539 | } |
| 540 | |
| 541 | // See comment above about the layering of the scrollbar and scrollbar. |
| 542 | if self.overlayed_scrollbar { |
| 543 | ctx.scene.stop_layer(); |
| 544 | } |
| 545 | |
| 546 | self.child_max_z_index = Some(ctx.scene.max_active_z_index()); |
| 547 | } |
| 548 | |
| 549 | fn dispatch_event( |
| 550 | &mut self, |
| 551 | event: &DispatchedEvent, |
| 552 | ctx: &mut EventContext, |
| 553 | app: &AppContext, |
| 554 | ) -> bool { |
| 555 | let handled = self.child.dispatch_event(event, ctx, app); |
| 556 | let z_index = *self.child_max_z_index.as_ref().unwrap(); |
| 557 | |
| 558 | match event.raw_event() { |
| 559 | Event::LeftMouseDragged { position, .. } => { |
| 560 | let is_dragging = self.state().started.is_some(); |
| 561 | if !is_dragging { |
| 562 | return handled; |
| 563 | } |
| 564 | self.mouse_dragged(*position, ctx, app); |
| 565 | true |
| 566 | } |
| 567 | Event::LeftMouseDown { position, .. } => { |
| 568 | if ctx.is_covered(Point::from_vec2f(*position, z_index)) { |
| 569 | return handled; |
| 570 | } |
| 571 | |
| 572 | let Some(thumb_bounds) = self.scrollbar_thumb_bounds else { |
| 573 | log::warn!( |
| 574 | "Expected scrollbar thumb bounds to exist in dispatch_event, but got None" |
| 575 | ); |
| 576 | return handled; |
| 577 | }; |
| 578 | |
| 579 | if thumb_bounds.contains_point(*position) { |
| 580 | self.start_scrolling(*position); |
| 581 | |
| 582 | // Dispatch an action in tests so we can perform assertions |
| 583 | // on clicks. |
| 584 | #[cfg(test)] |
| 585 | ctx.dispatch_action("scrollable_click::on_thumb", ()); |
| 586 | |
| 587 | true |
| 588 | } else if self |
| 589 | .scrollbar_track_bounds |
| 590 | .is_some_and(|bounds| bounds.contains_point(*position)) |
| 591 | { |
| 592 | // If mouse down happens in the x range of scrollbar but not on the thumb, |
| 593 | // we should scroll to the mouse down position. |
| 594 | let previous_position = thumb_bounds.center().along(self.axis); |
| 595 | self.jump_to_position( |
| 596 | previous_position.into_pixels(), |
| 597 | position.along(self.axis).into_pixels(), |
| 598 | ctx, |
| 599 | app, |
| 600 | ); |
| 601 | |
| 602 | // Dispatch an action in tests so we can perform assertions |
| 603 | // on clicks. |
| 604 | #[cfg(test)] |
| 605 | ctx.dispatch_action("scrollable_click::on_gutter", ()); |
| 606 | |
| 607 | true |
| 608 | } else { |
| 609 | handled |
| 610 | } |
| 611 | } |
| 612 | Event::LeftMouseUp { .. } => { |
| 613 | let previous_dragging_position = self.state().started; |
| 614 | if previous_dragging_position.is_some() { |
| 615 | self.end_scrolling(); |
| 616 | true |
| 617 | } else { |
| 618 | handled |
| 619 | } |
| 620 | } |
| 621 | Event::MouseMoved { position, .. } => { |
| 622 | let is_dragging = self.state().started.is_some(); |
| 623 | |
| 624 | if is_dragging { |
| 625 | return handled; |
| 626 | } |
| 627 | let is_covered = ctx.is_covered(Point::from_vec2f(*position, z_index)); |
| 628 | |
| 629 | let mouse_in = self |
| 630 | .scrollbar_thumb_bounds |
| 631 | .unwrap() |
| 632 | .contains_point(*position) |
| 633 | && !is_covered; |
| 634 | let was_hovered = mem::replace(&mut self.state().hovered, mouse_in); |
| 635 | |
| 636 | let mouse_in_child = self |
| 637 | .child |
| 638 | .bounds() |
| 639 | .unwrap_or_default() |
| 640 | .contains_point(*position) |
| 641 | && !is_covered; |
| 642 | let child_was_hovered = |
| 643 | mem::replace(&mut self.state().child_hovered, mouse_in_child); |
| 644 | |
| 645 | if was_hovered != mouse_in || child_was_hovered != mouse_in_child { |
| 646 | ctx.notify(); |
| 647 | } |
| 648 | |
| 649 | if mouse_in { |
| 650 | true |
| 651 | } else { |
| 652 | handled |
| 653 | } |
| 654 | } |
| 655 | Event::ScrollWheel { |
| 656 | position, |
| 657 | delta, |
| 658 | precise, |
| 659 | modifiers: ModifiersState { ctrl: false, .. }, |
| 660 | } => { |
| 661 | if !self.child.should_handle_scroll_wheel() { |
| 662 | return handled; |
| 663 | } |
| 664 | |
| 665 | if self.bounds().unwrap().contains_point(*position) |
| 666 | && !ctx.is_covered(Point::from_vec2f(*position, z_index)) |
| 667 | { |
| 668 | self.mousewheel(*delta, *precise, ctx); |
| 669 | return true; |
| 670 | } |
| 671 | handled |
| 672 | } |
| 673 | _ => handled, |
| 674 | } |
| 675 | } |
| 676 | |
| 677 | fn size(&self) -> Option<Vector2F> { |
| 678 | self.scrollable_size |
| 679 | } |
| 680 | |
| 681 | fn origin(&self) -> Option<Point> { |
| 682 | self.origin |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | #[cfg(test)] |
| 687 | #[path = "scrollable_test.rs"] |
| 688 | mod tests; |
| 689 |