authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 02:38:27-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 03:11:20-07:00
logba16a18feec9b6b0d5b03464b1e8cda9ded4a81e
tree9ecd9d41c836152bb8342c0b34c03c18fe6a873c
parent12d02ba99c1881032feaeb079d60181d0f066945
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: the wheel and trackpad scroll the interface as they scroll the page

Lists, menus, the sidebar, Options and the section tabs eased towards where the wheel sent them, so on GNOME, where the page follows each delta as it comes, the settings felt smooth-scrolled and out of step. Every scroller in the interface kit now moves by each wheel or trackpad delta at once, from the same deltas the page takes; only programmatic jumps, as scrolling to an index entry or a newly opened tab, still ease. Assisted-by: claude-opus-5.5

4 files changed, 34 insertions(+), 20 deletions(-)

crates/ui/src/lib.rs+4-2
...@@ -866,7 +866,7 @@ impl Ui {...@@ -866,7 +866,7 @@ impl Ui {
866 }866 }
867867
868 /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, both868 /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, both
869 /// as last laid out; the wheel's easing carries it there.869 /// as last laid out, easing there.
870 pub fn scroll_to(&mut self, scroller: Id, target: Id) {870 pub fn scroll_to(&mut self, scroller: Id, target: Id) {
871 let (Some(top), Some(start)) = (self.rect(target), self.rect(scroller)) else {871 let (Some(top), Some(start)) = (self.rect(target), self.rect(scroller)) else {
872 return;872 return;
...@@ -1036,7 +1036,9 @@ impl Ui {...@@ -1036,7 +1036,9 @@ impl Ui {
1036 Some(id) => {1036 Some(id) => {
1037 let state = self.states.entry(id).or_default();1037 let state = self.states.entry(id).or_default();
1038 let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0);1038 let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0);
1039 state.scroll_target = (state.scroll_target - delta[1]).clamp(0.0, most);1039 // The wheel moves the content at once, as on the page; only jumps ease.
1040 state.scroll = (state.scroll - delta[1]).clamp(0.0, most);
1041 state.scroll_target = state.scroll;
1040 self.signals.entry(id).or_default().events.push(event);1042 self.signals.entry(id).or_default().events.push(event);
1041 }1043 }
1042 None => {}1044 None => {}
crates/ui/src/list.rs+2-1
...@@ -132,7 +132,8 @@ pub fn list<R: Rows>(...@@ -132,7 +132,8 @@ pub fn list<R: Rows>(
132132
133 for event in ui.signal(id).events {133 for event in ui.signal(id).events {
134 if let Event::Wheel(delta) = event {134 if let Event::Wheel(delta) = event {
135 state.target = (state.target - f64::from(delta[1])).clamp(0.0, most);135 state.scroll = (state.scroll - f64::from(delta[1])).clamp(0.0, most);
136 state.target = state.scroll;
136 }137 }
137 }138 }
138 let mut clicked = None;139 let mut clicked = None;
crates/ui/src/shell.rs+12-2
...@@ -65,12 +65,18 @@ pub fn section_tabs(...@@ -65,12 +65,18 @@ pub fn section_tabs(
65 let wide = left + lean(height) + crate::SHADOW[0];65 let wide = left + lean(height) + crate::SHADOW[0];
66 let view = ui.rect(row).map_or(wide, |rect| rect[2] - rect[0]);66 let view = ui.rect(row).map_or(wide, |rect| rect[2] - rect[0]);
67 let most = (wide - view).max(0.0);67 let most = (wide - view).max(0.0);
68 let [scroll, shown] = [row.child("scroll"), row.child("shown")];68 let [scroll, shown, shift] = [row.child("scroll"), row.child("shown"), row.child("offset")];
69 let held_value = |ui: &Ui, id: Id| ui.states.get(&id).and_then(|state| state.tween);69 let held_value = |ui: &Ui, id: Id| ui.states.get(&id).and_then(|state| state.tween);
70 let mut target = held_value(ui, scroll).map_or(0.0, |[_, target]| target);70 let mut target = held_value(ui, scroll).map_or(0.0, |[_, target]| target);
71 // The wheel moves the tabs at once from where they show, as on the page; only jumps ease.
72 let mut wheeled = false;
71 for event in ui.signal(row).events {73 for event in ui.signal(row).events {
72 if let crate::Event::Wheel([x, y]) = event {74 if let crate::Event::Wheel([x, y]) = event {
75 if !wheeled {
76 target = held_value(ui, shift).map_or(target, |[value, _]| value);
77 }
73 target -= x + y;78 target -= x + y;
79 wheeled = true;
74 }80 }
75 }81 }
76 if held_value(ui, shown).is_none_or(|[_, was]| was != active as f32)82 if held_value(ui, shown).is_none_or(|[_, was]| was != active as f32)
...@@ -91,7 +97,11 @@ pub fn section_tabs(...@@ -91,7 +97,11 @@ pub fn section_tabs(
91 Some(start)97 Some(start)
92 });98 });
93 let target = ui.hold(scroll, target.clamp(0.0, most));99 let target = ui.hold(scroll, target.clamp(0.0, most));
94 let offset = ui.animate(row.child("offset"), target);100 let offset = if wheeled {
101 ui.hold(shift, target)
102 } else {
103 ui.animate(shift, target)
104 };
95 let fade = |cut: bool| if cut { EDGE } else { 0.0 };105 let fade = |cut: bool| if cut { EDGE } else { 0.0 };
96 ui.open_as(106 ui.open_as(
97 row,107 row,
crates/ui/src/tests.rs+16-15
...@@ -542,10 +542,12 @@ fn wheel_scrolls_within_the_content_and_clips_children() {...@@ -542,10 +542,12 @@ fn wheel_scrolls_within_the_content_and_clips_children() {
542 build(&mut ui);542 build(&mut ui);
543 ui.event(Event::PointerMoved([50.0, 50.0]));543 ui.event(Event::PointerMoved([50.0, 50.0]));
544 ui.event(Event::Wheel([0.0, -1000.0]));544 ui.event(Event::Wheel([0.0, -1000.0]));
545 for _ in 0..60 {545 build(&mut ui);
546 build(&mut ui);546 assert_eq!(
547 }547 ui.states[&list.unwrap()].scroll,
548 assert_eq!(ui.states[&list.unwrap()].scroll, 200.0);548 200.0,
549 "at once, as on the page"
550 );
549 assert_eq!(ui.rect(last.unwrap()), Some([0.0, 70.0, 100.0, 100.0]));551 assert_eq!(ui.rect(last.unwrap()), Some([0.0, 70.0, 100.0, 100.0]));
550 let layers = ui.layers();552 let layers = ui.layers();
551 assert!(matches!(553 assert!(matches!(
...@@ -1906,10 +1908,10 @@ fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() {...@@ -1906,10 +1908,10 @@ fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() {
1906 list_frame(&mut ui, &rows, &mut selected, &[]);1908 list_frame(&mut ui, &rows, &mut selected, &[]);
1907 rows = Keyed::new((20_000..20_050).chain(rows.keys.iter().copied()));1909 rows = Keyed::new((20_000..20_050).chain(rows.keys.iter().copied()));
1908 list_frame(&mut ui, &rows, &mut selected, &[]);1910 list_frame(&mut ui, &rows, &mut selected, &[]);
1909 let moving = row_top(&ui, 500).unwrap();1911 assert_eq!(
1910 assert!(1912 row_top(&ui, 500),
1911 moving < at && moving > at - 60.0,1913 Some(at - 60.0),
1912 "the wheel eases on through arrivals"1914 "the wheel moves the rows at once, and they hold through arrivals"
1913 );1915 );
1914 settle_list(&mut ui, &rows, &mut selected);1916 settle_list(&mut ui, &rows, &mut selected);
1915 assert_eq!(row_top(&ui, 500), Some(at - 60.0));1917 assert_eq!(row_top(&ui, 500), Some(at - 60.0));
...@@ -2950,16 +2952,15 @@ fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() {...@@ -2950,16 +2952,15 @@ fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() {
2950 let first = ui.rect(shell::tab_id(row, 0)).unwrap()[0];2952 let first = ui.rect(shell::tab_id(row, 0)).unwrap()[0];
2951 ui.event(Event::PointerMoved([100.0, 14.0]));2953 ui.event(Event::PointerMoved([100.0, 14.0]));
2952 ui.event(Event::Wheel([0.0, -40.0]));2954 ui.event(Event::Wheel([0.0, -40.0]));
2953 settle_tabs(&mut ui, 0);2955 tab_row(&mut ui, 0);
2956 tab_row(&mut ui, 0);
2954 let scrolled = ui.rect(shell::tab_id(row, 0)).unwrap()[0];2957 let scrolled = ui.rect(shell::tab_id(row, 0)).unwrap()[0];
2955 assert!(2958 assert_eq!(first - scrolled, 40.0, "at once, as on the page");
2956 (first - scrolled - 40.0).abs() < 0.5,
2957 "{first} to {scrolled}"
2958 );
2959 ui.event(Event::Wheel([30.0, 0.0]));2959 ui.event(Event::Wheel([30.0, 0.0]));
2960 settle_tabs(&mut ui, 0);2960 tab_row(&mut ui, 0);
2961 tab_row(&mut ui, 0);
2961 let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0];2962 let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0];
2962 assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}");2963 assert_eq!(back - scrolled, 30.0);
2963}2964}
29642965
2965#[test]2966#[test]