UI: keep rapid mouse-wheel slide navigation steps

This commit is contained in:
kinower
2026-10-22 00:43:03 +02:00
parent 354087ecd5
commit 7dce06dca4
3 changed files with 115 additions and 14 deletions
+2
View File
@@ -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 |
+45 -14
View File
@@ -95,6 +95,37 @@ pub(crate) fn page_flick(wheel: egui::Vec2) -> Option<bool> {
(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<usize> {
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!({}));
}
}
}
}
+68
View File
@@ -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]