git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit82845a3a789b9ecec15b26fc4898f52d58294251
parent4d3787832e
authorLucas Galante <[email protected]>
date2026-09-04 09:54
feat: glide and coast the row list and text preview through ScrollMotion

Both app-owned scrollers move through cce-ui's ScrollMotion (cce-ui@0f84843)
now: a wheel notch moves the target, a new tick(dt) on each — pumped from
Application::tick — glides the drawn offset there, a trackpad finger tracks
1:1 and a flick coasts.

RowList keeps scroll_y as the drawn value; thumb drags, scroll_into_view and
the bounds clamp are adopted by reconcile. The rows' hover is re-derived when
they slide under a still pointer. The wheel test settles the motion before
asserting the two-notch distance.

PreviewPane's line-indexed scroll_line becomes a pixel offset (scroll_px):
the paint derives the first whole line plus the sub-line remainder and clips
each line to the content box, so a multi-notch wheel slides through the
lines instead of stepping. A notch is still three lines (45px) and a pixel
delta is still pixels; PreviewLoaded resets through reset_scroll().

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

 src/main.rs          | 17 ++++++++++
 src/pages/mod.rs     |  6 ++++
 src/pages/preview.rs |  2 +-
 src/preview_pane.rs  | 93 ++++++++++++++++++++++++++++++++++++++--------------
 src/row_list.rs      | 40 +++++++++++++++++-----
 5 files changed, 123 insertions(+), 35 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 5d7e5cd..2fbb043 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1531,6 +1531,20 @@ impl Application for FilesystemApp {
             *needs_rebuild = true;
             self.needs_rebuild = true;
         }
+
+        // The app-owned scrollers' wheel glide / flick coast: the wheel only
+        // moves their target, these ticks carry the drawn offsets there, so
+        // frames must keep coming while either is live.
+        if self.browse.list.tick(dt) {
+            // Rows slid under a still pointer: re-derive the hover.
+            self.browse.list.cursor_moved(self.cursor_x, self.cursor_y);
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+        if self.preview.tick(dt) {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
     }
 
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
@@ -2432,6 +2446,9 @@ impl Application for FilesystemApp {
                 eprintln!("[scroll] files: browse.list.wheel at ({:.0},{:.0}) -> {hit}", pos.x, pos.y);
             }
             if hit {
+                // A trackpad finger moves the rows now: keep the hover on the
+                // row under the pointer (a wheel glide does this in tick).
+                self.browse.list.cursor_moved(self.cursor_x, self.cursor_y);
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index a6894d8..c96642c 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -319,6 +319,12 @@ impl PageContent {
         self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), None));
     }
 
+    /// `text_with_font` with an explicit clip box `[l, t, r, b]` — for text
+    /// that scrolls under an edge and must render cut, not culled.
+    pub fn text_with_font_bounded(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, bounds: [f32; 4]) {
+        self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), Some(bounds)));
+    }
+
     pub fn button(
         &mut self,
         label: &str,
diff --git a/src/pages/preview.rs b/src/pages/preview.rs
index 9c919f7..76af54d 100644
--- a/src/pages/preview.rs
+++ b/src/pages/preview.rs
@@ -36,7 +36,7 @@ pub fn update(state: &mut PreviewPane, msg: PreviewMessage) {
             state.target = data.target;
             state.content_preview = data.content_preview;
             state.set_image(data.image_preview.map(|img| (img.pixels, img.width, img.height)));
-            state.scroll_line = 0;
+            state.reset_scroll();
         }
     }
 }
diff --git a/src/preview_pane.rs b/src/preview_pane.rs
index e61fd65..bfb873d 100644
--- a/src/preview_pane.rs
+++ b/src/preview_pane.rs
@@ -14,7 +14,7 @@ use std::path::PathBuf;
 use cce_ui::layout::SectionContext;
 use cce_ui::scene::layout::{fit_rect, FitMode, Rect};
 use cce_ui::widget::display::{measure_text_width, truncate_tail};
-use cce_ui::widget::MouseScrollDelta;
+use cce_ui::widget::{Bounds, MouseScrollDelta, ScrollMotion};
 
 /// Truncate `s` to fit `avail` px, measured for real (resvg-backed, cached per
 /// string+size — the handful of details strings re-measure only on selection
@@ -48,6 +48,11 @@ fn truncate_px(s: &str, family: &str, size: f32, avail: f32, head: bool) -> Stri
 
 use crate::pages::PageContent;
 
