mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Smooth scrolling: spread a wheel detent over frames instead of jumping it (#382)
* 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. * fix(terminal): tell a trackpad from a wheel by phase, not delta type The first cut split the two on the delta variant — Lines meant a wheel, Pixels meant a trackpad. That holds on Windows and Linux. It does not hold on macOS, which sets hasPreciseScrollingDeltas for a wheel mouse as well, so a wheel reported pixels, took the trackpad path, and was never animated. Measured on this machine, one detent arrives as a single ~103px event — about five lines at a 21px line height, applied in one go. That is a worse jump than the three lines Windows produced, and it was invisible to a check based on the delta's type. Size cannot separate them either: a trackpad flick reaches ~3 lines in one event while an inched wheel moves ~0.6, so the ranges overlap and any threshold misclassifies a quarter of the trackpad stream. Phase can. Only a device that can gesture ever reports Started/Ended; a wheel is Moved forever, on every backend. So track the gesture instead — and hold it open on a 150ms idle timer rather than closing it on Ended, because lifting the fingers is not the end of the stream: the momentum tail keeps delivering Moved events larger than the gesture that spawned them, and animating those would smooth what the system already smoothed. Also skip the animation for jumps under a line. Inching a wheel one detent at a time lands there, and spreading a half-line move only adds lag to something that already reads as continuous. Tests are now shaped after the measured event streams rather than after an assumption about which variant each device sends. * chore: trigger a Claude review of this branch --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
@@ -63,6 +63,57 @@ 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.
|
||||
|
||||
macOS was no better, just differently broken: it reports a wheel mouse as
|
||||
*pixels* too — `hasPreciseScrollingDeltas` is set — so the fraction was never
|
||||
zero, but one detent arrives as a single ~103px event, which at a 21px line
|
||||
height is a five-line jump applied in one go. Worse than Windows, and
|
||||
invisible to any check based on the delta's type.
|
||||
|
||||
A wheel detent 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 detent 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; putting an animation between the fingers and
|
||||
the grid would only add lag. What separates the two is **phase**, not delta
|
||||
type and not delta size — measured on this machine, a trackpad flick moves up
|
||||
to ~3 lines in one event while an inched wheel moves ~0.6, so size overlaps
|
||||
badly, but only a device that can gesture ever reports `Started`/`Ended`, and
|
||||
a wheel is `Moved` forever on every platform. The gesture is then held open
|
||||
on a 150ms idle timer rather than closed on `Ended`, because lifting the
|
||||
fingers is not the end of the stream: the momentum tail keeps delivering
|
||||
`Moved` events *larger* than the gesture itself, and animating those would
|
||||
smooth what the system is already smoothing.
|
||||
|
||||
Two more things stay direct. A jump under a line reads as continuous already
|
||||
— inching a wheel one detent at a time lands there — and mouse reporting and
|
||||
alternate-scroll forward whole lines to the application, which cannot be
|
||||
spread over frames at all.
|
||||
|
||||
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
|
||||
|
||||
@@ -207,6 +207,11 @@ pub struct Config {
|
||||
pub mouse_hide_while_typing: bool,
|
||||
pub focus_follows_mouse: bool,
|
||||
pub mouse_scroll_multiplier: f32,
|
||||
/// Spread a wheel detent over several frames instead of jumping the whole
|
||||
/// distance at once. Trackpad gestures are left alone — they are already a
|
||||
/// continuous stream, and animating one 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,
|
||||
@@ -467,6 +472,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,
|
||||
|
||||
+359
-3
@@ -85,6 +85,33 @@ 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;
|
||||
/// A jump smaller than this reads as continuous already; spreading it would
|
||||
/// only put lag between the hand and the grid. Inching a wheel one detent at a
|
||||
/// time lands here, and so does every event a trackpad sends.
|
||||
const SCROLL_ANIM_MIN_JUMP: f32 = 1.0;
|
||||
/// How long a trackpad gesture stays "live" after its last event. Long enough
|
||||
/// to bridge the gaps in a momentum tail, short enough that reaching for the
|
||||
/// wheel right after a swipe is not mistaken for more of the swipe.
|
||||
const SCROLL_GESTURE_IDLE: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
|
||||
fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool {
|
||||
match pane_runs_remotely {
|
||||
false => host_is_local,
|
||||
@@ -118,6 +145,9 @@ pub struct TerminalView {
|
||||
selecting: bool,
|
||||
drag_scroll: Option<DragScroll>,
|
||||
drag_scroll_epoch: u64,
|
||||
scroll_anim: Option<ScrollAnim>,
|
||||
scroll_anim_epoch: u64,
|
||||
gesture_until: Option<std::time::Instant>,
|
||||
pub title: String,
|
||||
pub marked_text: String,
|
||||
last_mouse_cell: Option<(usize, usize)>,
|
||||
@@ -915,6 +945,9 @@ impl TerminalView {
|
||||
selecting: false,
|
||||
drag_scroll: None,
|
||||
drag_scroll_epoch: 0,
|
||||
scroll_anim: None,
|
||||
scroll_anim_epoch: 0,
|
||||
gesture_until: None,
|
||||
title: "tty7".to_string(),
|
||||
marked_text: String::new(),
|
||||
last_mouse_cell: None,
|
||||
@@ -1904,6 +1937,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 +2105,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 +2435,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 +3954,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)));
|
||||
@@ -3968,6 +4005,7 @@ impl TerminalView {
|
||||
ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(),
|
||||
};
|
||||
let delta = raw * mult;
|
||||
let gesturing = self.track_scroll_gesture(ev.touch_phase);
|
||||
|
||||
let quantized = !ev.modifiers.shift && {
|
||||
let mode = *self.terminal.term.lock().mode();
|
||||
@@ -3975,6 +4013,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 +4025,110 @@ impl TerminalView {
|
||||
return;
|
||||
}
|
||||
|
||||
self.smooth_scroll(delta, cx);
|
||||
if self.should_animate_scroll(delta, gesturing, cx) {
|
||||
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>) {
|
||||
/// Track whether the pointing device is mid-gesture, which is what tells a
|
||||
/// trackpad apart from a wheel.
|
||||
///
|
||||
/// Not the delta *type*: macOS reports a wheel mouse as pixels too — one
|
||||
/// notch arrives as a single ~100px event — so `Pixels` says nothing about
|
||||
/// the device. Phase does: only devices that can gesture ever report
|
||||
/// `Started`/`Ended`, and a wheel is `Moved` forever, on every platform.
|
||||
///
|
||||
/// The gesture is held open on a timer rather than closed on `Ended`,
|
||||
/// because lifting the fingers is not the end of the stream — the momentum
|
||||
/// tail keeps delivering `Moved` events, larger than the gesture itself,
|
||||
/// and animating those would put a second layer of smoothing on scrolling
|
||||
/// the system is already smoothing.
|
||||
fn track_scroll_gesture(&mut self, phase: gpui::TouchPhase) -> bool {
|
||||
let now = std::time::Instant::now();
|
||||
let live = matches!(phase, gpui::TouchPhase::Started)
|
||||
|| self.gesture_until.is_some_and(|until| now < until);
|
||||
self.gesture_until = live.then(|| now + SCROLL_GESTURE_IDLE);
|
||||
live
|
||||
}
|
||||
|
||||
fn should_animate_scroll(&self, delta: f32, gesturing: bool, cx: &App) -> bool {
|
||||
if gesturing || !cx.global::<Config>().smooth_scroll {
|
||||
return false;
|
||||
}
|
||||
// Anything already in flight keeps accumulating, or a slow notch
|
||||
// arriving mid-animation would fight the frames still to come.
|
||||
self.scroll_anim.is_some() || delta.abs() >= SCROLL_ANIM_MIN_JUMP
|
||||
}
|
||||
|
||||
/// 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 +4140,9 @@ impl TerminalView {
|
||||
if jump != 0 || frac != self.scroll_frac {
|
||||
self.scroll_frac = frac;
|
||||
cx.notify();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn grid_line(
|
||||
@@ -5357,6 +5500,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 +5529,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 +6261,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 +6691,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 +8160,145 @@ mod gpui_tests {
|
||||
view.terminal.term.lock().grid().display_offset()
|
||||
}
|
||||
|
||||
/// Shaped after what macOS actually delivers, measured on a wheel mouse and
|
||||
/// a trackpad: both arrive as pixels, and only the trackpad ever reports a
|
||||
/// phase. One wheel detent is ~103px, roughly five lines at a 21px line
|
||||
/// height; a trackpad event is a fraction of that but can reach ~3 lines
|
||||
/// when flicked, which is why phase and not size decides.
|
||||
fn wheel(view: &TerminalView, lines: f32, phase: gpui::TouchPhase) -> ScrollWheelEvent {
|
||||
ScrollWheelEvent {
|
||||
delta: gpui::ScrollDelta::Pixels(point(px(0.), px(lines * view.line_height.as_f32()))),
|
||||
touch_phase: phase,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn notch(view: &TerminalView, lines: f32) -> ScrollWheelEvent {
|
||||
wheel(view, lines, gpui::TouchPhase::Moved)
|
||||
}
|
||||
|
||||
/// The whole point of the animation: a detent 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);
|
||||
let ev = notch(view, -4.9);
|
||||
view.on_scroll(&ev, w, cx);
|
||||
assert_eq!(
|
||||
display_offset(view),
|
||||
10,
|
||||
"the detent 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. It is told apart by its phase, not
|
||||
/// by its delta type or size: a flick moves further in one event than a
|
||||
/// slowly inched wheel does.
|
||||
#[gpui::test]
|
||||
fn a_trackpad_gesture_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 ev = wheel(view, -3., gpui::TouchPhase::Started);
|
||||
view.on_scroll(&ev, w, cx);
|
||||
assert_eq!(display_offset(view), 7, "the gesture was held back");
|
||||
assert!(
|
||||
view.scroll_anim.is_none(),
|
||||
"a trackpad started an animation"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Lifting the fingers does not end the stream: macOS keeps sending Moved
|
||||
/// events for the momentum tail, and they are *larger* than the gesture
|
||||
/// that spawned them. Treating those as a wheel would smooth what the
|
||||
/// system is already smoothing.
|
||||
#[gpui::test]
|
||||
fn a_momentum_tail_is_not_mistaken_for_a_wheel(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, w, cx| {
|
||||
scroll_into_history(view, 10);
|
||||
for (lines, phase) in [
|
||||
(-0.5, gpui::TouchPhase::Started),
|
||||
(-1.2, gpui::TouchPhase::Moved),
|
||||
(0., gpui::TouchPhase::Ended),
|
||||
(-2.9, gpui::TouchPhase::Moved),
|
||||
(-2.4, gpui::TouchPhase::Moved),
|
||||
] {
|
||||
let ev = wheel(view, lines, phase);
|
||||
view.on_scroll(&ev, w, cx);
|
||||
assert!(
|
||||
view.scroll_anim.is_none(),
|
||||
"the momentum tail was animated at {lines} lines"
|
||||
);
|
||||
}
|
||||
assert_eq!(display_offset(view), 3, "the tail did not all land");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Inching the wheel one detent at a time reads as continuous already.
|
||||
#[gpui::test]
|
||||
fn a_scroll_too_small_to_see_jump_stays_direct(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, w, cx| {
|
||||
scroll_into_history(view, 10);
|
||||
let ev = notch(view, -0.57);
|
||||
view.on_scroll(&ev, w, cx);
|
||||
assert!(view.scroll_anim.is_none(), "half a line was animated");
|
||||
assert!(view.scroll_frac > 0., "and it did not move either");
|
||||
})
|
||||
.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);
|
||||
let ev = notch(view, -3.);
|
||||
view.on_scroll(&ev, 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);
|
||||
let ev = notch(view, -4.9);
|
||||
view.on_scroll(&ev, 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);
|
||||
|
||||
@@ -2095,6 +2095,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);
|
||||
}
|
||||
|
||||
@@ -288,6 +288,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.",
|
||||
|
||||
@@ -288,6 +288,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 => {
|
||||
|
||||
@@ -281,6 +281,8 @@ pub enum L10nKey {
|
||||
SettingsScrollbackDesc,
|
||||
SettingsScrollSpeed,
|
||||
SettingsScrollSpeedDesc,
|
||||
SettingsSmoothScroll,
|
||||
SettingsSmoothScrollDesc,
|
||||
SettingsMouse,
|
||||
SettingsFocusFollowsMouse,
|
||||
SettingsFocusFollowsMouseDesc,
|
||||
|
||||
@@ -252,6 +252,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 => "悬停窗格即聚焦,无需点击。",
|
||||
|
||||
@@ -3388,6 +3388,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 {
|
||||
@@ -3488,6 +3489,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))
|
||||
@@ -3505,6 +3510,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(
|
||||
|
||||
Reference in New Issue
Block a user