diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..26ad6c97 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..5aa2ee51 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,6 +53,17 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { (r, scale) } +/// A trackpad flick or mouse-wheel notch that should turn the page. +/// +/// The stronger axis wins, so a sideways swipe is not read as a vertical scroll. Negative +/// movement — the content moves left or up, which is a swipe left or up with natural scrolling — +/// is the next slide. A nudge under 18 points is ignored so a resting finger does not change slides. +pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { + const MIN_FLICK: f32 = 18.0; + let along = if wheel.x.abs() > wheel.y.abs() { wheel.x } else { wheel.y }; + (along.abs() > MIN_FLICK).then_some(along < 0.0) +} + pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let avail = ui.available_rect_before_wrap(); @@ -80,15 +91,19 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let _ = pinch_pos; } else if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - // Wheel over a fitted slide pages through slides (like PowerPoint). - let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" }; + } else if 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"); - let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(0.0)); + // 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(id, json!({})); + 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 ec97e6e6..94a05f4b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1408,4 +1408,114 @@ mod tests { harness.run_steps(3); assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); } + + fn flick_harness() -> egui_kittest::Harness<'static, SlideApp> { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + // Fitted: the slide is smaller than the window, so a flick turns the page instead of panning. + harness.state_mut().ui.zoom = Some(0.4); + harness + } + + fn slide_at(app: &SlideApp) -> usize { + app.session.active().unwrap().selection.slide + } + + /// One frame of a trackpad flick: four short samples, each under egui's cutoff for smoothing a + /// mouse notch. The harness runs one frame per queued event, so they have to share a frame or + /// none of them reaches the page threshold. + fn flick(harness: &mut egui_kittest::Harness<'static, SlideApp>, delta: egui::Vec2) { + let centre = harness.state().canvas_rect.unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))).center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + let step = delta / 4.0; + for _ in 0..4 { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: step, + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); + } + + #[test] + fn page_flick_uses_the_stronger_axis() { + assert_eq!(crate::canvas::page_flick(egui::vec2(-24.0, -4.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(24.0, 4.0)), Some(false)); + assert_eq!(crate::canvas::page_flick(egui::vec2(-4.0, -24.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); + } + + /// 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] + fn a_sideways_trackpad_swipe_turns_the_page_and_a_second_flick_waits() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + assert_eq!(slide_at(harness.state()), 0); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "swipe left goes to the next slide"); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "the next frame of the same gesture does not skip a slide"); + + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "swipe right goes back"); + } + + #[test] + fn a_vertical_wheel_still_turns_the_page_and_a_zoomed_slide_pans_instead() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-2.0, -24.0)); + assert_eq!(slide_at(harness.state()), 1, "a vertical wheel still goes to the next slide"); + + harness.state_mut().ui.zoom = Some(3.0); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); + } + + #[test] + fn a_swipe_during_the_show_turns_the_page() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1)); + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); + } + + /// The pen is drawing. A flick must not turn the page out from under the stroke. + #[test] + fn the_pen_ignores_a_swipe_during_the_show() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::P); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); + } } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..3d29387f 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -35,6 +35,8 @@ pub struct Show { show_start: f64, timings: Vec<(usize, f64)>, last_move: f64, + /// The last trackpad flick or mouse-wheel notch, so one gesture turns one page. + last_swipe: f64, pub ended: bool, /// The slide whose media was last started (auto-play runs once per visit). media_slide: Option, @@ -95,6 +97,7 @@ impl Show { show_start: 0.0, timings: vec![], last_move: 0.0, + last_swipe: f64::NEG_INFINITY, ended: false, media_slide: None, last_viewed: None, @@ -342,6 +345,24 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { if secondary && !show.ended { actions.push(show.state.prev(&doc)); } + // A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick + // there stays on the slide. Pinch zoom is not a show gesture. + if !show.pen + && let Some(next) = crate::canvas::page_flick(ctx.input(|i| i.smooth_scroll_delta)) + && now - show.last_swipe > 0.35 + { + show.last_swipe = now; + if next { + if show.ended { + keep = false; + } else { + actions.push(show.state.next(&doc)); + } + } else { + show.ended = false; + actions.push(show.state.prev(&doc)); + } + } // Apply input first: a step started by this click must restart the clock before it is read. for a in actions.drain(..) { if !show.apply(a, &doc, now) { diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..459490e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page in the editor and the show; pinch-zoom remains; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -217,7 +217,8 @@ Detail in [hardware-parity.md](hardware-parity.md). or wgpu path). - **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy FilmCraft's hardware decode). -- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h. +- **No pen pressure or tilt for ink**; no pinch-zoom. A trackpad swipe turns the page when the + slide fits the window, and during the show. 2–4 h for pinch-zoom. ## Localization @@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A trackpad swipe (and the mouse wheel, in the show) turns the page. Pinch-zoom remains | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..21e8d401 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page; pinch-zoom remains; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -43,7 +43,8 @@ because it mostly follows from hardware use. |---|---|---| | Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes | | Mouse and trackpad scroll | yes | yes | -| Pinch to zoom, two-finger gestures | yes | no | +| Pinch to zoom | yes | no | +| Two-finger swipe between slides | yes | yes: when the slide fits the window, and during the show. A zoomed slide still pans | | Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | | Touch screens (Windows) | yes | basic pointer only | | IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | @@ -71,5 +72,6 @@ outlines every frame (#67, fix open), and the decoders run in software. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A sideways trackpad swipe turns the page in Normal view (when the slide fits) and in the show. Pinch-zoom is unchanged | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index fd981595..47e2ff2a 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe or mouse wheel turns the page during the show; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -14,6 +14,7 @@ ready** (estimated), **10–16 h** to parity. |---|---|---|---| | Play from Start / from Current Slide (F5, Shift+F5, Cmd+Enter) | yes | yes | | | Next/previous by click, arrows, Space, Page Up/Down, Enter, Backspace | yes | yes | #17 reported arrows not working in v0.3.0 AppImage; recheck | +| Next/previous by trackpad swipe or mouse wheel | yes | yes | One flick turns one page. The pen ignores the flick | | Go to slide (number + Enter), first/last | yes | yes | | | Black / white screen (B / W) | yes | yes | | | End screen, Esc to leave | yes | yes | |