From 3c3c069650d5bb31e8f89cb59929f8b5f63cf5f8 Mon Sep 17 00:00:00 2001 From: wenlingang Date: Tue, 22 Sep 2026 16:13:32 +0800 Subject: [PATCH 1/2] fix(terminal): let a flick's momentum tail keep the gesture it started as (#912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS stamps every event of a momentum tail with the modifiers held at delivery, so grabbing ⌘ while a two-finger flick was still coasting turned the rest of a plain scroll into a zoom — dozens of font steps in a few frames, down to FONT_SIZE_MIN and saved to config.json, which is why it outlived a relaunch and read as the font randomly shrinking. A trackpad gesture now answers "scroll or zoom?" once, at its first event, and holds that answer until the stream dies. A wheel has no gesture to belong to and still decides notch by notch. The latch is symmetric: a zoom gesture that outlives its modifier keeps zooming rather than dumping its tail into the scrollback. Co-Authored-By: Claude Opus 5 (1M context) --- src/terminal/view.rs | 81 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1c82402e..4bfe914e 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -336,6 +336,10 @@ pub struct TerminalView { scroll_anim: Option, scroll_anim_epoch: u64, gesture_until: Option, + /// Whether the trackpad gesture in flight is zooming, latched at its first + /// event. `None` between gestures, and never set for a wheel, which has no + /// gesture to belong to and decides notch by notch. + gesture_zoom: Option, pub title: String, /// A title the pane has been told about but has not adopted yet — see /// `set_title_when_settled`. `None` means the tab is showing the newest @@ -1579,6 +1583,7 @@ impl TerminalView { scroll_anim: None, scroll_anim_epoch: 0, gesture_until: None, + gesture_zoom: None, title: DEFAULT_TITLE.to_string(), pending_title: None, default_title: DEFAULT_TITLE.to_string(), @@ -5469,12 +5474,31 @@ impl TerminalView { } fn on_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + let gesturing = self.track_scroll_gesture(ev.touch_phase); // One modifier turns the wheel into a zoom, the way it does in a // browser. Which one is the user's to say, because the default is the // platform modifier and on macOS that is a key half the world is // already holding for something else (#668). - if zoom_wheel(cx.global::().mouse_zoom_modifier, &ev.modifiers) { - self.zoom_scroll(ev, window, cx); + let wants_zoom = zoom_wheel(cx.global::().mouse_zoom_modifier, &ev.modifiers); + // A trackpad gesture answers "scroll or zoom?" once, on its first + // event, and keeps that answer until the stream dies — momentum tail + // included. The tail is why: those events are the system's, not the + // hand's, yet each one is stamped with whatever modifiers happen to be + // down as it is delivered. Reaching for ⌘ during a flick's coast — + // ⌘-Tab, ⌘-C, anything — would otherwise turn hundreds of coasting + // lines into zoom steps and leave the font at its minimum, from a + // gesture that was never a zoom (#912). + let zoom = if gesturing { + *self.gesture_zoom.get_or_insert(wants_zoom) + } else { + self.gesture_zoom = None; + // Leftover travel belongs to the gesture that earned it; a new one + // must not start already part-way to a step. + self.zoom_debt = 0.; + wants_zoom + }; + if zoom { + self.zoom_scroll(ev, gesturing, window, cx); return; } let mult = cx.global::().mouse_scroll_multiplier; @@ -5483,7 +5507,6 @@ 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(); @@ -5519,7 +5542,13 @@ impl TerminalView { /// asked for the wheel. Steps go out as the same actions the keyboard and /// the View menu send, so the min/max clamp and the saved setting live in /// one place — [`Tty7App::change_font_size`](crate::ui::app::Tty7App). - fn zoom_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + fn zoom_scroll( + &mut self, + ev: &ScrollWheelEvent, + gesturing: bool, + window: &mut Window, + cx: &mut Context, + ) { // Whatever the scrollback still had in flight is dropped: it was // travelling in lines of a font that is about to change size. self.cancel_scroll_anim(); @@ -5527,7 +5556,6 @@ impl TerminalView { ScrollDelta::Lines(p) => p.y, ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(), }; - let gesturing = self.track_scroll_gesture(ev.touch_phase); let (steps, debt) = zoom_scroll_steps(lines, self.zoom_debt, gesturing); self.zoom_debt = debt; for _ in 0..steps.unsigned_abs() { @@ -13262,6 +13290,49 @@ mod gpui_tests { .unwrap(); } + /// The bug this guards: a two-finger flick coasts long after the fingers + /// are gone, and every coasting event carries whatever modifiers are down + /// when it lands. Grabbing ⌘ for something else mid-coast used to read as + /// a zoom and run the font down to its minimum in a blink (#912). + #[gpui::test] + fn a_modifier_pressed_mid_flick_does_not_hijack_it_into_a_zoom(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + view.on_scroll(&wheel(view, -0.5, gpui::TouchPhase::Started), w, cx); + let before = display_offset(view); + // The tail, now stamped with ⌘ the hand reached for. + let mut tail = wheel(view, -3., gpui::TouchPhase::Moved); + tail.modifiers = Modifiers::secondary_key(); + view.on_scroll(&tail, w, cx); + assert!( + display_offset(view) != before || view.scroll_anim.is_some(), + "the tail stayed a scroll, the way the gesture started" + ); + assert_eq!(view.zoom_debt, 0., "and never paid into the zoom"); + }) + .unwrap(); + } + + /// The same latch the other way round: a zoom gesture that outlives the + /// modifier keeps zooming rather than dumping its tail into the buffer. + #[gpui::test] + fn a_zoom_gesture_keeps_zooming_after_the_modifier_is_released(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + let mut start = wheel(view, -0.5, gpui::TouchPhase::Started); + start.modifiers = Modifiers::secondary_key(); + view.on_scroll(&start, w, cx); + view.on_scroll(&wheel(view, -0.5, gpui::TouchPhase::Moved), w, cx); + assert_eq!(display_offset(view), 10, "the grid never moved"); + assert!(view.scroll_anim.is_none(), "and nothing was queued for it"); + }) + .unwrap(); + } + /// A detent is one step however many lines the platform bills it as — /// macOS calls a single notch five. #[test] From 0a32a6c38e4a4426904a5761854fe6a34c0f9706 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:12:11 +0800 Subject: [PATCH 2/2] fix(terminal): let the zoom latch die with its gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latch added for #912 was cleared only on an event that was not part of a gesture — but a new gesture's first event is `Started`, which `track_scroll_gesture` reports as live, so the previous gesture's answer was still sitting there for `get_or_insert` to find. That is the same bug wearing the other coat: one ⌘-zoom, and every later two-finger flick kept zooming with nothing held at all, until some wheel notch or a stray non-gesture event happened to clear it. Deterministic, where the original was merely likely. Reset the latch (and the leftover zoom debt) when fingers go back down, so each gesture answers "scroll or zoom?" for itself. Guard: `a_new_gesture_is_not_bound_by_what_the_last_one_answered`, verified red against the commit it fixes. Claude-Session: https://claude.ai/code/session_01FG2s9mbZu6LbjjmU54X7kt --- src/terminal/view.rs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 4bfe914e..43ef29d6 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -5488,13 +5488,20 @@ impl TerminalView { // ⌘-Tab, ⌘-C, anything — would otherwise turn hundreds of coasting // lines into zoom steps and leave the font at its minimum, from a // gesture that was never a zoom (#912). - let zoom = if gesturing { - *self.gesture_zoom.get_or_insert(wants_zoom) - } else { + // The answer is latched per gesture, not per stream: fingers going + // back down ask it again. Carrying it over would be the same bug + // wearing the other coat — one ⌘-zoom, and every later flick zooms + // with nothing held at all, because `Started` keeps the gesture live + // and would find the old answer still sitting there. + if !gesturing || matches!(ev.touch_phase, gpui::TouchPhase::Started) { self.gesture_zoom = None; // Leftover travel belongs to the gesture that earned it; a new one // must not start already part-way to a step. self.zoom_debt = 0.; + } + let zoom = if gesturing { + *self.gesture_zoom.get_or_insert(wants_zoom) + } else { wants_zoom }; if zoom { @@ -13333,6 +13340,30 @@ mod gpui_tests { .unwrap(); } + /// The latch is per gesture, not per stream: fingers going down again + /// start a fresh question. Carrying the last answer over would mean one + /// ⌘-zoom left every later flick zooming with no modifier held at all. + #[gpui::test] + fn a_new_gesture_is_not_bound_by_what_the_last_one_answered(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + let mut zoom = wheel(view, -0.5, gpui::TouchPhase::Started); + zoom.modifiers = Modifiers::secondary_key(); + view.on_scroll(&zoom, w, cx); + assert_eq!(display_offset(view), 10, "that one was a zoom"); + // Fingers down again, nothing held: a plain scroll. + view.on_scroll(&wheel(view, -3., gpui::TouchPhase::Started), w, cx); + assert_ne!( + display_offset(view), + 10, + "the new gesture asked the question again" + ); + }) + .unwrap(); + } + /// A detent is one step however many lines the platform bills it as — /// macOS calls a single notch five. #[test]