diff --git a/ROADMAP.md b/ROADMAP.md index c642377e..a4ef29ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad8..818a9f5f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -95,11 +95,62 @@ 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. +/// +/// 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 { + 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!({})); + } } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50387a93..df8ee6f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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]