feat(terminal): animate wheel scrolling instead of jumping a notch at once

Sub-line scroll positions were already in place — the view keeps a
fractional remainder and paints the grid shifted by it — but the position
was a function of the event, not of time. A notch arrived and the whole
distance was applied at once, so whether it looked smooth came down to how
fine-grained the platform's deltas happened to be.

A macOS trackpad reports pixels, so it did. A wheel on Windows reports
whole lines (gpui multiplies the notch by the system's scroll-lines
setting, three by default), the fraction came out zero every time, and the
view jumped three lines per notch. The sub-line machinery was present and
never engaged.

A 120-step notch is one discrete pulse; no arithmetic on the delta recovers
a continuous gesture from it. So make position a function of time: a notch
adds to a remaining distance and each frame consumes a share of what is
left, ~120ms to land, exponential, with a sub-pixel remainder snapped
rather than approached since every frame of it costs a repaint.

Line deltas are discrete and get animated; pixel deltas are continuous and
do not — putting an animation between a trackpad and the grid would only
add lag. Mouse reporting and alternate-scroll keep forwarding whole lines,
which cannot be spread over frames either.

The distance in flight is relative rather than an absolute target, so
output arriving mid-scroll shifts the grid without dragging the animation
elsewhere. Everything that moves the view on its own cancels what is in
flight first.

Settings -> Terminal -> Scrolling -> Smooth scrolling, on by default.

