git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commitd624c60ae5696acc84a221a8dc5873e98cd62a68
parentdd63169250
authorLucas Galante <[email protected]>
date2026-09-04 09:54
page scroll: glide and coast through ScrollMotion; tick the page lists

Two things the toolkit's smooth scrolling (cce-ui@0f84843) needed here.

The whole-page fallback scroll is now driven by a ScrollMotion: the wheel
moves the target (or, for a trackpad finger, the offset itself) and a new
tick_page_scroll in tick_internal carries the drawn offset there each frame.
The in-place geometry shift the fast path did is factored into
shift_page_to(new_y), which both paths use, so a glide still never rebuilds
the layout; scroll_y stays the drawn value and the scrollbar thumb follows
it. Arrow keys ride the same glide as wheel notches, Home/End glide to their
target. Thumb drags, the search jump and the clamp are adopted by reconcile.

The pages' inner ScrollRegions were never ticked, and a region's tick is now
what moves its offset after a wheel — without it the list would only move
its target and appear frozen. AppPage gains a default-false tick(dt) that
the six list pages (accounts, network, packages, processes, services,
timers) implement over their regions; the host pumps the current page and
re-lays it out when it reports movement.

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

 src/input_handler.rs   | 92 +++++++++++++++++++++++++++++---------------------
 src/main.rs            | 32 ++++++++++++++++++
 src/pages/accounts.rs  |  4 +++
 src/pages/mod.rs       |  9 +++++
 src/pages/network.rs   |  4 +++
 src/pages/packages.rs  |  4 +++
 src/pages/processes.rs |  4 +++
 src/pages/services.rs  |  4 +++
 src/pages/timers.rs    |  4 +++
 9 files changed, 119 insertions(+), 38 deletions(-)

diff --git a/src/input_handler.rs b/src/input_handler.rs
index 0a6d003..7c67798 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -304,33 +304,14 @@ impl SystemInterface {
                 return true;
             }
 
-            let scroll_speed = 24.0;
-            let dy = match delta {
-                cce_ui::widget::MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
-                cce_ui::widget::MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-            };
-            let old_scroll = self.scroll_y;
-            self.scroll_y = (self.scroll_y + dy).max(0.0).min(self.max_scroll_y);
-            if (self.scroll_y - old_scroll).abs() > 0.01 {
-                let actual_dy = self.scroll_y - old_scroll;
-                for w in &mut self.widgets[self.scrollable_widgets_start_idx..] {
-                    w.y -= actual_dy;
-                }
-                for (_, _, _, ty, _, _, bounds) in &mut self.texts[self.scrollable_text_items_start_idx..] {
-                    *ty -= actual_dy;
-                    if let Some(ref mut b) = bounds {
-                        b[1] -= actual_dy;
-                        b[3] -= actual_dy;
-                    }
-                }
-                for (btn, _) in &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
-                    btn.base_mut().y -= actual_dy;
-                }
-                self.last_scroll_y = self.scroll_y;
-                // The fast path skips the rebuild, so feed the scrollbar here:
-                // sync the thumb and raise the bar from behind the window plate
-                // (display_list emits it fresh each frame from this state).
-                self.page_scroll_bar.scroll_y = self.scroll_y;
+            // The whole-page scroll: a wheel notch moves the motion's target
+            // and `tick_page_scroll` glides the page there; a trackpad finger
+            // moves it now. Either way the bar raises in the same frame.
+            use cce_ui::widget::{Bounds, LINE_PX};
+            self.page_scroll_motion.reconcile(0.0, self.scroll_y);
+            let moved = self.page_scroll_motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll_y));
+            if moved {
+                self.shift_page_to(self.page_scroll_motion.y.pos());
                 self.page_scroll_bar.on_scroll();
                 return true;
             }
@@ -338,6 +319,35 @@ impl SystemInterface {
         false
     }
 
