From 7dce06dca415485a250d1f4b21d782383d6718c7 Mon Sep 17 00:00:00 2001 From: kinower Date: Fri, 16 Oct 2026 09:29:36 +0200 Subject: [PATCH] UI: keep rapid mouse-wheel slide navigation steps --- ROADMAP.md | 2 ++ crates/ui-egui/src/canvas.rs | 59 +++++++++++++++++++++++-------- crates/ui-egui/src/lib.rs | 68 ++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d3..f8029e47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | +| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | | 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | @@ -245,6 +246,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | +| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas | | 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad8..f2efc09d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -95,6 +95,37 @@ pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { (along.abs() > MIN_FLICK).then_some(along < 0.0) } +/// Discrete wheel input must not share the trackpad's gesture cooldown. Keep all the steps, +/// including several notches delivered in one frame, and clamp at each end of the deck. +fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize) -> Option { + let last = slides.checked_sub(1)?; + let mut target = current.min(last); + let mut handled = false; + for event in events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event + && matches!(unit, egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page) + && !modifiers.ctrl + && !modifiers.command + && !modifiers.shift + && delta.y.is_finite() + && delta.y.abs() > 0.0 + && delta.y.abs() >= delta.x.abs() + { + let steps = delta.y.abs().ceil().min(slides as f32) as usize; + target = if delta.y < 0.0 { target.saturating_add(steps).min(last) } else { target.saturating_sub(steps) }; + handled = true; + } + } + handled.then_some(target) +} + +fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 { + events.iter().fold(egui::Vec2::ZERO, |sum, event| match event { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers, .. } if !modifiers.ctrl && !modifiers.command => sum + *delta, + _ => sum, + }) +} + pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let avail = ui.available_rect_before_wrap(); @@ -134,20 +165,20 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if resp.hovered() && !zooming { if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if !app.anim_preview_active() - && app.session.tool.marquee.is_none() - && !app.session.tool.dragging() - && let Some(next) = page_flick(wheel) - { - // A fitted slide: the mouse wheel and a trackpad swipe turn the page. Pinch zoom is - // the branch above and is left alone. - let gate = ui.id().with("wheel_gate"); - // No previous flick yet: the first one must turn the page, not wait out the gap. - let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); - let now = ui.input(|i| i.time); - if now - last > 0.35 { - ui.data_mut(|d| d.insert_temp(gate, now)); - let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); + } else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { + let target = app.session.active().and_then(|d| ui.input(|i| wheel_slide_target(&i.events, d.selection.slide, d.doc.slides.len()))); + if let Some(index) = target { + let _ = app.run("slide.go", json!({"index": index})); + } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { + // Point-based trackpad gestures keep their cooldown. Raw input avoids reading + // the smoothed tail of a discrete mouse notch as another page turn. + let gate = ui.id().with("wheel_gate"); + let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); + let now = ui.input(|i| i.time); + if now - last > 0.35 { + ui.data_mut(|d| d.insert_temp(gate, now)); + let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); + } } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b3..0ccb7a65 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3315,6 +3315,74 @@ mod tests { assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); } + fn mouse_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, notches: &[f32]) { + let centre = harness.state().canvas_rect.unwrap().center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + for &y in notches { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, y), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); + } + + #[test] + fn mouse_wheel_keeps_fast_notches_in_both_directions_without_a_smoothed_tail() { + let mut h = flick_harness(); + for _ in 0..8 { + h.state_mut().run("slide.new", json!({})).unwrap(); + } + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + + mouse_wheel(&mut h, &[-1.0]); + assert_eq!(slide_at(h.state()), 1, "one notch responds immediately"); + mouse_wheel(&mut h, &[-1.0]); + assert_eq!(slide_at(h.state()), 2, "the next frame does not lose a notch to the cooldown"); + mouse_wheel(&mut h, &[-1.0, -1.0]); + assert_eq!(slide_at(h.state()), 4, "notches batched into a frame are retained"); + mouse_wheel(&mut h, &[-3.0]); + assert_eq!(slide_at(h.state()), 7, "an accelerated wheel event keeps its steps"); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 7, "smoothing must not turn more pages"); + mouse_wheel(&mut h, &[2.0]); + assert_eq!(slide_at(h.state()), 5, "reversing direction responds immediately"); + mouse_wheel(&mut h, &[100.0]); + assert_eq!(slide_at(h.state()), 0, "stop at the first slide"); + mouse_wheel(&mut h, &[-100.0]); + assert_eq!(slide_at(h.state()), 8, "stop at the last slide"); + } + + #[test] + fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { + let mut h = flick_harness(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.state_mut().ui.zoom = Some(3.0); + h.run_steps(2); + let before = h.state().slide_rect.unwrap(); + mouse_wheel(&mut h, &[-3.0]); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 0); + assert!(h.state().slide_rect.unwrap().min.y < before.min.y, "the enlarged canvas scrolls"); + + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -3.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 0, "wheel input outside the canvas is ignored"); + } + /// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. /// The frames of one gesture do not skip ahead. #[test]