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}