+    /// Move the page to `new_scroll_y` WITHOUT a rebuild — the wheel fast
+    /// path: the cached widget/text/button geometry shifts in place by the
+    /// delta from the current drawn offset, and the scrollbar thumb follows
+    /// (display_list emits the bar fresh each frame from this state). False
+    /// when the offset did not actually change.
+    pub(crate) fn shift_page_to(&mut self, new_scroll_y: f32) -> bool {
+        let actual_dy = new_scroll_y - self.scroll_y;
+        if actual_dy.abs() <= 0.01 {
+            return false;
+        }
+        self.scroll_y = new_scroll_y;
+        for w in &mut self.widgets[self.scrollable_widgets_start_idx..] {
+            w.y -= actual_dy;
+        }
+        for (_, _, _, ty, _, _, bounds) in &mut self.texts[self.scrollable_text_items_start_idx..] {
+            *ty -= actual_dy;
+            if let Some(ref mut b) = bounds {
+                b[1] -= actual_dy;
+                b[3] -= actual_dy;
+            }
+        }
+        for (btn, _) in &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
+            btn.base_mut().y -= actual_dy;
+        }
+        self.last_scroll_y = self.scroll_y;
+        self.page_scroll_bar.scroll_y = self.scroll_y;
+        true
+    }
+
     /// The current page's event-dispatch roots (Phase 6w — SectionContainer dissolved):
     /// the pages' widgets themselves, flattened in the legacy propagate order (sections
     /// last-to-first, and within a section the container children were visited in
