mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 04:56:58 -06:00
UI: keep rapid mouse-wheel slide navigation steps
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user