cloud storage client
git clone https://git.lucas.co/cce-cloud.git
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.