Merge pull request #872 from kinower/fix/responsive-mouse-wheel-navigation

This commit is contained in:
Brandon Thomas
2026-10-23 17:09:49 -04:00
3 changed files with 207 additions and 14 deletions
+2
View File
@@ -147,6 +147,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 |
@@ -254,6 +255,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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-22 | trivial | Progress log: table text takes the master's other-text style |
| 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 |
+72 -14
View File
@@ -95,11 +95,62 @@ 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.
///
/// High-resolution and free-spin wheels send fractions of a line (0.125 per event on Windows), so
/// the delta is accumulated in `carry` and each whole line is one slide; a change of direction
/// drops what was left over. Returns the target, `None` when no wheel event was read.
fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize, carry: &mut f32) -> 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()
{
if *carry * delta.y < 0.0 {
*carry = 0.0;
}
*carry += delta.y;
// The small slack keeps sums such as 10 × 0.1 from falling just short of a line.
let whole = (carry.abs() + 1e-4).floor();
*carry -= whole.copysign(*carry);
let steps = whole.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();
app.canvas_rect = Some(avail);
let Some(st) = app.session.active() else { return };
// The leftover fraction of a wheel line belongs to one presentation. Drop it whenever another
// one is drawn here, hovered or not, so switching away and back starts from zero.
let carry_id = ui.id().with("wheel_carry");
let doc_uid = st.uid;
ui.data_mut(|d| {
if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) {
d.insert_temp(carry_id, (doc_uid, 0.0_f32));
}
});
if st.doc.slides.is_empty() && st.selection.target == Target::Slides {
let resp = ui.allocate_rect(avail, Sense::click());
ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim);
@@ -134,20 +185,27 @@ 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 (_, mut carry): (u64, f32) = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default());
let wheel_step = app.session.active().and_then(|d| {
let current = d.selection.slide;
ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target))
});
ui.data_mut(|d| d.insert_temp(carry_id, (doc_uid, carry)));
if let Some((current, index)) = wheel_step {
if index != current {
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!({}));
}
}
}
}
+133
View File
@@ -3331,6 +3331,139 @@ 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");
}
/// A high-resolution wheel on Windows sends one notch as eight 0.125-line events.
#[test]
fn mouse_wheel_fractional_lines_accumulate_into_one_slide_per_line() {
let mut h = flick_harness();
for _ in 0..4 {
h.state_mut().run("slide.new", json!({})).unwrap();
}
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
h.run_steps(1);
for _ in 0..7 {
mouse_wheel(&mut h, &[-0.125]);
assert_eq!(slide_at(h.state()), 0, "a fraction of a line does not turn the page");
}
mouse_wheel(&mut h, &[-0.125]);
assert_eq!(slide_at(h.state()), 1, "eight eighths make exactly one slide");
h.run_steps(30);
assert_eq!(slide_at(h.state()), 1, "nothing is left over to turn another page");
mouse_wheel(&mut h, &[-0.5]);
mouse_wheel(&mut h, &[0.5, 0.25]);
assert_eq!(slide_at(h.state()), 1, "a change of direction drops the leftover fraction");
mouse_wheel(&mut h, &[0.25]);
assert_eq!(slide_at(h.state()), 0, "a whole line back is one slide back");
}
/// A fraction of a line left over in one presentation does not carry into another.
#[test]
fn mouse_wheel_fraction_does_not_carry_into_another_presentation() {
let mut h = flick_harness();
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, &[-0.75]);
assert_eq!(slide_at(h.state()), 0);
let a = h.state().session.active_index().unwrap();
h.state_mut().run("file.new", json!({})).unwrap();
h.state_mut().run("slide.new", json!({})).unwrap();
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
h.state_mut().ui.zoom = Some(0.4);
h.run_steps(2);
mouse_wheel(&mut h, &[-0.25]);
assert_eq!(slide_at(h.state()), 0, "a quarter line in the new presentation does not turn the page");
mouse_wheel(&mut h, &[-0.75]);
assert_eq!(slide_at(h.state()), 1, "a whole line in the new presentation does");
// Back in the first one, leave 0.75 of a line, then switch away and back with the pointer
// off the canvas (on the tabs): the canvas never sees a hover in the other presentation.
let b = h.state().session.active_index().unwrap();
h.state_mut().session.set_active(a);
h.state_mut().ui.zoom = Some(0.4);
h.run_steps(2);
mouse_wheel(&mut h, &[-0.75]);
assert_eq!(slide_at(h.state()), 0);
h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0)));
h.run_steps(1);
h.state_mut().session.set_active(b);
h.run_steps(2);
h.state_mut().session.set_active(a);
h.run_steps(2);
mouse_wheel(&mut h, &[-0.25]);
assert_eq!(slide_at(h.state()), 0, "switching away without a hover still drops the fraction");
}
#[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]