Also pin the scratch config dir in the terminal-view test harness: building
a view reads the config, and which test got there first decided whether
that touched the real user directory.
This commit is contained in:
l0ng-ai
2026-08-07 13:32:17 +08:00
parent 05bfa8a269
commit abbdb85aef
9 changed files with 342 additions and 6 deletions
+36
View File
@@ -63,6 +63,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
touch one it didn't, so a hand-written skill that happens to share the
directory name survives. (#248)
- **Smooth scrolling for wheel mice** — a notch now eases into place over a
handful of frames instead of jumping the whole distance at once.
**Settings → Terminal → Scrolling → Smooth scrolling**, on by default.
Sub-line scroll positions have been in place for a while: the view keeps a
fractional remainder and paints the grid shifted by it, so the scrollback
need not land on a line boundary. But the position was a function of the
*event*, not of *time* — a notch arrived and the whole thing was applied at
once. Whether that looked smooth came down entirely to how fine-grained the
platform's deltas were. A macOS trackpad reports pixels, so it did. A wheel
on Windows reports whole lines (gpui multiplies the notch by the system's
scroll-lines setting, three by default), the fraction came out zero every
time, and the view jumped three lines per notch — the sub-line machinery was
present and never engaged. That is the "no smooth scroll on Windows" report.
A 120-step wheel notch is one discrete pulse; no amount of arithmetic on the
delta recovers a continuous gesture from it. So the fix is to make position a
function of time: a notch adds to a remaining distance, and each frame
consumes a share of what is left. Roughly 120ms to land, exponential, so most
of the travel happens immediately and the tail is invisible — under a pixel
of remainder is snapped rather than approached, since every frame of it costs
a repaint.
Trackpads keep the direct path. They already deliver a continuous pixel
stream, and putting an animation between the fingers and the grid would only
add lag. The split falls out of the event itself — line deltas are discrete
and get animated, pixel deltas are continuous and do not. Mouse reporting and
alternate-scroll are untouched too: those forward whole lines to the
application, which cannot be spread over frames.
The distance in flight is relative, not an absolute target, so output
arriving mid-scroll shifts the grid without dragging the animation somewhere
else. Everything that moves the view on its own — jumping to a prompt,
dragging a selection past the edge, clearing the scrollback, the keyboard and
mouse-reporting paths — cancels what is in flight first.
### Fixed
- **Scoop shims work again when tty7 is launched from a hardened Windows
+6
View File
@@ -195,6 +195,11 @@ pub struct Config {
pub mouse_hide_while_typing: bool,
pub focus_follows_mouse: bool,
pub mouse_scroll_multiplier: f32,
/// Spread a wheel notch over several frames instead of jumping the whole
/// distance at once. Only affects discrete wheel input — trackpads already
/// deliver a continuous pixel stream, and animating that would just add lag.
#[serde(default = "default_true")]
pub smooth_scroll: bool,
#[serde(default = "default_true")]
pub mouse_reporting: bool,
pub clipboard_trim_trailing_spaces: bool,
@@ -438,6 +443,7 @@ impl Default for Config {
mouse_hide_while_typing: true,
focus_follows_mouse: false,
mouse_scroll_multiplier: 1.0,
smooth_scroll: true,
mouse_reporting: true,
clipboard_trim_trailing_spaces: false,
copy_on_select: false,
+269 -6
View File
@@ -85,6 +85,25 @@ struct DragScroll {
side: Side,
}
/// In-flight wheel animation. `remaining` is what is left to scroll, in lines,
/// relative to wherever the view happens to be — deliberately not an absolute
/// target, so output arriving mid-animation shifts the grid under us without
/// dragging the animation somewhere else.
#[derive(Clone, Copy)]
struct ScrollAnim {
remaining: f32,
last: std::time::Instant,
}
/// Fraction of the remaining distance consumed per [`SCROLL_ANIM_FRAME`].
const SCROLL_ANIM_SMOOTH: f32 = 0.4;
/// The frame `SCROLL_ANIM_SMOOTH` is calibrated against, and the tick interval.
const SCROLL_ANIM_FRAME: std::time::Duration = std::time::Duration::from_millis(16);
/// Below this much left to travel, land instead of asymptoting toward it. A
/// twentieth of a line is around a pixel — the tail of an exponential decay is
/// invisible long before it ends, and every frame of it costs a full repaint.
const SCROLL_ANIM_MIN: f32 = 0.05;
fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool {
match pane_runs_remotely {
false => host_is_local,
@@ -118,6 +137,8 @@ pub struct TerminalView {
selecting: bool,
drag_scroll: Option<DragScroll>,
drag_scroll_epoch: u64,
scroll_anim: Option<ScrollAnim>,
scroll_anim_epoch: u64,
pub title: String,
pub marked_text: String,
last_mouse_cell: Option<(usize, usize)>,
@@ -915,6 +936,8 @@ impl TerminalView {
selecting: false,
drag_scroll: None,
drag_scroll_epoch: 0,
scroll_anim: None,
scroll_anim_epoch: 0,
title: "tty7".to_string(),
marked_text: String::new(),
last_mouse_cell: None,
@@ -1904,6 +1927,7 @@ impl TerminalView {
}
fn jump_to_prompt(&mut self) {
self.cancel_scroll_anim();
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
@@ -2071,6 +2095,7 @@ impl TerminalView {
if lines == 0 {
return;
}
self.cancel_scroll_anim();
let mut mode = *self.terminal.term.lock().mode();
if !self.report_mouse {
mode.remove(TermMode::MOUSE_MODE);
@@ -2400,6 +2425,7 @@ impl TerminalView {
}
pub fn clear_scrollback(&mut self, cx: &mut Context<Self>) {
self.cancel_scroll_anim();
self.terminal.term.lock().grid_mut().clear_history();
self.scroll_frac = 0.;
self.terminal.marks().clear();
@@ -3918,6 +3944,7 @@ impl TerminalView {
let Some(ds) = self.drag_scroll else {
return false;
};
self.cancel_scroll_anim();
let mut term = self.terminal.term.lock();
let before = term.grid().display_offset();
term.scroll_display(Scroll::Delta(drag_scroll_step(ds.overshoot)));
@@ -3963,9 +3990,12 @@ impl TerminalView {
fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &mut Window, cx: &mut Context<Self>) {
let mult = cx.global::<Config>().mouse_scroll_multiplier;
let raw = match ev.delta {
ScrollDelta::Lines(p) => p.y,
ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(),
// A wheel notch is one discrete pulse — applying it whole is what makes
// scrolling look like it jumps. A trackpad already sends a fine-grained
// pixel stream, so it stays on the direct path.
let (raw, discrete) = match ev.delta {
ScrollDelta::Lines(p) => (p.y, true),
ScrollDelta::Pixels(p) => (p.y.as_f32() / self.line_height.as_f32(), false),
};
let delta = raw * mult;
@@ -3975,6 +4005,9 @@ impl TerminalView {
|| mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
};
if quantized {
// Whole lines are what the application gets told about, so this path
// cannot be spread over frames.
self.cancel_scroll_anim();
let total = self.scroll_debt + delta;
let lines = total.trunc() as i32;
self.scroll_debt = total - lines as f32;
@@ -3984,10 +4017,80 @@ impl TerminalView {
return;
}
self.smooth_scroll(delta, cx);
if discrete && cx.global::<Config>().smooth_scroll {
self.queue_scroll_anim(delta, cx);
} else {
self.cancel_scroll_anim();
self.smooth_scroll(delta, cx);
}
}
fn smooth_scroll(&mut self, delta: f32, cx: &mut Context<Self>) {
/// Add `delta` to the in-flight animation, starting the frame loop if it is
/// idle. Successive notches accumulate rather than restart, so spinning the
/// wheel fast still lands exactly where the notches asked for.
fn queue_scroll_anim(&mut self, delta: f32, cx: &mut Context<Self>) {
if let Some(anim) = self.scroll_anim.as_mut() {
anim.remaining += delta;
return;
}
self.scroll_anim = Some(ScrollAnim {
remaining: delta,
last: std::time::Instant::now(),
});
self.scroll_anim_epoch += 1;
let epoch = self.scroll_anim_epoch;
cx.spawn(async move |this, cx| {
loop {
cx.background_executor().timer(SCROLL_ANIM_FRAME).await;
if !matches!(
this.update(cx, |view, cx| view.scroll_anim_tick(epoch, cx)),
Ok(true)
) {
break;
}
}
})
.detach();
}
/// Drop whatever is left to travel. Every other way the display offset moves
/// — jumping to a prompt, dragging a selection past the edge, the keyboard
/// and mouse-reporting paths — has to come through here first, or the
/// animation would keep walking away from wherever it put us.
fn cancel_scroll_anim(&mut self) {
if self.scroll_anim.take().is_some() {
self.scroll_anim_epoch += 1;
}
}
fn scroll_anim_tick(&mut self, epoch: u64, cx: &mut Context<Self>) -> bool {
if epoch != self.scroll_anim_epoch {
return false;
}
let Some(anim) = self.scroll_anim.as_mut() else {
return false;
};
let now = std::time::Instant::now();
let dt = now.duration_since(anim.last);
anim.last = now;
let (step, last) = scroll_anim_step(anim.remaining, dt);
anim.remaining -= step;
if last {
self.scroll_anim = None;
}
// Hitting the top or the bottom of the scrollback consumes nothing; the
// remaining distance has nowhere to go, so stop instead of decaying it
// against the clamp for another 100ms.
let moved = self.smooth_scroll(step, cx);
if !moved {
self.cancel_scroll_anim();
return false;
}
!last
}
/// Apply `delta` lines right now. Returns whether anything actually moved.
fn smooth_scroll(&mut self, delta: f32, cx: &mut Context<Self>) -> bool {
let mut term = self.terminal.term.lock();
let offset = term.grid().display_offset();
let max = term.grid().history_size();
@@ -3999,7 +4102,9 @@ impl TerminalView {
if jump != 0 || frac != self.scroll_frac {
self.scroll_frac = frac;
cx.notify();
return true;
}
false
}
fn grid_line(
@@ -5357,6 +5462,23 @@ fn smooth_scroll_step(offset: usize, frac: f32, delta: f32, max: usize) -> (i32,
(new_offset as i32 - offset as i32, pos - new_offset)
}
/// How much of `remaining` to consume this frame, and whether this is the last
/// one. Decay is scaled by the real elapsed time so a dropped frame covers the
/// ground it missed instead of stretching the animation out.
fn scroll_anim_step(remaining: f32, dt: std::time::Duration) -> (f32, bool) {
if remaining.abs() <= SCROLL_ANIM_MIN {
return (remaining, true);
}
let frames = (dt.as_secs_f32() / SCROLL_ANIM_FRAME.as_secs_f32()).clamp(0.1, 8.);
let consumed = 1. - (1. - SCROLL_ANIM_SMOOTH).powf(frames);
let step = remaining * consumed;
if (remaining - step).abs() <= SCROLL_ANIM_MIN {
(remaining, true)
} else {
(step, false)
}
}
fn drag_scroll_step(overshoot: f32) -> i32 {
let lines = overshoot.abs().ceil().clamp(1., 8.) as i32;
if overshoot < 0. { -lines } else { lines }
@@ -5369,6 +5491,7 @@ mod tests {
compose_notification_title, cwd_is_on_host, display_width, is_typeahead_interrupt,
loopback_plan, observe_typeahead_for_owner,
};
use super::{SCROLL_ANIM_FRAME, scroll_anim_step};
use super::{
drag_scroll_step, encode_mouse, escape_candidate, expand_file_command_template,
fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, input_overflow_shift,
@@ -6100,6 +6223,58 @@ mod tests {
assert_eq!(smooth_scroll_step(0, 0.0, 2.5, 0), (0, 0.0));
}
#[test]
fn scroll_anim_step_converges_and_lands() {
let frame = SCROLL_ANIM_FRAME;
// A notch is spread over frames instead of being applied whole.
let (step, last) = scroll_anim_step(3.0, frame);
assert!(!last);
assert!(
step > 0. && step < 3.0,
"took {step} of 3 lines in one frame"
);
// And it converges: no notch is left hanging.
let mut remaining = 3.0_f32;
let mut frames = 0u32;
loop {
let (step, last) = scroll_anim_step(remaining, frame);
remaining -= step;
frames += 1;
if last {
break;
}
assert!(frames < 200, "still {remaining} lines short after {frames}");
}
assert!(remaining.abs() < 1e-4, "landed {remaining} lines off");
// Slow enough to read as motion, fast enough not to feel like lag.
let ms = frames * SCROLL_ANIM_FRAME.as_millis() as u32;
assert!((60..=200).contains(&ms), "a 3-line notch took {ms}ms");
}
#[test]
fn scroll_anim_step_covers_dropped_frames() {
// A late tick has to make up the ground it missed, or a busy pane would
// scroll slower than an idle one.
let (one, _) = scroll_anim_step(10.0, SCROLL_ANIM_FRAME);
let (four, _) = scroll_anim_step(10.0, SCROLL_ANIM_FRAME * 4);
assert!(four > one * 2., "{four} should far outpace {one}");
// But the catch-up is capped, so a tick after a long stall never
// overshoots what was actually asked for.
let (stalled, _) = scroll_anim_step(10.0, std::time::Duration::from_secs(5));
assert!(
stalled > four && stalled <= 10.0,
"stalled tick took {stalled}"
);
}
#[test]
fn scroll_anim_step_lands_on_a_negligible_remainder() {
let (step, last) = scroll_anim_step(-0.005, SCROLL_ANIM_FRAME);
assert!(last);
assert_eq!(step, -0.005);
}
#[test]
fn drag_scroll_step_scales_with_overshoot_and_caps() {
assert_eq!(drag_scroll_step(0.2), 1);
@@ -6478,10 +6653,14 @@ pub(crate) fn quiet_test_ssh_pane(
mod gpui_tests {
use super::*;
use crate::daemon::protocol::{ClientMsg, DaemonMsg};
use gpui::TestAppContext;
use gpui::{TestAppContext, point};
use std::os::unix::net::UnixStream;
fn harness(cx: &mut TestAppContext) -> (gpui::WindowHandle<TerminalView>, UnixStream) {
// Building a view reads the config. Whether that hit the real user
// directory used to come down to which test happened to pin the
// scratch dir first.
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
let (client_side, daemon_side) = UnixStream::pair().unwrap();
cx.update(|cx| {
@@ -7943,6 +8122,90 @@ mod gpui_tests {
view.terminal.term.lock().grid().display_offset()
}
fn wheel(delta: gpui::ScrollDelta) -> ScrollWheelEvent {
ScrollWheelEvent {
delta,
..Default::default()
}
}
/// The whole point of the animation: a notch must not land in one go.
#[gpui::test]
fn a_wheel_notch_is_spread_over_frames(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, w, cx| {
scroll_into_history(view, 10);
view.on_scroll(&wheel(gpui::ScrollDelta::Lines(point(0., -3.))), w, cx);
assert_eq!(
display_offset(view),
10,
"the notch was applied whole, before a single frame ran"
);
assert_eq!(view.scroll_frac, 0., "and not even a sliver of it");
assert!(view.scroll_anim.is_some(), "nothing was left to animate");
})
.unwrap();
}
/// A trackpad is already a continuous stream — animating it would only put
/// lag between the fingers and the grid.
#[gpui::test]
fn a_trackpad_still_scrolls_the_instant_it_is_touched(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, w, cx| {
scroll_into_history(view, 10);
let lines = view.line_height.as_f32() * -3.;
view.on_scroll(
&wheel(gpui::ScrollDelta::Pixels(point(px(0.), px(lines)))),
w,
cx,
);
assert_eq!(display_offset(view), 7, "the pixels were held back");
assert!(
view.scroll_anim.is_none(),
"a trackpad started an animation"
);
})
.unwrap();
}
#[gpui::test]
fn turning_smooth_scrolling_off_restores_the_direct_path(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
cx.update(|cx| cx.global_mut::<Config>().smooth_scroll = false);
window
.update(cx, |view, w, cx| {
scroll_into_history(view, 10);
view.on_scroll(&wheel(gpui::ScrollDelta::Lines(point(0., -3.))), w, cx);
assert_eq!(display_offset(view), 7);
assert!(view.scroll_anim.is_none());
})
.unwrap();
}
/// An animation that kept walking after the view was moved out from under
/// it would drag the user back off the prompt they just jumped to.
#[gpui::test]
fn moving_the_viewport_cancels_an_animation_in_flight(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, w, cx| {
scroll_into_history(view, 10);
view.on_scroll(&wheel(gpui::ScrollDelta::Lines(point(0., -3.))), w, cx);
assert!(view.scroll_anim.is_some());
view.jump_to_prompt();
assert!(
view.scroll_anim.is_none(),
"the animation outlived the jump"
);
assert_eq!(display_offset(view), 0);
})
.unwrap();
}
#[gpui::test]
fn history_recall_snaps_the_viewport_back_to_the_prompt(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
+4
View File
@@ -2082,6 +2082,10 @@ impl Tty7App {
});
}
pub(crate) fn set_smooth_scroll(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.smooth_scroll = on);
}
pub(crate) fn set_clipboard_trim(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.clipboard_trim_trailing_spaces = on);
}
+5
View File
@@ -294,6 +294,11 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsScrollbackDesc => "Lines of history kept per pane. Applies to new panes.",
L10nKey::SettingsScrollSpeed => "Scroll speed",
L10nKey::SettingsScrollSpeedDesc => "Multiplier applied to mouse-wheel scrolling.",
L10nKey::SettingsSmoothScroll => "Smooth scrolling",
L10nKey::SettingsSmoothScrollDesc => {
"Ease each wheel notch into place instead of jumping the whole way at once. \
Trackpads scroll continuously already and are unaffected."
}
L10nKey::SettingsMouse => "Mouse",
L10nKey::SettingsFocusFollowsMouse => "Focus follows mouse",
L10nKey::SettingsFocusFollowsMouseDesc => "Hovering a pane focuses it without a click.",
+5
View File
@@ -292,6 +292,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
}
L10nKey::SettingsScrollSpeed => "スクロール速度",
L10nKey::SettingsScrollSpeedDesc => "マウスホイールのスクロールに適用する倍率",
L10nKey::SettingsSmoothScroll => "スムーズスクロール",
L10nKey::SettingsSmoothScrollDesc => {
"ホイール1ノッチ分を一気に飛ばさず、数フレームかけて動かす。\
"
}
L10nKey::SettingsMouse => "マウス",
L10nKey::SettingsFocusFollowsMouse => "フォーカスがマウスに追従する",
L10nKey::SettingsFocusFollowsMouseDesc => {
+2
View File
@@ -283,6 +283,8 @@ pub enum L10nKey {
SettingsScrollbackDesc,
SettingsScrollSpeed,
SettingsScrollSpeedDesc,
SettingsSmoothScroll,
SettingsSmoothScrollDesc,
SettingsMouse,
SettingsFocusFollowsMouse,
SettingsFocusFollowsMouseDesc,
+4
View File
@@ -256,6 +256,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsScrollbackDesc => "每个窗格保留的历史行数。仅适用于新窗格。",
L10nKey::SettingsScrollSpeed => "滚动速度",
L10nKey::SettingsScrollSpeedDesc => "应用于鼠标滚轮滚动的倍率。",
L10nKey::SettingsSmoothScroll => "平滑滚动",
L10nKey::SettingsSmoothScrollDesc => {
"滚轮每一格分几帧滚到位,而不是一次跳完。触控板本来就是连续滚动,不受影响。"
}
L10nKey::SettingsMouse => "鼠标",
L10nKey::SettingsFocusFollowsMouse => "焦点跟随鼠标",
L10nKey::SettingsFocusFollowsMouseDesc => "悬停窗格即聚焦,无需点击。",
+11
View File
@@ -3444,6 +3444,7 @@ impl Tty7App {
let mouse_hide = cfg.mouse_hide_while_typing;
let focus_follows = cfg.focus_follows_mouse;
let scroll_mult = cfg.mouse_scroll_multiplier;
let smooth_scroll = cfg.smooth_scroll;
let mouse_reporting = cfg.mouse_reporting;
let bell = cfg.bell;
let scrollback_idx = match cfg.scrollback_limit {
@@ -3544,6 +3545,10 @@ impl Tty7App {
.child(format!("{scroll_mult:.2}×")),
)
.into_any_element();
let smooth_scroll_switch = crate::ui::theme::switch("term-smooth-scroll", cx)
.checked(smooth_scroll)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smooth_scroll(*on, cx)))
.into_any_element();
v_flex()
.child(self.render_shell_group(cx))
@@ -3561,6 +3566,12 @@ impl Tty7App {
scroll_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsSmoothScroll),
t(L10nKey::SettingsSmoothScrollDesc),
smooth_scroll_switch,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header(t(L10nKey::SettingsMouse), cx))
.child(self.settings_row(