diff --git a/CHANGELOG.md b/CHANGELOG.md index 2828a55c..610487a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 927c6356..8c2f2156 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -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, diff --git a/src/terminal/view.rs b/src/terminal/view.rs index c22b2328..4ba19dc7 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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, drag_scroll_epoch: u64, + scroll_anim: Option, + scroll_anim_epoch: u64, + gesture_until: Option, 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.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) { + /// 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::().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) { + 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) -> 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) -> 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, 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::().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); diff --git a/src/ui/app.rs b/src/ui/app.rs index eae9ffc9..042dc9c9 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2095,6 +2095,10 @@ impl Tty7App { }); } + pub(crate) fn set_smooth_scroll(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.smooth_scroll = on); + } + pub(crate) fn set_clipboard_trim(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.clipboard_trim_trailing_spaces = on); } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 72295f10..16f85910 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -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.", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a2dda072..0ab32695 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -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 => { diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index b74e6e8a..5c13cce5 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -281,6 +281,8 @@ pub enum L10nKey { SettingsScrollbackDesc, SettingsScrollSpeed, SettingsScrollSpeedDesc, + SettingsSmoothScroll, + SettingsSmoothScrollDesc, SettingsMouse, SettingsFocusFollowsMouse, SettingsFocusFollowsMouseDesc, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 864a8b22..03a9dfdd 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -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 => "悬停窗格即聚焦,无需点击。", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 22c73a90..3a893306 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -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(