use std::cell::Cell; use std::ops::Deref; use std::panic::Location; use std::rc::Rc; use std::time::{Duration, Instant}; use gpui::{ App, Axis, BorderStyle, Bounds, ContentMask, Corner, CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill, point, px, relative, size, }; use theme::{ActiveTheme, ScrollbarMode}; use crate::AxisExt; /// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) const WIDTH: Pixels = px(1. * 2. + 8.); const MIN_THUMB_SIZE: f32 = 48.; const THUMB_WIDTH: Pixels = px(6.); const THUMB_RADIUS: Pixels = px(6. / 2.); const THUMB_INSET: Pixels = px(1.); const THUMB_ACTIVE_WIDTH: Pixels = px(8.); const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.); const THUMB_ACTIVE_INSET: Pixels = px(1.); const FADE_OUT_DURATION: f32 = 3.0; const FADE_OUT_DELAY: f32 = 2.0; /// A trait for scroll handles that can get and set offset. pub trait ScrollbarHandle: 'static { /// Get the current offset of the scroll handle. fn offset(&self) -> Point; /// Set the offset of the scroll handle. fn set_offset(&self, offset: Point); /// The full size of the content, including padding. fn content_size(&self) -> Size; /// Called when start dragging the scrollbar thumb. fn start_drag(&self) {} /// Called when end dragging the scrollbar thumb. fn end_drag(&self) {} } impl ScrollbarHandle for ScrollHandle { fn offset(&self) -> Point { self.offset() } fn set_offset(&self, offset: Point) { self.set_offset(offset); } fn content_size(&self) -> Size { self.max_offset() + self.bounds().size } } impl ScrollbarHandle for UniformListScrollHandle { fn offset(&self) -> Point { self.0.borrow().base_handle.offset() } fn set_offset(&self, offset: Point) { self.0.borrow_mut().base_handle.set_offset(offset) } fn content_size(&self) -> Size { let base_handle = &self.0.borrow().base_handle; base_handle.max_offset() + base_handle.bounds().size } } impl ScrollbarHandle for ListState { fn offset(&self) -> Point { self.scroll_px_offset_for_scrollbar() } fn set_offset(&self, offset: Point) { self.set_offset_from_scrollbar(offset); } fn content_size(&self) -> Size { self.viewport_bounds().size + self.max_offset_for_scrollbar() } fn start_drag(&self) { self.scrollbar_drag_started(); } fn end_drag(&self) { self.scrollbar_drag_ended(); } } #[doc(hidden)] #[derive(Debug, Clone)] struct ScrollbarState(Rc>); #[doc(hidden)] #[derive(Debug, Clone, Copy)] struct ScrollbarStateInner { hovered_axis: Option, hovered_on_thumb: Option, dragged_axis: Option, drag_pos: Point, last_scroll_offset: Point, last_scroll_time: Option, // Last update offset last_update: Instant, idle_timer_scheduled: bool, } impl Default for ScrollbarState { fn default() -> Self { Self(Rc::new(Cell::new(ScrollbarStateInner { hovered_axis: None, hovered_on_thumb: None, dragged_axis: None, drag_pos: point(px(0.), px(0.)), last_scroll_offset: point(px(0.), px(0.)), last_scroll_time: None, last_update: Instant::now(), idle_timer_scheduled: false, }))) } } impl Deref for ScrollbarState { type Target = Rc>; fn deref(&self) -> &Self::Target { &self.0 } } impl ScrollbarStateInner { fn with_drag_pos(&self, axis: Axis, pos: Point) -> Self { let mut state = *self; if axis.is_vertical() { state.drag_pos.y = pos.y; } else { state.drag_pos.x = pos.x; } state.dragged_axis = Some(axis); state } fn with_unset_drag_pos(&self) -> Self { let mut state = *self; state.dragged_axis = None; state } fn with_hovered(&self, axis: Option) -> Self { let mut state = *self; state.hovered_axis = axis; if axis.is_some() { state.last_scroll_time = Some(std::time::Instant::now()); } state } fn with_hovered_on_thumb(&self, axis: Option) -> Self { let mut state = *self; state.hovered_on_thumb = axis; if self.is_scrollbar_visible() && axis.is_some() { state.last_scroll_time = Some(std::time::Instant::now()); } state } fn with_last_scroll( &self, last_scroll_offset: Point, last_scroll_time: Option, ) -> Self { let mut state = *self; state.last_scroll_offset = last_scroll_offset; state.last_scroll_time = last_scroll_time; state } fn with_last_scroll_time(&self, t: Option) -> Self { let mut state = *self; state.last_scroll_time = t; state } fn with_last_update(&self, t: Instant) -> Self { let mut state = *self; state.last_update = t; state } fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self { let mut state = *self; state.idle_timer_scheduled = scheduled; state } fn is_scrollbar_visible(&self) -> bool { // On drag if self.dragged_axis.is_some() { return true; } if let Some(last_time) = self.last_scroll_time { let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); elapsed < FADE_OUT_DURATION } else { false } } } /// Scrollbar axis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScrollbarAxis { /// Vertical scrollbar. Vertical, /// Horizontal scrollbar. Horizontal, /// Show both vertical and horizontal scrollbars. Both, } impl From for ScrollbarAxis { fn from(axis: Axis) -> Self { match axis { Axis::Vertical => Self::Vertical, Axis::Horizontal => Self::Horizontal, } } } impl ScrollbarAxis { /// Return true if the scrollbar axis is vertical. #[inline] pub fn is_vertical(&self) -> bool { matches!(self, Self::Vertical) } /// Return true if the scrollbar axis is horizontal. #[inline] pub fn is_horizontal(&self) -> bool { matches!(self, Self::Horizontal) } /// Return true if the scrollbar axis is both vertical and horizontal. #[inline] pub fn is_both(&self) -> bool { matches!(self, Self::Both) } /// Return true if the scrollbar has vertical axis. #[inline] pub fn has_vertical(&self) -> bool { matches!(self, Self::Vertical | Self::Both) } /// Return true if the scrollbar has horizontal axis. #[inline] pub fn has_horizontal(&self) -> bool { matches!(self, Self::Horizontal | Self::Both) } #[inline] fn all(&self) -> Vec { match self { Self::Vertical => vec![Axis::Vertical], Self::Horizontal => vec![Axis::Horizontal], // This should keep Horizontal first, Vertical is the primary axis // if Vertical not need display, then Horizontal will not keep right margin. Self::Both => vec![Axis::Horizontal, Axis::Vertical], } } } /// Scrollbar control for scroll-area or a uniform-list. pub struct Scrollbar { pub(crate) id: ElementId, axis: ScrollbarAxis, scrollbar_mode: Option, scroll_handle: Rc, scroll_size: Option>, /// Maximum frames per second for scrolling by drag. Default is 120 FPS. /// /// This is used to limit the update rate of the scrollbar when it is /// being dragged for some complex interactions for reducing CPU usage. max_fps: usize, } impl Scrollbar { /// Create a new scrollbar. /// /// This will have both vertical and horizontal scrollbars. #[track_caller] pub fn new(scroll_handle: &H) -> Self { let caller = Location::caller(); Self { id: ElementId::CodeLocation(*caller), axis: ScrollbarAxis::Both, scrollbar_mode: None, scroll_handle: Rc::new(scroll_handle.clone()), max_fps: 120, scroll_size: None, } } /// Create with horizontal scrollbar. #[track_caller] pub fn horizontal(scroll_handle: &H) -> Self { Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal) } /// Create with vertical scrollbar. #[track_caller] pub fn vertical(scroll_handle: &H) -> Self { Self::new(scroll_handle).axis(ScrollbarAxis::Vertical) } /// Set a specific element id, default is the [`Location::caller`]. /// /// NOTE: In most cases, you don't need to set a specific id for scrollbar. pub fn id(mut self, id: impl Into) -> Self { self.id = id.into(); self } /// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`. pub fn scrollbar_mode(mut self, mode: ScrollbarMode) -> Self { self.scrollbar_mode = Some(mode); self } /// Set a special scroll size of the content area, default is None. /// /// Default will sync the `content_size` from `scroll_handle`. pub fn scroll_size(mut self, scroll_size: Size) -> Self { self.scroll_size = Some(scroll_size); self } /// Set scrollbar axis. pub fn axis(mut self, axis: impl Into) -> Self { self.axis = axis.into(); self } /// Set maximum frames per second for scrolling by drag. Default is 120 FPS. /// /// If you have very high CPU usage, consider reducing this value to improve performance. /// /// Available values: 30..120 #[allow(dead_code)] pub(crate) fn max_fps(mut self, max_fps: usize) -> Self { self.max_fps = max_fps.clamp(30, 120); self } // Get the width of the scrollbar. #[allow(dead_code)] pub(crate) const fn width() -> Pixels { WIDTH } fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_hover_background, cx.theme().scrollbar_thumb_background, cx.theme().scrollbar_thumb_border, THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS, ) } fn style_for_hovered_thumb(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_hover_background, cx.theme().scrollbar_thumb_background, cx.theme().scrollbar_thumb_border, THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS, ) } fn style_for_hovered_bar(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_background, cx.theme().scrollbar_thumb_border, gpui::transparent_black(), THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS, ) } fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); let (width, inset, radius) = match scrollbar_mode { ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), }; ( cx.theme().scrollbar_thumb_background, cx.theme().scrollbar_track_background, gpui::transparent_black(), width, inset, radius, ) } fn style_for_idle(&self, _cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { let scrollbar_mode = self.scrollbar_mode.unwrap_or(ScrollbarMode::Always); let (width, inset, radius) = match scrollbar_mode { ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), }; ( gpui::transparent_black(), gpui::transparent_black(), gpui::transparent_black(), width, inset, radius, ) } } impl IntoElement for Scrollbar { type Element = Self; fn into_element(self) -> Self::Element { self } } #[doc(hidden)] pub struct PrepaintState { hitbox: Hitbox, scrollbar_state: ScrollbarState, states: Vec, } #[doc(hidden)] pub struct AxisPrepaintState { axis: Axis, bar_hitbox: Hitbox, bounds: Bounds, radius: Pixels, bg: Hsla, border: Hsla, thumb_bounds: Bounds, // Bounds of thumb to be rendered. thumb_fill_bounds: Bounds, thumb_bg: Hsla, scroll_size: Pixels, container_size: Pixels, thumb_size: Pixels, margin_end: Pixels, } impl Element for Scrollbar { type PrepaintState = PrepaintState; type RequestLayoutState = (); fn id(&self) -> Option { Some(self.id.clone()) } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { None } fn request_layout( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let style = Style { position: Position::Absolute, flex_grow: 1.0, flex_shrink: 1.0, size: Size { width: relative(1.).into(), height: relative(1.).into(), }, ..Default::default() }; (window.request_layout(style, None, cx), ()) } fn prepaint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| { window.insert_hitbox(bounds, HitboxBehavior::Normal) }); let state = window .use_state(cx, |_, _| ScrollbarState::default()) .read(cx) .clone(); let mut states = vec![]; let mut has_both = self.axis.is_both(); let scroll_size = self .scroll_size .unwrap_or(self.scroll_handle.content_size()); for axis in self.axis.all().into_iter() { let is_vertical = axis.is_vertical(); let (scroll_area_size, container_size, scroll_position) = if is_vertical { ( scroll_size.height, hitbox.size.height, self.scroll_handle.offset().y, ) } else { ( scroll_size.width, hitbox.size.width, self.scroll_handle.offset().x, ) }; // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. let margin_end = if has_both && !is_vertical { WIDTH } else { px(0.) }; // Hide scrollbar, if the scroll area is smaller than the container. if scroll_area_size <= container_size { has_both = false; continue; } let thumb_length = (container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE)); let thumb_start = -(scroll_position / (scroll_area_size - container_size) * (container_size - margin_end - thumb_length)); let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end); let bounds = Bounds { origin: if is_vertical { point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y) } else { point( hitbox.origin.x, hitbox.origin.y + hitbox.size.height - WIDTH, ) }, size: gpui::Size { width: if is_vertical { WIDTH } else { hitbox.size.width }, height: if is_vertical { hitbox.size.height } else { WIDTH }, }, }; let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); let is_always_to_show = scrollbar_show.is_always(); let is_hover_to_show = scrollbar_show.is_hover(); let is_hovered_on_bar = state.get().hovered_axis == Some(axis); let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis); let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset(); let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) = if state.get().dragged_axis == Some(axis) { Self::style_for_active(cx) } else if is_hover_to_show && (is_hovered_on_bar || is_hovered_on_thumb) { if is_hovered_on_thumb { Self::style_for_hovered_thumb(cx) } else { Self::style_for_hovered_bar(cx) } } else if is_offset_changed { self.style_for_normal(cx) } else if is_always_to_show { if is_hovered_on_thumb { Self::style_for_hovered_thumb(cx) } else { Self::style_for_hovered_bar(cx) } } else { let mut idle_state = self.style_for_idle(cx); // Delay 2s to fade out the scrollbar thumb (in 1s) if let Some(last_time) = state.get().last_scroll_time { let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); if is_hovered_on_bar { state.set(state.get().with_last_scroll_time(Some(Instant::now()))); idle_state = if is_hovered_on_thumb { Self::style_for_hovered_thumb(cx) } else { Self::style_for_hovered_bar(cx) }; } else if elapsed < FADE_OUT_DELAY { idle_state.0 = cx.theme().scrollbar_thumb_background; if !state.get().idle_timer_scheduled { let state = state.clone(); state.set(state.get().with_idle_timer_scheduled(true)); let current_view = window.current_view(); let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed); window .spawn(cx, async move |cx| { cx.background_executor().timer(next_delay).await; state.set(state.get().with_idle_timer_scheduled(false)); cx.update(|_, cx| cx.notify(current_view)).ok(); }) .detach(); } } else if elapsed < FADE_OUT_DURATION { let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10); idle_state.0 = cx.theme().scrollbar_thumb_background.opacity(opacity); window.request_animation_frame(); } } idle_state }; // The clickable area of the thumb let thumb_length = thumb_end - thumb_start - inset * 2; let thumb_bounds = if is_vertical { Bounds::from_corner_and_size( Corner::TopRight, bounds.top_right() + point(-inset, inset + thumb_start), size(WIDTH, thumb_length), ) } else { Bounds::from_corner_and_size( Corner::BottomLeft, bounds.bottom_left() + point(inset + thumb_start, -inset), size(thumb_length, WIDTH), ) }; // The actual render area of the thumb let thumb_fill_bounds = if is_vertical { Bounds::from_corner_and_size( Corner::TopRight, bounds.top_right() + point(-inset, inset + thumb_start), size(thumb_width, thumb_length), ) } else { Bounds::from_corner_and_size( Corner::BottomLeft, bounds.bottom_left() + point(inset + thumb_start, -inset), size(thumb_length, thumb_width), ) }; let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| { window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) }); states.push(AxisPrepaintState { axis, bar_hitbox, bounds, radius, bg: bar_bg, border: bar_border, thumb_bounds, thumb_fill_bounds, thumb_bg, scroll_size: scroll_area_size, container_size, thumb_size: thumb_length, margin_end, }) } PrepaintState { hitbox, states, scrollbar_state: state, } } fn paint( &mut self, _: Option<&GlobalElementId>, _: Option<&InspectorElementId>, _: Bounds, _: &mut Self::RequestLayoutState, prepaint: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { let scrollbar_state = &prepaint.scrollbar_state; let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); let view_id = window.current_view(); let hitbox_bounds = prepaint.hitbox.bounds; let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always(); let is_hover_to_show = scrollbar_show.is_hover(); // Update last_scroll_time when offset is changed. if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset { scrollbar_state.set( scrollbar_state .get() .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())), ); cx.notify(view_id); } window.with_content_mask( Some(ContentMask { bounds: hitbox_bounds, }), |window| { for state in prepaint.states.iter() { let axis = state.axis; let mut radius = state.radius; if cx.theme().radius.is_zero() { radius = px(0.); } let bounds = state.bounds; let thumb_bounds = state.thumb_bounds; let scroll_area_size = state.scroll_size; let container_size = state.container_size; let thumb_size = state.thumb_size; let margin_end = state.margin_end; let is_vertical = axis.is_vertical(); window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox); window.paint_layer(hitbox_bounds, |cx| { cx.paint_quad(fill(state.bounds, state.bg)); cx.paint_quad(PaintQuad { bounds, corner_radii: (0.).into(), background: gpui::transparent_black().into(), border_widths: Edges { top: px(0.), right: px(0.), bottom: px(0.), left: px(0.), }, border_color: state.border, border_style: BorderStyle::default(), }); cx.paint_quad( fill(state.thumb_fill_bounds, state.thumb_bg).corner_radii(radius), ); }); window.on_mouse_event({ let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |event: &ScrollWheelEvent, phase, _, cx| { if phase.bubble() && hitbox_bounds.contains(&event.position) && scroll_handle.offset() != state.get().last_scroll_offset { state.set(state.get().with_last_scroll( scroll_handle.offset(), Some(Instant::now()), )); cx.notify(view_id); } } }); let safe_range = (-scroll_area_size + container_size)..px(0.); if is_hover_to_show || is_visible { window.on_mouse_event({ let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |event: &MouseDownEvent, phase, _, cx| { if phase.bubble() && bounds.contains(&event.position) { cx.stop_propagation(); if thumb_bounds.contains(&event.position) { // click on the thumb bar, set the drag position let pos = event.position - thumb_bounds.origin; scroll_handle.start_drag(); state.set(state.get().with_drag_pos(axis, pos)); cx.notify(view_id); } else { // click on the scrollbar, jump to the position // Set the thumb bar center to the click position let offset = scroll_handle.offset(); let percentage = if is_vertical { (event.position.y - thumb_size / 2. - bounds.origin.y) / (bounds.size.height - thumb_size) } else { (event.position.x - thumb_size / 2. - bounds.origin.x) / (bounds.size.width - thumb_size) } .min(1.); if is_vertical { scroll_handle.set_offset(point( offset.x, (-scroll_area_size * percentage) .clamp(safe_range.start, safe_range.end), )); } else { scroll_handle.set_offset(point( (-scroll_area_size * percentage) .clamp(safe_range.start, safe_range.end), offset.y, )); } } } } }); } window.on_mouse_event({ let scroll_handle = self.scroll_handle.clone(); let state = scrollbar_state.clone(); let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64); move |event: &MouseMoveEvent, _, _, cx| { let mut notify = false; // When is hover to show mode or it was visible, // we need to update the hovered state and increase the last_scroll_time. let need_hover_to_update = is_hover_to_show || is_visible; // Update hovered state for scrollbar if bounds.contains(&event.position) && need_hover_to_update { state.set(state.get().with_hovered(Some(axis))); if state.get().hovered_axis != Some(axis) { notify = true; } } else if state.get().hovered_axis == Some(axis) { state.set(state.get().with_hovered(None)); notify = true; } // Update hovered state for scrollbar thumb if thumb_bounds.contains(&event.position) { if state.get().hovered_on_thumb != Some(axis) { state.set(state.get().with_hovered_on_thumb(Some(axis))); notify = true; } } else if state.get().hovered_on_thumb == Some(axis) { state.set(state.get().with_hovered_on_thumb(None)); notify = true; } // Move thumb position on dragging if state.get().dragged_axis == Some(axis) && event.dragging() { // Stop the event propagation to avoid selecting text or other side effects. cx.stop_propagation(); // drag_pos is the position of the mouse down event // We need to keep the thumb bar still at the origin down position let drag_pos = state.get().drag_pos; let percentage = (if is_vertical { (event.position.y - drag_pos.y - bounds.origin.y) / (bounds.size.height - thumb_size) } else { (event.position.x - drag_pos.x - bounds.origin.x) / (bounds.size.width - thumb_size - margin_end) }) .clamp(0., 1.); let offset = if is_vertical { point( scroll_handle.offset().x, (-(scroll_area_size - container_size) * percentage) .clamp(safe_range.start, safe_range.end), ) } else { point( (-(scroll_area_size - container_size) * percentage) .clamp(safe_range.start, safe_range.end), scroll_handle.offset().y, ) }; if (scroll_handle.offset().y - offset.y).abs() > px(1.) || (scroll_handle.offset().x - offset.x).abs() > px(1.) { // Limit update rate if state.get().last_update.elapsed() > max_fps_duration { scroll_handle.set_offset(offset); state.set(state.get().with_last_update(Instant::now())); notify = true; } } } if notify { cx.notify(view_id); } } }); window.on_mouse_event({ let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |_event: &MouseUpEvent, phase, _, cx| { if phase.bubble() { scroll_handle.end_drag(); state.set(state.get().with_unset_drag_pos()); cx.notify(view_id); } } }); } }, ); } }