Merge PR #648 from NikitkaRa: Canvas: a trackpad swipe turns the page

# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:42:43 -04:00
7 changed files with 164 additions and 5 deletions
+1
View File
@@ -217,6 +217,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) |
| 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Breadth 79% → 80%, Animations 79% → 85% |
| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
| 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 |
+21 -5
View File
@@ -84,6 +84,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<bool> {
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();
@@ -123,15 +134,20 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
if resp.hovered() && !zooming {
if overflow != egui::Vec2::ZERO {
offset -= wheel;
} else if wheel.y.abs() > 18.0 && !app.anim_preview_active() && 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.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");
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!({}));
}
}
}
+110
View File
@@ -2189,4 +2189,114 @@ mod tests {
assert!(harness.state().anim_preview.is_none(), "the preview ends after the sequence");
assert!(harness.state().show.is_none());
}
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));
}
}
+21
View File
@@ -53,6 +53,8 @@ pub struct Show {
notes_px: f32,
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<usize>,
@@ -199,6 +201,7 @@ impl Show {
notes_px: NOTES_DEFAULT,
timings: vec![],
last_move: 0.0,
last_swipe: f64::NEG_INFINITY,
ended: false,
media_slide: None,
last_viewed: None,
@@ -613,6 +616,24 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm {
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) {
+4
View File
@@ -27,6 +27,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview plays on the editor canvas; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, 3-D bevel, camera and face lighting rendered) · **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
@@ -373,6 +374,8 @@ Detail in [hardware-parity.md](hardware-parity.md).
- **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 (it does not keep the point under the pointer). A two-finger twist does rotate the selected shapes. 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
@@ -455,6 +458,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 |
| 2026-10-13 | minor | Animation preview plays the current slide on the editor canvas; the pane gap keeps the timeline, preview from the selected effect, and the remaining effects |
| 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open |
| 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
| 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 |
+4
View File
@@ -2,6 +2,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (PNG and JPEG slide export record 144 dpi at the default 2 pixels per point; previous: IME input, #101) · **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 two-finger twist rotates the selection; previous: minor, IME input, #101) · **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
@@ -46,6 +47,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 | pinch zoom does not keep the point under the pointer; a two-finger twist rotates the selected shapes |
| 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) |
@@ -75,5 +78,6 @@ outlines every frame (#67, fix open), and the decoders run in software.
|---|---|---|
| 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size |
| 2026-10-14 | minor | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
| 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 |
+3
View File
@@ -9,6 +9,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (presenter timer pause/reset, clock and notes size; previous: minor, See All Slides and zoom) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (custom show playback; previous: minor, presenter timer pause/reset) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (go to slide shows the number while it is typed; previous: minor, custom show playback) · **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
@@ -23,6 +24,8 @@ 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 |
| Go to slide (number + Enter), first/last | yes | yes: the digits show as "Go to slide …" until Enter, Backspace, Esc, or 3 seconds | Slide numbers are 1-based; 0 and a number past the end do not move |
| 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 | |
| Step clock: a click after a long pause plays its step (#11) | yes | yes | |