+/// Pitch of the text preview's lines (11px monospace on a 15px advance).
+const PREVIEW_LINE_H: f32 = 15.0;
+/// One wheel notch moves the text preview three lines (the legacy speed).
+const PREVIEW_NOTCH_PX: f32 = 3.0 * PREVIEW_LINE_H;
+
 #[derive(Debug, Clone)]
 pub struct PreviewPane {
     rect: (f32, f32, f32, f32),
@@ -65,7 +70,12 @@ pub struct PreviewPane {
     /// exclusively through [`PreviewPane::set_image`] — the sole upload/free
     /// site, so a stale id can never leak against the renderer's image budget.
     image_tex: Option<(u32, u32, u32)>,
-    pub scroll_line: usize,
+    /// Text-preview scroll offset in pixels — the DRAWN value, which
+    /// `scroll_motion` glides (wheel) or coasts (trackpad flick). The paint
+    /// derives the first whole line and a sub-line remainder from it, so a
+    /// multi-notch wheel slides through the lines instead of stepping.
+    scroll_px: f32,
+    scroll_motion: ScrollMotion,
     /// Which of the pane's two wells holds pointer focus (the app's well-focus
     /// tracking): that well renders as the tinted carve — accent ring
     /// replacing the relief lighting.
@@ -95,7 +105,8 @@ impl Default for PreviewPane {
             target: String::new(),
             content_preview: None,
             image_tex: None,
-            scroll_line: 0,
+            scroll_px: 0.0,
+            scroll_motion: ScrollMotion::new(),
             focused_well: None,
         }
     }
@@ -164,10 +175,18 @@ impl PreviewPane {
         Some(self.scroll(delta, prev_h))
     }
 
-    fn scroll(&mut self, delta: &MouseScrollDelta, ch: f32) -> bool {
-        let content = match &self.content_preview {
-            Some(c) => c,
-            None => return false,
+    /// Back to the top, motion cancelled (a new file loaded).
+    pub fn reset_scroll(&mut self) {
+        self.scroll_px = 0.0;
+        self.scroll_motion = ScrollMotion::new();
+    }
+
+    /// How far the text preview can scroll, in pixels, for a pane `ch` tall:
+    /// the lines that don't fit the content well, times the line pitch. Zero
+    /// when there is no text or it all fits.
+    fn max_scroll_px(&self, ch: f32) -> f32 {
+        let Some(content) = &self.content_preview else {
+            return 0.0;
         };
         let total_lines = content.lines().count();
         let half_h = ch * 0.5;
@@ -177,23 +196,38 @@ impl PreviewPane {
             max_visible_lines += 1;
             text_y += 15.0;
         }
-        if total_lines <= max_visible_lines {
-            if self.scroll_line != 0 {
-                self.scroll_line = 0;
+        total_lines.saturating_sub(max_visible_lines) as f32 * PREVIEW_LINE_H
+    }
+
+    fn scroll(&mut self, delta: &MouseScrollDelta, ch: f32) -> bool {
+        let max = self.max_scroll_px(ch);
+        if max <= 0.0 {
+            if self.scroll_px != 0.0 {
+                self.reset_scroll();
                 return true;
             }
             return false;
         }
-        let max_scroll = total_lines.saturating_sub(max_visible_lines);
-        let scroll_speed = 3.0;
-        let diff = match delta {
-            MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
-            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32 / 15.0,
-        };
-        let prev_scroll = self.scroll_line;
-        let new_scroll = (self.scroll_line as f32 + diff).round() as isize;
-        self.scroll_line = new_scroll.clamp(0, max_scroll as isize) as usize;
-        self.scroll_line != prev_scroll
+        // A notch is three lines (the legacy `scroll_speed`); a pixel delta
+        // is pixels, as before (it used to be divided by the line pitch and
+        // rounded to whole lines).
+        self.scroll_motion.reconcile(0.0, self.scroll_px);
+        let moved = self.scroll_motion.apply(delta, (PREVIEW_NOTCH_PX, PREVIEW_NOTCH_PX), Bounds::max(0.0), Bounds::max(max));
+        self.scroll_px = self.scroll_motion.y.pos();
+        moved
+    }
+
+    /// Advance the text preview's wheel glide / flick coast; true while the
+    /// offset is moving, so the host keeps frames coming until it settles.
+    pub fn tick(&mut self, dt: f32) -> bool {
+        self.scroll_motion.reconcile(0.0, self.scroll_px);
+        if !self.scroll_motion.is_animating() {
+            return false;
+        }
+        let max = self.max_scroll_px(self.rect.3);
+        let moved = self.scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
+        self.scroll_px = self.scroll_motion.y.pos();
+        moved || self.scroll_motion.is_animating()
     }
 
     /// One pass: sections, fills, image runs, and text — every text emitted with
@@ -293,9 +327,18 @@ impl PreviewPane {
             );
             pc.image(id, fitted.x, fitted.y, fitted.width, fitted.height, 1.0);
         } else if let Some(content) = &self.content_preview {
-            let mut text_y = rect_y + 12.0;
-            for line in content.lines().skip(self.scroll_line) {
-                if text_y + 14.0 > rect_y + rect_h - 8.0 {
+            // The first whole line scrolled past, plus the sub-line remainder
+            // the glide is mid-way through: lines slide under the well's top
+            // and bottom edges, clipped to the content box so a partial line
+            // renders cut rather than popping.
+            let first_line = (self.scroll_px / PREVIEW_LINE_H).floor().max(0.0);
+            let frac = self.scroll_px - first_line * PREVIEW_LINE_H;
+            let clip_top = rect_y;
+            let clip_bottom = rect_y + rect_h - 8.0;
+            let clip = [cx, clip_top, cx + cw, clip_bottom];
+            let mut text_y = rect_y + 12.0 - frac;
+            for line in content.lines().skip(first_line as usize) {
+                if text_y >= clip_bottom {
                     break;
                 }
                 // Chars-per-width from one measured glyph (cached) instead of
@@ -303,8 +346,8 @@ impl PreviewPane {
                 let char_w = measure_text_width("M", "monospace", 11.0).max(1.0);
                 let limit = (((cw - 24.0) / char_w).floor() as usize).max(20);
                 let line_truncated = truncate_tail(line, limit);
-                pc.text_with_font(&line_truncated, cx + 12.0, text_y, 11.0, text_fg, "monospace");
-                text_y += 15.0;
+                pc.text_with_font_bounded(&line_truncated, cx + 12.0, text_y, 11.0, text_fg, "monospace", clip);
+                text_y += PREVIEW_LINE_H;
             }
         } else {
             pc.text("No preview available", cx + 12.0, rect_y + 12.0, 11.0, text_dim);
diff --git a/src/row_list.rs b/src/row_list.rs
index 0771588..b9221a8 100644
--- a/src/row_list.rs
+++ b/src/row_list.rs
@@ -19,7 +19,7 @@
 //! reinstate the exact Phase 6v sandwich this note describes. It would also be silent:
 //! the bg is translucent, so the overlays wash out rather than disappear.
 
-use cce_ui::widget::{Justification, MouseScrollDelta};
+use cce_ui::widget::{Bounds, Justification, MouseScrollDelta, ScrollMotion, LINE_PX};
 
 /// Column sizing (moved here with the cce-ui `List` deletion — RowList is the only
 /// remaining consumer of the column model).
@@ -55,7 +55,11 @@ pub struct RowList {
     /// Row height with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
     pub item_height: f32,
     pub item_gap: f32,
+    /// The DRAWN offset — `motion` glides it (wheel) or coasts it (trackpad
+    /// flick); direct writes (thumb drag, scroll_into_view, the clamp) are
+    /// adopted by the motion on its next step.
     pub scroll_y: f32,
+    motion: ScrollMotion,
     pub content_h: f32,
     pub columns: Vec<ListColumn>,
     pub rows: Vec<Row>,
@@ -83,6 +87,7 @@ impl RowList {
             item_height: item_height.max(font_size + 14.0),
             item_gap,
             scroll_y: 0.0,
+            motion: ScrollMotion::new(),
             content_h: 0.0,
             columns: Vec::new(),
             rows: Vec::new(),
@@ -334,18 +339,29 @@ impl RowList {
         }
     }
 
-    /// Hit-scoped wheel (`ScrollBox::mouse_wheel`).
+    /// Hit-scoped wheel (`ScrollBox::mouse_wheel`). A wheel notch moves the
+    /// target and [`Self::tick`] glides the offset there; a trackpad finger
+    /// moves the offset now. True when either moved (repaint).
     pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
         if !self.hit(px, py) {
             return false;
         }
-        let dy = match delta {
-            MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
-            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-        };
-        let old = self.scroll_y;
-        self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
-        (self.scroll_y - old).abs() > 0.01
+        self.motion.reconcile(0.0, self.scroll_y);
+        let moved = self.motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll()));
+        self.scroll_y = self.motion.y.pos();
+        moved
+    }
+
+    /// Advance the wheel glide / flick coast; true while the offset is
+    /// moving, so the host keeps frames coming until it settles.
+    pub fn tick(&mut self, dt: f32) -> bool {
+        self.motion.reconcile(0.0, self.scroll_y);
+        if !self.motion.is_animating() {
+            return false;
+        }
+        let moved = self.motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
+        self.scroll_y = self.motion.y.pos();
+        moved || self.motion.is_animating()
     }
 
     // ── Paint ──
@@ -532,6 +548,12 @@ mod tests {
     fn wheel_scrolls_and_scroll_into_view_clamps() {
         let mut l = list_with_rows(50);
         assert!(l.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
+        // Two notches glide to 2 * LINE_PX; settle the motion first.
+        for _ in 0..600 {
+            if !l.tick(1.0 / 60.0) {
+                break;
+            }
+        }
         assert_eq!(l.scroll_y, 48.0);
         l.scroll_into_view(0);
         assert_eq!(l.scroll_y, 2.0);