git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

commitf73531d8cda82a51a5b68528c6def4a599e31d07
parent2f7d208410
authorLucas Galante <[email protected]>
date2026-09-04 09:54
scroll: glide and coast the detail body through cce-ui's ScrollMotion

body_scroll is now driven by a ScrollMotion (cce-ui@0f84843) instead of the
inline -y*24 / raw-pixel add-and-clamp: a wheel notch moves the target and
tick_body_scroll (from Application::tick) glides the drawn offset there; a
trackpad finger tracks 1:1 and a flick coasts. The activity bump that raises
the bar still fires on the same "moved" signal. Page keys and Home/End glide
too — pages are taken from the motion's TARGET so a held key accumulates
into one motion. Direct writes (selection reset, thumb drag, the paint-time
clamp) are adopted by reconcile. The WPE webview branch is untouched: the
engine owns that scrolling. email_list (a ScrollRegion) was already ticked.

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

 src/main.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++---------------
 1 file changed, 52 insertions(+), 17 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 4a2eca2..0b26feb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,7 +7,7 @@ use cce_ui::cosmic_text::FontSystem;
 use cce_ui::engine::{Application, CursorIcon, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::widget::{
     MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost,
-    TextBox, Button, TextLabel, Key, Dropdown
+    TextBox, Button, TextLabel, Key, Dropdown, Bounds, ScrollMotion, LINE_PX
 };
 use cce_ui::context::UiContext;
 use native_tls::TlsConnector;
@@ -250,6 +250,10 @@ struct ClearEmailApp {
     /// wheel/keyboard scrolling to the pane. The sb_* pair is the scrollbar
     /// thumb drag (grab offset within the thumb), mirroring ScrollRegion.
     body_scroll: f32,
+    /// Drives `body_scroll` (the DRAWN offset): wheel notches glide it, a
+    /// trackpad flick coasts it. Direct writes — selection reset, thumb drag,
+    /// the paint-time clamp — are adopted by the motion on its next step.
+    body_motion: ScrollMotion,
     body_content_h: f32,
     detail_hovered: bool,
     body_sb_dragging: bool,
@@ -3358,6 +3362,20 @@ impl ClearEmailApp {
         (self.body_scroll - old).abs() > 0.01
     }
 
+    /// Advance the detail body's wheel glide / flick coast; true while the
+    /// offset is moving, so the frame loop keeps drawing until it settles.
+    fn tick_body_scroll(&mut self, dt: f32) -> bool {
+        self.body_motion.reconcile(0.0, self.body_scroll);
+        if !self.body_motion.is_animating() {
+            return false;
+        }
+        let (_, body_h) = self.detail_body_geom();
+        let max = (self.body_content_h - body_h).max(0.0);
+        let moved = self.body_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
+        self.body_scroll = self.body_motion.y.pos();
+        moved || self.body_motion.is_animating()
+    }
+
     fn emit_text_prims(&mut self, pc: &mut cce_ui::scene::paint::PaintCtx) {
         let mut labels = Vec::new();
 
@@ -3746,6 +3764,7 @@ impl Application for ClearEmailApp {
             synced_tags: std::collections::HashSet::new(),
             selected_email_id: None,
             body_scroll: 0.0,
+            body_motion: ScrollMotion::new(),
             body_content_h: 0.0,
             detail_hovered: false,
             body_sb_dragging: false,
@@ -4381,6 +4400,12 @@ impl Application for ClearEmailApp {
             *needs_rebuild = true;
             self.needs_rebuild = true;
         }
+        // The body's own glide/coast: the wheel only moves the target, this
+        // is what carries the drawn offset there.
+        if self.tick_body_scroll(dt) {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
         let body_holding = self.body_sb_activity.holding();
         let body_visible = self.body_scrollbar_geom().is_some();
         if self.body_sb_activity.tick(dt, body_visible, self.body_sb_dragging) || body_holding {
@@ -5469,15 +5494,14 @@ impl Application for ClearEmailApp {
                         self.webview.wheel(dx, dy, wx, wy);
                     }
                 } else {
-                    let dy = match delta {
-                        MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
-                        MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-                    };
                     let (_, body_h) = self.detail_body_geom();
                     let max = (self.body_content_h - body_h).max(0.0);
-                    let old = self.body_scroll;
-                    self.body_scroll = (self.body_scroll + dy).clamp(0.0, max);
-                    if (self.body_scroll - old).abs() > 0.01 {
+                    // A wheel notch moves the target (tick_body_scroll glides
+                    // the offset there); a trackpad finger moves the offset now.
+                    self.body_motion.reconcile(0.0, self.body_scroll);
+                    let moved = self.body_motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(max));
+                    self.body_scroll = self.body_motion.y.pos();
+                    if moved {
                         // The scroll raises the bar in the same frame.
                         self.body_sb_activity.bump();
                         self.body_sb_activity.recompute(true, self.body_sb_dragging);
@@ -5698,17 +5722,28 @@ impl Application for ClearEmailApp {
                 && event.state == ElementState::Pressed
             {
                 let max = (self.body_content_h - body_h).max(0.0);
-                let old = self.body_scroll;
-                match &event.logical_key {
+                let b = Bounds::max(max);
+                let s = cce_ui::widget::scroll_motion::scroll_settings();
+                // Pages glide from the current TARGET, so a held key
+                // accumulates into one motion rather than restarting it.
+                self.body_motion.reconcile(0.0, self.body_scroll);
+                let moved = match &event.logical_key {
                     // Up/Down belong to the selection; the body still has
                     // Page keys, Home/End, the wheel and its scrollbar.
-                    Key::Named(cce_ui::widget::NamedKey::PageDown) => self.body_scroll = (self.body_scroll + body_h).min(max),
-                    Key::Named(cce_ui::widget::NamedKey::PageUp) => self.body_scroll = (self.body_scroll - body_h).max(0.0),
-                    Key::Named(cce_ui::widget::NamedKey::Home) => self.body_scroll = 0.0,
-                    Key::Named(cce_ui::widget::NamedKey::End) => self.body_scroll = max,
-                    _ => {}
-                }
-                if (self.body_scroll - old).abs() > 0.01 {
+                    Key::Named(cce_ui::widget::NamedKey::PageDown) => {
+                        let t = self.body_motion.y.target() + body_h;
+                        self.body_motion.y.scroll_to(t, b, &s)
+                    }
+                    Key::Named(cce_ui::widget::NamedKey::PageUp) => {
+                        let t = self.body_motion.y.target() - body_h;
+                        self.body_motion.y.scroll_to(t, b, &s)
+                    }
+                    Key::Named(cce_ui::widget::NamedKey::Home) => self.body_motion.y.scroll_to(0.0, b, &s),
+                    Key::Named(cce_ui::widget::NamedKey::End) => self.body_motion.y.scroll_to(max, b, &s),
+                    _ => false,
+                };
+                self.body_scroll = self.body_motion.y.pos();
+                if moved {
                     handled = true;
                 }
             }