git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commitd985d810b047341847cbc7d5f995311ebe546fa0
parent27ef121dd3
authorLucas Galante <[email protected]>
date2026-09-04 09:57
scroll: synthesize wheel/finger phases like the runner; glide the JSON pages

The launcher runs its own Wayland loop, so it never got cce-ui's
smooth-scroll input path. The Axis handler used to fold every event into
`LineDelta(absolute / 10)`, which made a trackpad swipe a staircase of
notches and told the ScrollRegion nothing about the gesture. It now does
what cce-ui's runner does (window_runner.rs, `axis_stop`): discrete steps
become `LineDelta` notches, anything else is `PixelDelta` 1:1, a bare stop
is the finger lift, the phase is published through `set_scroll_phase`
before the dispatch, and the per-app `scroll_factors()` apply. The list's
ScrollRegion (already ticked from both frame loops, whose `render`
re-tessellates) glides, tracks and flings from there.

JsonLayoutWidget's per-page `page_scroll_y` gets a `ScrollMotion` per page
driving it: the wheel glides, PageUp/Down/Home/End glide via `scroll_to`,
the arrows accumulate like notches, and a `tick_scroll` reached from
`tick_children` (the `jl.tick` the frame loops already call) carries the
drawn offset and keeps frames coming while it moves. The wheel is also now
routed to the widget at all in JSON mode — only the fuzzel list ever
received it, so the page-scroll code was unreachable.

Also fixes the test target, which had not compiled since `AppInfo` grew
`icon`: the three initializers in `test_filter_and_sort_preserving_history`
now pass `icon: None`.

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

 src/json_layout.rs | 112 ++++++++++++++++++++++++++++++++++++-----------------
 src/main.rs        |  66 +++++++++++++++++++++++++++----
 2 files changed, 135 insertions(+), 43 deletions(-)

diff --git a/src/json_layout.rs b/src/json_layout.rs
index b7fcf8d..12da715 100644
--- a/src/json_layout.rs
+++ b/src/json_layout.rs
@@ -4,6 +4,7 @@
 //! walk reaches it as the adapter, whose subtree text pass-through forwards `paint`'s
 //! prims verbatim. `Justification` stayed in cce-ui (Button, cce-files, settings).
 
+use cce_ui::widget::scroll_motion::{scroll_settings, Bounds, ScrollMotion, LINE_PX};
 use cce_ui::widget::{
     WidgetHost, Widget, Checkbox, Button, Label, Spinbox, ColorSelector, TextLabel, MouseButton, ElementState, Slider, Event, UiContext,
     Key, NamedKey, Justification,
@@ -109,7 +110,12 @@ pub struct JsonLayoutWidget {
     base: Widget,
     pub widgets: Vec<JsonWidget>,
     pub dragging_slider_idx: Option<usize>,
+    /// Per-page DRAWN scroll offset; `page_scroll` drives it.
     pub page_scroll_y: Vec<f32>,
+    /// Per-page scroll motion: wheel notches glide, fingers track 1:1 and
+    /// fling on the lift, keyboard pages glide. `tick_scroll` carries the
+    /// drawn offset after it each frame.
+    pub page_scroll: Vec<ScrollMotion>,
     pub page_total_heights: Vec<f32>,
     pub active_page: usize,
 }
@@ -271,6 +277,7 @@ impl JsonLayoutWidget {
             widgets,
             dragging_slider_idx: None,
             page_scroll_y: vec![0.0; 16],
+            page_scroll: vec![ScrollMotion::new(); 16],
             page_total_heights: vec![0.0; 16],
             active_page: 0,
         })
@@ -352,9 +359,47 @@ impl JsonLayoutWidget {
         (self.base.x, self.base.y, self.base.w, self.base.h)
     }
 
