git.lucas.co / cce-terminal
terminal emulator
git clone https://git.lucas.co/cce-terminal.git

commitc42c7b3ecf5434ef115384eaf0887684999020fb
parented42cfd4b3
authorLucas Galante <[email protected]>
date2026-09-04 09:58
feat: glide and coast the scrollback through cce-ui's ScrollMotion

The wheel used to quantize notches to whole lines on the spot and jump the
view. Scrollback scrolling now feeds a ScrollMotion kept in line units
(notch = SCROLL_LINES_PER_NOTCH, bounds = the grid's history length):
notches glide, a trackpad tracks 1:1 and its flick coasts through the
history. Each frame the Term is stepped by Scroll::Delta to the motion's
rounded offset, so drawing stays line-quantized — only the offset is
smooth. View moves made behind the motion's back (PageUp/Down, the snap to
bottom, output pushing a held view up) are adopted before every step.

The mouse-report and alternate-scroll paths are protocol-driven and keep
the whole-line accumulator.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/main.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 85 insertions(+), 11 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index b175574..ea9c165 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -49,7 +49,7 @@ use alacritty_terminal::vte::ansi::{
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{DisplayList, PaintCtx, TextAttrs};
-use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
+use cce_ui::widget::{Bounds, ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, ScrollMotion};
 use wayland_client::QueueHandle;
 
 const INIT_W: u32 = 840;
@@ -191,8 +191,15 @@ struct TerminalApp {
     rows: u16,
     /// OSC title; polled by the engine through `settings().title`.
     title: Option<String>,
-    /// Fractional wheel-scroll remainder (trackpad pixel deltas).
+    /// Fractional wheel remainder for the whole-line wheel protocols (mouse
+    /// reports, alternate-scroll arrows), where a trackpad's pixel deltas
+    /// have to add up to a line before anything is sent.
     scroll_accum: f32,
+    /// Scrollback view motion in LINE units: `y.pos()` is the float display
+    /// offset — a notch glides it, a trackpad tracks it 1:1 and its flick
+    /// coasts (cce-ui's ScrollMotion) — and each frame the Term is stepped to
+    /// its rounding. Drawing stays line-quantized; only the offset is smooth.
+    scroll_motion: ScrollMotion,
     focused: bool,
     /// Left button held: pointer moves extend the selection.
     selecting: bool,
@@ -281,6 +288,44 @@ impl TerminalApp {
         }
     }
 
+    /// Quantize wheel motion for the whole-line protocols, carrying the
+    /// fractional remainder across events.
+    fn take_whole_lines(&mut self, lines: f32) -> Option<i32> {
+        self.scroll_accum += lines;
+        let whole = self.scroll_accum as i32;
+        if whole == 0 {
+            return None;
+        }
+        self.scroll_accum -= whole as f32;
+        Some(whole)
+    }
+
+    /// Adopt view moves made behind the motion's back — PageUp/PageDown, the
+    /// snap to the bottom on a keypress or paste, new output pushing a held
+    /// view up the history — so the motion resumes from where the view is.
+    fn sync_scroll_motion(&mut self) {
+        let offset = self.term.grid().display_offset() as f32;
+        if self.scroll_motion.y.pos().round() != offset {
+            self.scroll_motion.y.jump_to(offset);
+        }
+    }
+
+    /// The scrollback offset's range: 0 (live bottom) ..= history length.
+    fn scrollback_bounds(&self) -> Bounds {
+        Bounds::max(self.term.grid().history_size() as f32)
+    }
+
+    /// Step the Term to the motion's rounded offset; true if the view moved.
+    fn apply_scroll_motion(&mut self) -> bool {
+        let target = self.scroll_motion.y.pos().round() as i32;
+        let current = self.term.grid().display_offset() as i32;
+        if target == current {
+            return false;
+        }
+        self.term.scroll_display(Scroll::Delta(target - current));
+        true
+    }
+
     /// Send pasted text to the pty and snap the view to the bottom.
     fn paste(&mut self, text: &str) {
         let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE);
@@ -511,6 +556,7 @@ impl Application for TerminalApp {
             rows,
             title: None,
             scroll_accum: 0.0,
+            scroll_motion: ScrollMotion::new(),
             focused: true,
             selecting: false,
             last_click: None,
@@ -613,6 +659,20 @@ impl Application for TerminalApp {
             *needs_rebuild = true;
         }
 
+        // Wheel glide / trackpad coast through the scrollback: advance the
+        // line-unit motion and step the Term to its rounded offset, asking
+        // for frames while it is still moving.
+        if self.scroll_motion.is_animating() {
+            self.sync_scroll_motion();
+            if self.scroll_motion.is_animating() {
+                let bounds = self.scrollback_bounds();
+                self.scroll_motion.tick(dt, Bounds::max(0.0), bounds);
+                if self.apply_scroll_motion() || self.scroll_motion.is_animating() {
+                    *needs_rebuild = true;
+                }
+            }
+        }
+
         // Selection autoscroll: while a drag holds the pointer in the top or
         // bottom edge padding, scroll at a rate scaling with the overshoot
         // (motion events stop at the edge, so this is time-driven).
@@ -1071,15 +1131,13 @@ impl Application for TerminalApp {
         pos: LogicalPosition,
         needs_rebuild: &mut bool,
     ) {
-        self.scroll_accum += delta.notches_y() * SCROLL_LINES_PER_NOTCH;
-        let lines = self.scroll_accum as i32;
-        if lines == 0 {
-            return;
-        }
-        self.scroll_accum -= lines as f32;
+        // Lines, signed like the display offset: up is positive.
+        let lines = delta.notches_y() * SCROLL_LINES_PER_NOTCH;
 
-        // Wheel reports take precedence over alternate-scroll arrows.
+        // Wheel reports take precedence over alternate-scroll arrows. Both
+        // are whole-line protocols and stay quantized.
         if self.mouse_reporting() {
+            let Some(lines) = self.take_whole_lines(lines) else { return };
             let (col, row) = self.viewport_cell(pos);
             let code = if lines > 0 { 64 } else { 65 } + self.report_mods();
             for _ in 0..lines.unsigned_abs() {
@@ -1091,11 +1149,27 @@ impl Application for TerminalApp {
         let mode = *self.term.mode();
         if mode.contains(TermMode::ALT_SCREEN) && mode.contains(TermMode::ALTERNATE_SCROLL) {
             // Full-screen apps without mouse reporting get arrow keys.
+            let Some(lines) = self.take_whole_lines(lines) else { return };
             let key: &[u8] = if lines > 0 { b"\x1b[A" } else { b"\x1b[B" };
             let bytes = key.repeat(lines.unsigned_abs() as usize);
             self.write_pty(&bytes);
-        } else {
-            self.term.scroll_display(Scroll::Delta(lines));
+            return;
+        }
+
+        // Scrollback: feed the line-unit motion. `apply_px` rather than
+        // `apply` — that one's sign flip and 1:1 pixel path are for pixel
+        // offsets, whereas here up grows the offset and the delta is already
+        // in lines. A finger lift arrives as a zero delta and must reach the
+        // motion (it is what starts the coast), so no early return on zero.
+        self.sync_scroll_motion();
+        let bounds = self.scrollback_bounds();
+        let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
+        let moved = self.scroll_motion.apply_px(0.0, lines, discrete, Bounds::max(0.0), bounds);
+        if moved && self.apply_scroll_motion() {
+            *needs_rebuild = true;
+        }
+        if self.scroll_motion.is_animating() {
+            // A notch only moved the target; tick glides the view there.
             *needs_rebuild = true;
         }
     }