@@ -655,18 +665,24 @@ impl SystemInterface {
                     && self.cursor_y <= ry + rh
             };
             if over_page {
-                use cce_ui::widget::{Key, NamedKey};
-                let old_scroll = self.scroll_y;
-                match &event.logical_key {
-                    Key::Named(NamedKey::ArrowDown) => self.scroll_y = (self.scroll_y + 24.0).min(self.max_scroll_y),
-                    Key::Named(NamedKey::ArrowUp) => self.scroll_y = (self.scroll_y - 24.0).max(0.0),
-                    Key::Named(NamedKey::Home) => self.scroll_y = 0.0,
-                    Key::Named(NamedKey::End) => self.scroll_y = self.max_scroll_y,
-                    _ => {}
-                }
-                if (self.scroll_y - old_scroll).abs() > 0.01 {
+                use cce_ui::widget::{Bounds, Key, NamedKey, LINE_PX};
+                // Arrow steps ride the same glide as wheel notches (a held
+                // key accumulates into one motion); Home/End glide to the
+                // absolute target. `tick_page_scroll` carries the page there.
+                let s = cce_ui::widget::scroll_motion::scroll_settings();
+                let b = Bounds::max(self.max_scroll_y);
+                self.page_scroll_motion.reconcile(0.0, self.scroll_y);
+                let moved = match &event.logical_key {
+                    Key::Named(NamedKey::ArrowDown) => self.page_scroll_motion.y.wheel(LINE_PX, b, &s),
+                    Key::Named(NamedKey::ArrowUp) => self.page_scroll_motion.y.wheel(-LINE_PX, b, &s),
+                    Key::Named(NamedKey::Home) => self.page_scroll_motion.y.scroll_to(0.0, b, &s),
+                    Key::Named(NamedKey::End) => self.page_scroll_motion.y.scroll_to(self.max_scroll_y, b, &s),
+                    _ => false,
+                };
+                if moved {
+                    // With smoothing off the axis jumped: land the page now.
+                    self.shift_page_to(self.page_scroll_motion.y.pos());
                     // Keyboard scrolling raises the bar like the wheel does.
-                    self.page_scroll_bar.scroll_y = self.scroll_y;
                     self.page_scroll_bar.on_scroll();
                     self.needs_rebuild = true;
                     key_handled = true;
diff --git a/src/main.rs b/src/main.rs
index 2e293d0..b9abe16 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -84,7 +84,12 @@ struct SystemInterface {
     width: u32,
     height: u32,
     needs_rebuild: bool,
+    /// The page's DRAWN offset — `page_scroll_motion` glides it (wheel) or
+    /// coasts it (trackpad flick) by shifting the cached geometry in place;
+    /// direct writes (thumb drag, keyboard, search jump, clamp) are adopted
+    /// by the motion on its next step.
     scroll_y: f32,
+    page_scroll_motion: cce_ui::widget::ScrollMotion,
     max_scroll_y: f32,
     scrollable_widgets_start_idx: usize,
     scrollable_text_items_start_idx: usize,
@@ -221,6 +226,7 @@ impl cce_ui::engine::Application for SystemInterface {
             height: 680,
             needs_rebuild: true,
             scroll_y: 0.0,
+            page_scroll_motion: cce_ui::widget::ScrollMotion::new(),
             max_scroll_y: 0.0,
             scrollable_widgets_start_idx: 0,
             scrollable_text_items_start_idx: 0,
@@ -633,6 +639,17 @@ impl SystemInterface {
         if self.page_scroll_bar.tick_activity(dt) {
             needs_redraw = true;
         }
+        // The page's own wheel glide / flick coast: shifts the cached
+        // geometry like the wheel fast path, no rebuild.
+        if self.tick_page_scroll(dt) {
+            needs_redraw = true;
+        }
+        // The current page's inner lists (their glide/coast lives in the
+        // region's tick): a moved list re-lays the page out.
+        if self.app.get_current_page_mut().tick(dt) {
+            needs_redraw = true;
+            self.needs_rebuild = true;
+        }
         if self.ui_context.tick(dt) {
             needs_redraw = true;
             self.needs_rebuild = true;
@@ -641,6 +658,21 @@ impl SystemInterface {
         needs_redraw
     }
 
+    /// Advance the page's wheel glide / flick coast; true while the offset is
+    /// moving, so the frame loop keeps drawing until it settles.
+    fn tick_page_scroll(&mut self, dt: f32) -> bool {
+        use cce_ui::widget::Bounds;
+        self.page_scroll_motion.reconcile(0.0, self.scroll_y);
+        if !self.page_scroll_motion.is_animating() {
+            return false;
+        }
+        let moved = self.page_scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll_y));
+        if moved {
+            self.shift_page_to(self.page_scroll_motion.y.pos());
+        }
+        moved || self.page_scroll_motion.is_animating()
+    }
+
     fn poll_background_updates(&mut self) {
         use pages::*;
         while let Ok(s) = self.rx_audio.try_recv() {
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index a979c83..75dec5a 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -1129,6 +1129,10 @@ impl crate::pages::AppPage for AccountsState {
     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
         self.list_visible() && self.list.keyboard(event)
     }
+
+    fn tick(&mut self, dt: f32) -> bool {
+        self.list.tick(dt)
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index d7bb0bd..eaeb621 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -142,6 +142,15 @@ pub trait AppPage {
     fn handle_key_input(&mut self, _event: &cce_ui::widget::KeyEvent) -> bool {
         false
     }
+
+    /// Per-frame upkeep for the dissolved inner lists. A `ScrollRegion`'s
+    /// wheel only moves its target; its `tick` is what glides (wheel) or
+    /// coasts (trackpad flick) the drawn offset there — a page hosting one
+    /// must pump it here or the list freezes after the first notch. True =
+    /// the page's geometry changed (the host re-lays it out).
+    fn tick(&mut self, _dt: f32) -> bool {
+        false
+    }
 }
 
 
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 4191b18..d1512f3 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -325,6 +325,10 @@ impl crate::pages::AppPage for NetworkState {
     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
         self.wifi_list_visible() && self.wifi_list.keyboard(event)
     }
+
+    fn tick(&mut self, dt: f32) -> bool {
+        self.wifi_list.tick(dt)
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index e07ec6e..648c5a7 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -838,6 +838,10 @@ impl crate::pages::AppPage for PackagesState {
     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
         self.loaded && self.active_list().keyboard(event)
     }
+
+    fn tick(&mut self, dt: f32) -> bool {
+        self.installed_list.tick(dt) | self.updates_list.tick(dt)
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 8168ccc..0a4a6a2 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -360,6 +360,10 @@ impl crate::pages::AppPage for ProcessesState {
     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
         self.loaded && self.cpu_list.keyboard(event)
     }
+
+    fn tick(&mut self, dt: f32) -> bool {
+        self.cpu_list.tick(dt)
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 9293f50..070b46b 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -525,6 +525,10 @@ impl crate::pages::AppPage for ServicesState {
     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
         self.loaded && self.list.keyboard(event)
     }
+
+    fn tick(&mut self, dt: f32) -> bool {
+        self.list.tick(dt)
+    }
 }
 
 #[cfg(test)]
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index b0b4803..c8eb4b5 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -738,6 +738,10 @@ impl crate::pages::AppPage for TimersState {
     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
         self.loaded && self.list.keyboard(event)
     }
+
+    fn tick(&mut self, dt: f32) -> bool {
+        self.list.tick(dt)
+    }
 }
 
 #[cfg(test)]