+    /// The active page's wheel range: `0..=overflow`.
+    fn page_scroll_bounds(&self, page: usize) -> Bounds {
+        let (_, _, _, bh) = self.rect();
+        let total = self.page_total_heights.get(page).copied().unwrap_or(0.0);
+        Bounds::max(total - bh)
+    }
+
+    /// Copy a page's motion position into its drawn offset; true if it moved
+    /// (the caller re-lays the children out).
+    fn sync_page_scroll(&mut self, page: usize) -> bool {
+        let pos = self.page_scroll[page].y.pos();
+        let moved = (pos - self.page_scroll_y[page]).abs() > 1e-4;
+        self.page_scroll_y[page] = pos;
+        moved
+    }
+
+    /// Per-frame glide/coast of the active page's scroll. Reached from
+    /// `tick_children`, which the main loop calls (through `Adapted::tick`)
+    /// beside the launcher list's own `ScrollRegion::tick`. True while the
+    /// offset is still moving, so the demand-driven frame loop keeps drawing.
+    fn tick_scroll(&mut self, dt: f32) -> bool {
+        let page = self.active_page;
+        if page >= self.page_scroll.len() || page >= self.page_scroll_y.len() {
+            return false;
+        }
+        let host = self.page_scroll_y[page];
+        self.page_scroll[page].reconcile(0.0, host);
+        if !self.page_scroll[page].is_animating() {
+            return false;
+        }
+        let by = self.page_scroll_bounds(page);
+        let moved = self.page_scroll[page].tick(dt, Bounds::max(0.0), by);
+        if self.sync_page_scroll(page) {
+            self.layout_children();
+        }
+        moved || self.page_scroll[page].is_animating()
+    }
+
     /// Per-frame child state (the old `WidgetHost::tick` override): active-page widgets only.
     fn tick_children(&mut self, dt: f32, ctx: &mut UiContext) -> bool {
-        let mut changed = false;
+        let mut changed = self.tick_scroll(dt);
         let active_page = self.active_page;
         for w in &mut self.widgets {
             if w.page_idx != active_page {
@@ -487,16 +532,18 @@ impl JsonLayoutWidget {
                     let visible_h = bh;
                     let max_scroll_y = (total_height - visible_h).max(0.0);
                     if max_scroll_y > 0.0 {
-                        let scroll_amount = match delta {
-                            cce_ui::widget::MouseScrollDelta::LineDelta(_x, y) => -*y * 24.0,
-                            cce_ui::widget::MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-                        };
-                        let old_scroll = self.page_scroll_y[active_page];
-                        self.page_scroll_y[active_page] = (old_scroll + scroll_amount).clamp(0.0, max_scroll_y);
-                        if (self.page_scroll_y[active_page] - old_scroll).abs() > 0.01 {
-                            self.layout_children();
+                        // A notch is LINE_PX, pixels are 1:1; the motion
+                        // glides or tracks and `tick_scroll` carries the drawn
+                        // offset after it. A true return is the repaint signal
+                        // (the target moved even if the offset has not yet).
+                        let motion = &mut self.page_scroll[active_page];
+                        motion.reconcile(0.0, self.page_scroll_y[active_page]);
+                        if motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(max_scroll_y)) {
                             changed = true;
                         }
+                        if self.sync_page_scroll(active_page) {
+                            self.layout_children();
+                        }
                     }
                 }
             }
@@ -505,35 +552,30 @@ impl JsonLayoutWidget {
         if let Event::KeyInput(key_event) = event {
             if key_event.state == ElementState::Pressed {
                 if active_page < self.page_total_heights.len() {
-                    let total_height = self.page_total_heights[active_page];
                     let (_, _, _, bh) = self.rect();
-                    let max_scroll_y = (total_height - bh).max(0.0);
-                    if max_scroll_y > 0.0 {
-                        let old_scroll = self.page_scroll_y[active_page];
-                        match &key_event.logical_key {
-                            Key::Named(NamedKey::PageDown) => {
-                                self.page_scroll_y[active_page] = (old_scroll + bh).clamp(0.0, max_scroll_y);
-                            }
-                            Key::Named(NamedKey::PageUp) => {
-                                self.page_scroll_y[active_page] = (old_scroll - bh).clamp(0.0, max_scroll_y);
-                            }
-                            Key::Named(NamedKey::Home) => {
-                                self.page_scroll_y[active_page] = 0.0;
-                            }
-                            Key::Named(NamedKey::End) => {
-                                self.page_scroll_y[active_page] = max_scroll_y;
-                            }
-                            Key::Named(NamedKey::ArrowDown) => {
-                                self.page_scroll_y[active_page] = (old_scroll + 24.0).clamp(0.0, max_scroll_y);
-                            }
-                            Key::Named(NamedKey::ArrowUp) => {
-                                self.page_scroll_y[active_page] = (old_scroll - 24.0).clamp(0.0, max_scroll_y);
-                            }
-                            _ => {}
+                    let by = self.page_scroll_bounds(active_page);
+                    if by.hi > 0.0 {
+                        // Pages and Home/End glide to their target; the arrows
+                        // step a line and accumulate like wheel notches (the
+                        // toolkit ScrollRegion's keyboard contract).
+                        let s = scroll_settings();
+                        let motion = &mut self.page_scroll[active_page];
+                        motion.reconcile(0.0, self.page_scroll_y[active_page]);
+                        let target = motion.y.target();
+                        let moved = match &key_event.logical_key {
+                            Key::Named(NamedKey::PageDown) => motion.y.scroll_to(target + bh, by, &s),
+                            Key::Named(NamedKey::PageUp) => motion.y.scroll_to(target - bh, by, &s),
+                            Key::Named(NamedKey::Home) => motion.y.scroll_to(0.0, by, &s),
+                            Key::Named(NamedKey::End) => motion.y.scroll_to(by.hi, by, &s),
+                            Key::Named(NamedKey::ArrowDown) => motion.y.wheel(LINE_PX, by, &s),
+                            Key::Named(NamedKey::ArrowUp) => motion.y.wheel(-LINE_PX, by, &s),
+                            _ => false,
+                        };
+                        if moved {
+                            changed = true;
                         }
-                        if (self.page_scroll_y[active_page] - old_scroll).abs() > 0.01 {
+                        if self.sync_page_scroll(active_page) {
                             self.layout_children();
-                            changed = true;
                         }
                     }
                 }
diff --git a/src/main.rs b/src/main.rs
index 78d78f8..41ec2c7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2156,11 +2156,61 @@ impl PointerHandler for AppState {
                             }
                         }
                     }
-                    PointerEventKind::Axis { horizontal, vertical, .. } => {
-                        let h_scroll = horizontal.absolute as f32;
-                        let v_scroll = vertical.absolute as f32;
-                        let delta = cce_ui::widget::MouseScrollDelta::LineDelta(-h_scroll / 10.0, -v_scroll / 10.0);
-                        if st.fuzzel.scroll_box.wheel(&delta, st.cursor_x, st.cursor_y) {
+                    PointerEventKind::Axis { horizontal, vertical, source, .. } => {
+                        // Synthesized the way cce-ui's runner does it
+                        // (window_runner.rs, `axis_stop`): discrete steps are
+                        // wheel notches (LineDelta), anything else is pixels
+                        // 1:1 (PixelDelta), and a bare stop is the finger
+                        // lift. The phase is published before the dispatch so
+                        // the ScrollMotion under each scroll host knows whether
+                        // to glide a notch, track a finger, or fling.
+                        use cce_ui::widget::scroll_motion::{set_scroll_phase, ScrollPhase};
+                        let factors = cce_ui::input::scroll_factors();
+                        let discrete = horizontal.discrete != 0 || vertical.discrete != 0;
+                        let no_delta = !discrete && horizontal.absolute == 0.0 && vertical.absolute == 0.0;
+                        let stop = horizontal.stop || vertical.stop;
+                        let phase = if stop && no_delta {
+                            ScrollPhase::FingerEnd
+                        } else if !discrete
+                            && matches!(
+                                source,
+                                None | Some(wl_pointer::AxisSource::Finger) | Some(wl_pointer::AxisSource::Continuous)
+                            )
+                        {
+                            ScrollPhase::Finger
+                        } else {
+                            ScrollPhase::Wheel
+                        };
+                        set_scroll_phase(phase);
+                        let delta = if discrete {
+                            let h = if horizontal.discrete != 0 { horizontal.discrete as f32 } else { horizontal.absolute as f32 / 10.0 };
+                            let v = if vertical.discrete != 0 { vertical.discrete as f32 } else { vertical.absolute as f32 / 10.0 };
+                            cce_ui::widget::MouseScrollDelta::LineDelta(-h * factors.mouse as f32, -v * factors.mouse as f32)
+                        } else {
+                            cce_ui::widget::MouseScrollDelta::PixelDelta(cce_ui::widget::Position {
+                                x: -horizontal.absolute * factors.trackpad,
+                                y: -vertical.absolute * factors.trackpad,
+                            })
+                        };
+                        if st.mode == LauncherMode::Json {
+                            // The JSON layout's page scroll never received the
+                            // wheel (only the fuzzel list did, and it is not the
+                            // surface shown in this mode): route it the way the
+                            // PointerMove above is routed.
+                            let mut changed = false;
+                            if let Some(jl) = &mut st.json_layout {
+                                let ev = cce_ui::widget::Event::MouseWheel { delta, x: cx, y: cy, local_x: cx, local_y: cy };
+                                let root = jl.id();
+                                st.ui_context.register_widget(root, jl.as_ptr_mut());
+                                if st.ui_context.propagate_event(&ev, root) {
+                                    changed = true;
+                                }
+                            }
+                            if changed {
+                                st.upload_vertices();
+                                self.redraw = true;
+                            }
+                        } else if st.fuzzel.scroll_box.wheel(&delta, st.cursor_x, st.cursor_y) {
                             st.fuzzel.update_scroll();
                             st.upload_vertices();
                             self.redraw = true;
@@ -3899,9 +3949,9 @@ mod tests {
         std::env::set_var("XDG_CACHE_HOME", &temp_dir);
 
         let mut apps = vec![
-            AppInfo { name: "App A".to_string(), exec: "exec_a".to_string(), terminal: false },
-            AppInfo { name: "App B".to_string(), exec: "exec_b".to_string(), terminal: false },
-            AppInfo { name: "App C".to_string(), exec: "exec_c".to_string(), terminal: false },
+            AppInfo { name: "App A".to_string(), exec: "exec_a".to_string(), terminal: false, icon: None },
+            AppInfo { name: "App B".to_string(), exec: "exec_b".to_string(), terminal: false, icon: None },
+            AppInfo { name: "App C".to_string(), exec: "exec_c".to_string(), terminal: false, icon: None },
         ];
 
         // Initially no history, sorted alphabetically.