Skip to main content

gpui_ai/
scrolling.rs

1//! Scroll behavior primitives: wheel acceleration and middle-click autoscroll.
2//!
3//! These are behaviors, not components: they own no rendering and no theme.
4//! Applications (or composite components) drive them from ordinary GPUI input
5//! events and apply the returned distances to whatever scroll position they
6//! manage. This mirrors how Zed's own editor handles the wheel — discrete
7//! line notches are scaled by a sensitivity multiplier while trackpad pixel
8//! deltas pass through untouched — and adds Windows-convention cadence
9//! acceleration on top for fast successive scrolling.
10//!
11//! # Wheel acceleration
12//!
13//! ```
14//! use gpui_ai::scrolling::{WheelAccelerator, LINE_HEIGHT_PX};
15//! use gpui::{px, Pixels};
16//!
17//! let mut wheel = WheelAccelerator::new();
18//! // One notch of a conventional wheel; negative dy scrolls down.
19//! let boost = wheel.line_notch(-1.0, false);
20//! assert!(boost > px(0.) && boost <= px((3.0 - 1.0) * LINE_HEIGHT_PX));
21//!
22//! // Trackpad pixel deltas never enter the accelerator: the application
23//! // forwards them straight to its scroll position, unaccelerated.
24//! ```
25//!
26//! # Middle-click autoscroll
27//!
28//! ```
29//! use gpui_ai::scrolling::Autoscroll;
30//! use gpui::{point, px, Pixels};
31//!
32//! let mut session = Autoscroll::start(point(px(300.), px(200.)));
33//! session.track(point(px(300.), px(270.)));
34//! // Pointer 70 px below the anchor at ~16 ms/frame:
35//! let distance = session.tick(0.016);
36//! assert!(distance > Pixels::ZERO);
37//! ```
38
39use gpui::{
40    HitboxBehavior, IntoElement as _, ListState, Pixels, ScrollWheelEvent, Styled as _, canvas, px,
41};
42
43/// When a scrollable surface shows its scrollbar.
44///
45/// A reader who cannot tell a surface scrolls will not try to scroll it,
46/// which is what the 0.4.0 feel review found in the tables and the
47/// sidebar. The policy names the three answers so an application chooses
48/// once for the whole library instead of per component.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum ScrollbarVisibility {
51    /// Visible while scrolling, fading out once the surface settles.
52    Scrolling,
53    /// Visible while the pointer is over the surface.
54    Hover,
55    /// Always visible, so a surface that scrolls always says so.
56    #[default]
57    Always,
58}
59
60impl ScrollbarVisibility {
61    fn upstream(self) -> gpui_component::scroll::ScrollbarMode {
62        match self {
63            Self::Scrolling => gpui_component::scroll::ScrollbarMode::Scrolling,
64            Self::Hover => gpui_component::scroll::ScrollbarMode::Hover,
65            Self::Always => gpui_component::scroll::ScrollbarMode::Always,
66        }
67    }
68}
69
70/// Where a scrollbar sits relative to the content it scrolls.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum ScrollbarPlacement {
73    /// Floats over the content's trailing edge, taking no layout space.
74    #[default]
75    Overlay,
76    /// Reserves a gutter, so the bar never covers what it scrolls.
77    Gutter,
78}
79
80/// The crate's scrollbar policy, replaceable before any window renders.
81///
82/// Defaults, then the application's policy, then a component's own
83/// builder — the same order every other policy in the library follows.
84///
85/// # Example
86///
87/// ```no_run
88/// # fn example(cx: &mut gpui::App) {
89/// gpui_ai::init(cx);
90/// gpui_ai::scrolling::ScrollbarTokens::default()
91///     .with_visibility(gpui_ai::scrolling::ScrollbarVisibility::Scrolling)
92///     .set(cx);
93/// # }
94/// ```
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct ScrollbarTokens {
97    visibility: ScrollbarVisibility,
98    placement: ScrollbarPlacement,
99}
100
101impl gpui::Global for ScrollbarTokens {}
102
103impl Default for ScrollbarTokens {
104    fn default() -> Self {
105        Self::DEFAULT
106    }
107}
108
109impl ScrollbarTokens {
110    /// The crate's default scrollbar policy: always visible, overlaid.
111    ///
112    /// Always, because a surface that scrolls should look like one before
113    /// the reader touches it; overlaid, because reserving a gutter
114    /// narrows content that usually does not need it.
115    pub const DEFAULT: Self = Self {
116        visibility: ScrollbarVisibility::Always,
117        placement: ScrollbarPlacement::Overlay,
118    };
119
120    /// The policy the application installed, or the crate's default.
121    pub fn read(cx: &gpui::App) -> Self {
122        cx.try_global::<Self>().copied().unwrap_or(Self::DEFAULT)
123    }
124
125    /// Makes this policy the application's, for every surface at once.
126    ///
127    /// The choice is projected onto the theme's own scrollbar mode, which
128    /// is what every bar resolves from when nothing overrides it — the
129    /// crate's surfaces and the bars upstream draws inside its tables
130    /// alike, and the crate re-projects it when it initializes.
131    pub fn set(self, cx: &mut gpui::App) {
132        cx.set_global(self);
133        self.project_onto_theme(cx);
134    }
135
136    fn project_onto_theme(self, cx: &mut gpui::App) {
137        // The component theme's own field, not the base theme's derived
138        // projection. The base theme's scrollbar block is rebuilt from
139        // this field every time a theme is applied, so writing the derived
140        // value would be erased by the next theme swap — and swapping
141        // themes is exactly what an application does.
142        // An application may choose its policy before the theme exists —
143        // that ordering is the customization contract every policy here
144        // honours. Nothing to project onto yet; `install` runs after the
145        // theme is up and projects whichever policy is holding then.
146        if !cx.has_global::<gpui_component::Theme>() {
147            return;
148        }
149        gpui_component::Theme::set_scrollbar_mode(self.visibility.upstream(), cx);
150        gpui_component::Theme::sync_base(cx);
151    }
152
153    /// When scrollbars show.
154    pub const fn visibility(&self) -> ScrollbarVisibility {
155        self.visibility
156    }
157
158    /// Whether scrollbars overlay content or reserve a gutter.
159    pub const fn placement(&self) -> ScrollbarPlacement {
160        self.placement
161    }
162
163    /// Replaces the [`visibility`](Self::visibility).
164    pub const fn with_visibility(mut self, visibility: ScrollbarVisibility) -> Self {
165        self.visibility = visibility;
166        self
167    }
168
169    /// Replaces the [`placement`](Self::placement).
170    pub const fn with_placement(mut self, placement: ScrollbarPlacement) -> Self {
171        self.placement = placement;
172        self
173    }
174}
175
176/// Installs the crate's default scrollbar policy unless the application
177/// already chose one, and projects whichever policy holds onto the theme.
178pub(crate) fn install(cx: &mut gpui::App) {
179    let tokens = ScrollbarTokens::read(cx);
180    tokens.set(cx);
181}
182
183/// The width a scrollbar gutter reserves.
184///
185/// Upstream's bar is a fixed 16px track (an 8px thumb inside 4px rails),
186/// so a gutter that means to clear it reserves the crate's nearest
187/// spacing step rather than guessing a raw width.
188fn scrollbar_gutter(cx: &gpui::App) -> Pixels {
189    gpui_base::Theme::global(cx).tokens.spacing.lg
190}
191
192/// The trailing space a row keeps clear so an overlaid bar cannot cover its
193/// controls.
194///
195/// An overlay bar takes no layout space, which is the point of it: prose runs
196/// the full width and slides under a translucent track, and nothing is lost
197/// because text reflows and stays readable. A control is different. A row's
198/// overflow menu sitting under the bar is a target you cannot hit, and on a
199/// list that always shows its bar that is the resting state rather than a
200/// hover accident.
201///
202/// So a list whose rows carry trailing controls reserves the bar's own width
203/// at its trailing edge, and nothing else changes: the reading column keeps
204/// what it had, no surface without controls pays anything, and an application
205/// that switches the policy to [`ScrollbarPlacement::Gutter`] gets zero here
206/// because the gutter has already moved the bar out of the way.
207///
208/// Read from upstream rather than restated, so it cannot drift from the bar it
209/// is measured against.
210pub(crate) fn control_lane(cx: &gpui::App) -> Pixels {
211    match ScrollbarTokens::read(cx).placement() {
212        ScrollbarPlacement::Overlay => gpui_base::Scrollbar::width(),
213        ScrollbarPlacement::Gutter => Pixels::ZERO,
214    }
215}
216
217/// Mounts a vertical scrollbar under the crate's policy.
218///
219/// Upstream's convenience helper takes the theme's own mode; this routes
220/// the choice through the crate policy instead, so one application-level
221/// setting reaches every scrollable surface in the library. Under
222/// [`ScrollbarPlacement::Gutter`] the caller's content is inset by the
223/// bar's width so the bar never covers it.
224pub(crate) trait PolicyScrollbarExt: Sized {
225    /// Mounts a vertical scrollbar under the crate's scrollbar policy.
226    fn policy_vertical_scrollbar<H>(self, handle: &H, cx: &gpui::App) -> Self
227    where
228        H: gpui_component::scroll::ScrollbarHandle + Clone + 'static;
229
230    /// Mounts a horizontal scrollbar under the crate's scrollbar policy.
231    fn policy_horizontal_scrollbar<H>(self, handle: &H, cx: &gpui::App) -> Self
232    where
233        H: gpui_component::scroll::ScrollbarHandle + Clone + 'static;
234}
235
236impl<E> PolicyScrollbarExt for E
237where
238    E: gpui::ParentElement + gpui::Styled + Sized,
239{
240    fn policy_vertical_scrollbar<H>(self, handle: &H, cx: &gpui::App) -> Self
241    where
242        H: gpui_component::scroll::ScrollbarHandle + Clone + 'static,
243    {
244        let tokens = ScrollbarTokens::read(cx);
245        let element = match tokens.placement() {
246            ScrollbarPlacement::Overlay => self,
247            ScrollbarPlacement::Gutter => self.pr(scrollbar_gutter(cx)),
248        };
249        element.child(gpui::ParentElement::child(
250            gpui::div().absolute().inset_0(),
251            gpui_component::scroll::Scrollbar::new(handle)
252                .axis(gpui_component::scroll::ScrollbarAxis::Vertical)
253                .mode(tokens.visibility().upstream())
254                .viewport_from_layout(),
255        ))
256    }
257
258    fn policy_horizontal_scrollbar<H>(self, handle: &H, cx: &gpui::App) -> Self
259    where
260        H: gpui_component::scroll::ScrollbarHandle + Clone + 'static,
261    {
262        let tokens = ScrollbarTokens::read(cx);
263        let element = match tokens.placement() {
264            ScrollbarPlacement::Overlay => self,
265            ScrollbarPlacement::Gutter => self.pb(scrollbar_gutter(cx)),
266        };
267        element.child(gpui::ParentElement::child(
268            gpui::div().absolute().inset_0(),
269            gpui_component::scroll::Scrollbar::new(handle)
270                .axis(gpui_component::scroll::ScrollbarAxis::Horizontal)
271                .mode(tokens.visibility().upstream())
272                .viewport_from_layout(),
273        ))
274    }
275}
276
277/// Covers a retained list viewport with capture-phase wheel containment.
278///
279/// GPUI lists consume wheel input during bubbling. When a list is nested in
280/// another list, the ancestor may therefore run before the descendant. This
281/// mask moves the descendant directly during capture while it has room and
282/// releases the event at either edge so ordinary scroll chaining resumes.
283pub(crate) fn list_scroll_mask(state: &ListState) -> impl gpui::IntoElement {
284    let state = state.clone();
285    canvas(
286        |bounds, window, _| window.insert_hitbox(bounds, HitboxBehavior::Normal),
287        move |_, hitbox, window, _| {
288            let line_height = window.line_height();
289            let view_id = window.current_view();
290            let hitbox_id = hitbox.id;
291            window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
292                if !(phase.capture() && hitbox_id.should_handle_scroll(window)) {
293                    return;
294                }
295                let delta_y = event.delta.pixel_delta(line_height).y;
296                // A list following its tail reports its position as one past
297                // its last item, and `scroll_by` seeks from there and lands
298                // back where it began — so absorbing a backward wheel here
299                // would swallow it and move nothing, which is what stopped a
300                // reader scrolling up through a streaming transcript. The
301                // list's own handler knows how to step off the tail, so this
302                // leaves that case to it and keeps containment for the rest.
303                let leaving_tail = delta_y > Pixels::ZERO && state.is_following_tail();
304                if !leaving_tail && ScrollRoom::from_list_state(&state).can_absorb(delta_y) {
305                    state.scroll_by(-delta_y);
306                    cx.notify(view_id);
307                    cx.stop_propagation();
308                }
309            });
310        },
311    )
312    .absolute()
313    .inset_0()
314    .into_any_element()
315}
316
317/// Base multiplier applied to wheel line deltas, matching Zed's editor
318/// `scroll_sensitivity` default of 1.0.
319pub const WHEEL_SENSITIVITY: f32 = 1.0;
320
321/// Multiplier applied to wheel line deltas while Alt is held, matching Zed's
322/// editor `fast_scroll_sensitivity` convention.
323pub const WHEEL_FAST_SENSITIVITY: f32 = 4.0;
324
325/// Consecutive same-direction notches before acceleration reaches full
326/// strength. Windows conventions ramp over a handful of notches.
327pub const WHEEL_ACCEL_NOTCHES_TO_FULL: f32 = 5.0;
328
329/// Peak wheel acceleration multiplier once cadence saturates.
330pub const WHEEL_ACCEL_MAX_MULTIPLIER: f32 = 3.0;
331
332/// Seconds without a notch after which cadence decays back to rest.
333pub const WHEEL_ACCEL_DECAY_SECONDS: f32 = 0.35;
334
335/// Pixels assumed per scroll line when converting line deltas to distances,
336/// matching gpui's `List` element conversion.
337pub const LINE_HEIGHT_PX: f32 = 20.0;
338
339/// Pointer distance from the anchor at which autoscroll reaches full speed.
340pub const AUTOSCROLL_FULL_SPEED_DISTANCE_PX: f32 = 140.0;
341
342/// Maximum autoscroll speed in pixels per second at or beyond full distance.
343pub const MAX_AUTOSCROLL_SPEED_PX_PER_SEC: f32 = 900.0;
344
345/// Cadence-based wheel accelerator over discrete line notches.
346///
347/// State is tiny (`Copy`); keep one per scroll surface. The accelerator never
348/// touches rendering or scroll state — callers apply [`WheelAccelerator::line_notch`]'s
349/// returned distance themselves.
350#[derive(Debug, Clone, Copy)]
351pub struct WheelAccelerator {
352    cadence: f32,
353    decayed: bool,
354}
355
356impl Default for WheelAccelerator {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362impl WheelAccelerator {
363    /// Creates an accelerator at rest.
364    pub fn new() -> Self {
365        Self {
366            cadence: 0.0,
367            decayed: true,
368        }
369    }
370
371    /// Processes one discrete wheel notch and returns the extra scroll
372    /// distance to apply beyond the base scroll the framework already made.
373    ///
374    /// Sign follows wheel convention: negative means scroll down. The boost
375    /// ramps linearly over [`WHEEL_ACCEL_NOTCHES_TO_FULL`] same-direction
376    /// notches toward [`WHEEL_ACCEL_MAX_MULTIPLIER`], so the first notch of a
377    /// burst contributes a small ramp-in boost and later ones contribute up
378    /// to the full multiplier minus one. A pause longer than
379    /// [`WHEEL_ACCEL_DECAY_SECONDS`] (see [`WheelAccelerator::rest`]) or a
380    /// direction reversal restarts the ramp. `fast` applies the
381    /// [`WHEEL_FAST_SENSITIVITY`] Alt-multiplier instead of
382    /// [`WHEEL_SENSITIVITY`].
383    pub fn line_notch(&mut self, dy: f32, fast: bool) -> Pixels {
384        let now_decaying = self.decayed;
385        self.decayed = false;
386        if now_decaying {
387            self.cadence = 0.0;
388        }
389        if dy * self.cadence < 0.0 {
390            self.cadence = 0.0;
391        }
392        self.cadence += dy.signum();
393        let strength = (self.cadence.abs() / WHEEL_ACCEL_NOTCHES_TO_FULL).min(1.0);
394        let accel = 1.0 + (WHEEL_ACCEL_MAX_MULTIPLIER - 1.0) * strength;
395        let sensitivity = if fast {
396            WHEEL_FAST_SENSITIVITY
397        } else {
398            WHEEL_SENSITIVITY
399        };
400        let boost = -dy * sensitivity * (accel - 1.0);
401        gpui::px(boost * LINE_HEIGHT_PX)
402    }
403
404    /// Marks the accelerator idle after a pause between gestures, resetting
405    /// its cadence. Call this when input handling notices elapsed time rather
406    /// than relying solely on per-notch decay checks.
407    pub fn rest(&mut self) {
408        self.cadence = 0.0;
409        self.decayed = true;
410    }
411
412    /// Current signed notch cadence (positive scrolling up).
413    pub fn cadence(&self) -> f32 {
414        self.cadence
415    }
416}
417
418/// Middle-click autoscroll session anchored to a window position.
419///
420/// Speed scales linearly with pointer distance from the anchor up to full
421/// speed at [`AUTOSCROLL_FULL_SPEED_DISTANCE_PX`], capped at
422/// [`MAX_AUTOSCROLL_SPEED_PX_PER_SEC`], matching Windows conventions.
423#[derive(Debug, Clone, Copy)]
424pub struct Autoscroll {
425    anchor_y: Pixels,
426    pointer_y: Pixels,
427}
428
429impl Autoscroll {
430    /// Begins a session anchored at `anchor`'s vertical position.
431    pub fn start(anchor: gpui::Point<Pixels>) -> Self {
432        Self {
433            anchor_y: anchor.y,
434            pointer_y: anchor.y,
435        }
436    }
437
438    /// Updates the tracked pointer position; call from hover/move handlers.
439    pub fn track(&mut self, pointer: gpui::Point<Pixels>) {
440        self.pointer_y = pointer.y;
441    }
442
443    /// Advances the session by `delta_seconds` using the latest tracked
444    /// pointer and returns the distance to scroll this frame. Positive values
445    /// scroll down; zero means the pointer sits within one pixel of the
446    /// anchor.
447    pub fn tick(&mut self, delta_seconds: f32) -> Pixels {
448        let offset = self.pointer_y - self.anchor_y;
449        let distance = offset.abs();
450        if distance < gpui::px(1.) {
451            return gpui::px(0.);
452        }
453        let direction = offset.signum();
454        let speed = ((distance / gpui::px(AUTOSCROLL_FULL_SPEED_DISTANCE_PX)).min(1.0)
455            * MAX_AUTOSCROLL_SPEED_PX_PER_SEC) as f32;
456        gpui::px(direction * speed * delta_seconds)
457    }
458}
459
460/// Whether a vertically scrollable region can still absorb wheel motion in
461/// the direction the user is scrolling.
462///
463/// This is the containment test behind native scroll chaining: an inner
464/// component traps the wheel while it has room, and releases it to the
465/// surrounding page once it bottoms out or tops up. `offset` and `max_offset`
466/// use the GPUI convention where the offset is zero at the top of the
467/// content and `-max_offset` at the bottom.
468#[derive(Debug, Clone, Copy)]
469pub struct ScrollRoom {
470    /// Current vertical scroll offset (zero or negative).
471    pub offset: Pixels,
472    /// Maximum magnitude of the offset (zero means nothing to scroll).
473    pub max_offset: Pixels,
474}
475
476impl ScrollRoom {
477    /// Builds a room snapshot from a GPUI scroll handle's reported state.
478    pub fn new(offset: Pixels, max_offset: Pixels) -> Self {
479        Self {
480            offset,
481            max_offset: max_offset.max(px(0.)),
482        }
483    }
484
485    /// True when scrolling by `delta_y` (negative = down) would move this
486    /// region: it has scrollable content and is not already pinned against
487    /// the edge in that direction.
488    pub fn can_absorb(&self, delta_y: Pixels) -> bool {
489        let zero = Pixels::ZERO;
490        if self.max_offset <= zero {
491            return false;
492        }
493        if delta_y < zero {
494            // Scrolling toward the bottom: room remains unless pinned there.
495            self.offset > -self.max_offset + px(0.5)
496        } else if delta_y > zero {
497            // Scrolling back toward the top.
498            self.offset < -px(0.5)
499        } else {
500            false
501        }
502    }
503
504    /// Snapshots the room from a GPUI [`ScrollHandle`](gpui::ScrollHandle).
505    ///
506    /// This is the form used by components whose scrolling lives in a
507    /// `div().overflow_y_scroll().track_scroll(&handle)` container.
508    pub fn from_handle(handle: &gpui::ScrollHandle) -> Self {
509        Self::new(handle.offset().y, handle.max_offset().y)
510    }
511
512    /// Snapshots the room from a GPUI [`gpui::ListState`].
513    pub fn from_list_state(state: &gpui::ListState) -> Self {
514        Self::new(
515            state.scroll_px_offset_for_scrollbar().y,
516            state.max_offset_for_scrollbar().y,
517        )
518    }
519}
520
521#[cfg(test)]
522mod scrollbar_policy_tests {
523    use super::*;
524    use gpui::TestAppContext;
525
526    /// A surface that scrolls says so before the reader touches it: the
527    /// crate defaults to always-visible bars, and the choice reaches the
528    /// theme every bar resolves from — including the ones upstream draws
529    /// inside its tables, which no crate-side call site could reach.
530    #[gpui::test]
531    fn the_default_policy_projects_always_visible_onto_the_theme(cx: &mut TestAppContext) {
532        cx.update(crate::init);
533        cx.update(|cx| {
534            assert_eq!(
535                ScrollbarTokens::read(cx).visibility(),
536                ScrollbarVisibility::Always
537            );
538            assert_eq!(
539                gpui_base::Theme::global(cx).scrollbar.mode(),
540                gpui_component::scroll::ScrollbarMode::Always
541            );
542        });
543    }
544
545    /// The projection survives a theme swap.
546    ///
547    /// The base theme's scrollbar block is derived: applying any theme
548    /// rebuilds it from the component theme's own field. Writing the
549    /// derived value instead of that field left the policy in place until
550    /// the reader changed theme, at which point every bar upstream draws
551    /// inside its tables quietly reverted.
552    #[gpui::test]
553    fn the_projection_survives_a_theme_change(cx: &mut TestAppContext) {
554        cx.update(crate::init);
555        cx.update(|cx| {
556            gpui_component::Theme::change(gpui_component::ThemeMode::Dark, None, cx);
557            assert_eq!(
558                gpui_base::Theme::global(cx).scrollbar.mode(),
559                gpui_component::scroll::ScrollbarMode::Always,
560                "a theme swap must not silently drop the scrollbar policy"
561            );
562        });
563    }
564
565    /// The policy is a policy: an application that chooses first keeps its
566    /// choice through init, and the theme follows it.
567    #[gpui::test]
568    fn install_respects_a_policy_the_application_chose_first(cx: &mut TestAppContext) {
569        cx.update(|cx| {
570            ScrollbarTokens::default()
571                .with_visibility(ScrollbarVisibility::Scrolling)
572                .set(cx);
573            crate::init(cx);
574        });
575        cx.update(|cx| {
576            assert_eq!(
577                ScrollbarTokens::read(cx).visibility(),
578                ScrollbarVisibility::Scrolling
579            );
580            assert_eq!(
581                gpui_base::Theme::global(cx).scrollbar.mode(),
582                gpui_component::scroll::ScrollbarMode::Scrolling
583            );
584        });
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    fn px_f(value: Pixels) -> f32 {
593        value.as_f32()
594    }
595
596    #[test]
597    fn first_notch_applies_ramp_in_boost() {
598        let mut wheel = WheelAccelerator::new();
599        // One notch in: strength = 1/NOTCHES_TO_FULL, so the boost is small
600        // but nonzero.
601        let expected =
602            ((WHEEL_ACCEL_MAX_MULTIPLIER - 1.0) / WHEEL_ACCEL_NOTCHES_TO_FULL) * LINE_HEIGHT_PX;
603        assert!((px_f(wheel.line_notch(-1.0, false)) - expected).abs() < 0.01);
604    }
605
606    #[test]
607    fn cadence_ramps_toward_max_multiplier() {
608        let mut wheel = WheelAccelerator::new();
609        wheel.line_notch(-1.0, false);
610        let mut boosts = Vec::new();
611        for _ in 0..6 {
612            boosts.push(px_f(wheel.line_notch(-1.0, false)));
613        }
614        // Monotonically non-decreasing, first boost small, last at max ramp.
615        assert!(boosts.windows(2).all(|pair| pair[0] <= pair[1]));
616        assert!(boosts[0] > 0.0);
617        let expected_full = (WHEEL_ACCEL_MAX_MULTIPLIER - 1.0) * LINE_HEIGHT_PX;
618        assert!((boosts[5] - expected_full).abs() < 0.01);
619    }
620
621    #[test]
622    fn direction_reversal_resets_cadence() {
623        let mut wheel = WheelAccelerator::new();
624        for _ in 0..8 {
625            wheel.line_notch(-1.0, false);
626        }
627        assert!(wheel.cadence() < 0.0);
628        let up_boost = px_f(wheel.line_notch(1.0, false));
629        let fresh = px_f(WheelAccelerator::new().line_notch(1.0, false));
630        assert!((up_boost - fresh).abs() < 0.01);
631    }
632
633    #[test]
634    fn rest_clears_cadence() {
635        let mut wheel = WheelAccelerator::new();
636        for _ in 0..8 {
637            wheel.line_notch(-1.0, false);
638        }
639        assert!(wheel.cadence() < 0.0);
640        wheel.rest();
641        assert_eq!(wheel.cadence(), 0.0);
642        // After rest, the next notch is back to first-notch ramp-in strength.
643        let expected =
644            ((WHEEL_ACCEL_MAX_MULTIPLIER - 1.0) / WHEEL_ACCEL_NOTCHES_TO_FULL) * LINE_HEIGHT_PX;
645        assert!((px_f(wheel.line_notch(-1.0, false)) - expected).abs() < 0.01);
646    }
647
648    #[test]
649    fn fast_multiplies_the_ramp() {
650        let mut base = WheelAccelerator::new();
651        let mut fast = WheelAccelerator::new();
652        base.line_notch(-1.0, false);
653        fast.line_notch(-1.0, false);
654        let base_boost = px_f(base.line_notch(-1.0, false));
655        let fast_boost = px_f(fast.line_notch(-1.0, true));
656        assert!(
657            (fast_boost / base_boost - WHEEL_FAST_SENSITIVITY / WHEEL_SENSITIVITY).abs() < 0.01
658        );
659    }
660
661    #[test]
662    fn autoscroll_speed_scales_with_distance_then_caps() {
663        let anchor = gpui::point(gpui::px(100.), gpui::px(200.));
664        let mut session = Autoscroll::start(anchor);
665
666        session.track(gpui::point(gpui::px(100.), gpui::px(240.)));
667        let near = px_f(session.tick(0.016));
668        session.track(gpui::point(gpui::px(100.), gpui::px(480.)));
669        let far = px_f(session.tick(0.016));
670        session.track(gpui::point(gpui::px(100.), gpui::px(900.)));
671        let farther = px_f(session.tick(0.016));
672
673        assert!(near > 0.0 && far > near && farther >= far);
674        // At 280 px past the anchor the speed is capped: identical ticks match.
675        let capped_a = px_f(session.tick(0.016));
676        session.track(gpui::point(gpui::px(100.), gpui::px(1200.)));
677        let capped_b = px_f(session.tick(0.016));
678        assert!((capped_a - capped_b).abs() < 0.001);
679    }
680
681    #[test]
682    fn autoscroll_near_anchor_is_zero_and_sign_follows_pointer() {
683        let anchor = gpui::point(gpui::px(50.), gpui::px(500.));
684        let mut session = Autoscroll::start(anchor);
685        assert_eq!(session.tick(0.016), gpui::px(0.));
686        session.track(gpui::point(gpui::px(50.), gpui::px(600.)));
687        assert!(px_f(session.tick(0.016)) > 0.0);
688        session.track(gpui::point(gpui::px(50.), gpui::px(400.)));
689        assert!(px_f(session.tick(0.016)) < 0.0);
690    }
691
692    #[test]
693    fn scroll_room_absorbs_only_when_room_remains_in_direction() {
694        // Mid-scroll: absorbs both directions.
695        let mid = ScrollRoom::new(px(-100.), px(300.));
696        assert!(mid.can_absorb(px(-40.)));
697        assert!(mid.can_absorb(px(40.)));
698
699        // Pinned at the top: only downward motion is absorbed.
700        let at_top = ScrollRoom::new(px(0.), px(300.));
701        assert!(!at_top.can_absorb(px(40.)), "at top, upward must chain out");
702        assert!(at_top.can_absorb(px(-40.)));
703
704        // Pinned at the bottom: only upward motion is absorbed.
705        let at_bottom = ScrollRoom::new(px(-300.), px(300.));
706        assert!(
707            !at_bottom.can_absorb(px(-40.)),
708            "at bottom, downward must chain out"
709        );
710        assert!(at_bottom.can_absorb(px(40.)));
711
712        // Nothing to scroll at all: never absorbs.
713        let empty = ScrollRoom::new(px(0.), px(0.));
714        assert!(!empty.can_absorb(px(-40.)));
715        assert!(!empty.can_absorb(px(40.)));
716    }
717}