file manager
git clone https://git.lucas.co/cce-files.git
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);