Canvas: a two-finger twist rotates the selection

macOS delivers a trackpad rotation as egui's Rotate event. While the
pointer is over the canvas, that twist turns the selected shapes, and
the whole gesture is one undo step. Pinch-zoom and scrolling are unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 04:55:33 +03:00
co-authored by Cursor
parent 457406a7a5
commit a9e7747ea5
7 changed files with 157 additions and 6 deletions
+1
View File
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. 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 |
+66 -1
View File
@@ -33,7 +33,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate),
cmd!(
"shape.rotate",
"Rotation",
["Shape Format", "Arrange", "Rotate"],
None,
"{deg?: absolute, by?: relative, ids?, gesture?: move|end}",
has_selection,
rotate
),
cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip),
cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust),
cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change),
@@ -174,6 +182,15 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
// A trackpad twist sends many small steps. `gesture: move` keeps them as one undo;
// `gesture: end` commits that step and does not rotate again.
if p.get("gesture").and_then(Value::as_str) == Some("end") {
s.commit_named("Rotation");
return Ok(json!({}));
}
if p.get("gesture").and_then(Value::as_str) == Some("move") {
s.begin("Rotation");
}
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
edit_geometry(s, p, "shape.rotate", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
@@ -860,3 +877,51 @@ fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({"id": id.0}))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use serde_json::json;
fn rotation(s: &Session) -> f64 {
let st = s.active().unwrap();
let id = st.selection.shapes[0];
st.shape(id).unwrap().xfrm.unwrap().rot
}
#[test]
fn a_twist_is_one_undo_and_wraps_past_a_full_turn() {
let mut s = Session::with_new();
s.execute("edit.select", &json!({"ids": []})).unwrap();
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 80.0, 40.0]})).unwrap();
let id = id.get("id").and_then(Value::as_u64).unwrap();
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
assert!((rotation(&s) - 0.0).abs() < 0.01);
s.execute("shape.rotate", &json!({"by": 10.0, "gesture": "move"})).unwrap();
s.execute("shape.rotate", &json!({"by": 350.0, "gesture": "move"})).unwrap();
assert!((rotation(&s) - 0.0).abs() < 0.01, "10 + 350 wraps to 0, got {}", rotation(&s));
s.execute("shape.rotate", &json!({"by": 20.0, "gesture": "move"})).unwrap();
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
assert!((rotation(&s) - 20.0).abs() < 0.01);
assert_eq!(s.active().unwrap().history.undo.last().unwrap().label, "Rotation");
assert!(s.active().unwrap().interaction.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert!((rotation(&s) - 0.0).abs() < 0.01, "one undo undoes the whole twist");
s.execute("edit.redo", &json!({})).unwrap();
assert!((rotation(&s) - 20.0).abs() < 0.01);
}
#[test]
fn ending_a_twist_does_not_commit_a_drag() {
let mut s = Session::with_new();
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap();
let st = s.active_mut().unwrap();
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() });
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move");
}
}
+9 -1
View File
@@ -309,7 +309,7 @@ impl Session {
}
}
fn begin(&mut self, label: &str) {
pub(crate) fn begin(&mut self, label: &str) {
if let Some(st) = self.active_mut()
&& st.interaction.is_none()
{
@@ -326,6 +326,14 @@ impl Session {
}
}
/// End the interaction only when it is the one `label` started. A twist must not commit a drag.
pub(crate) fn commit_named(&mut self, label: &str) {
let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label);
if ours {
self.commit();
}
}
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
// Next click of a freeform: close on the start point, else add a vertex.
+27
View File
@@ -35,6 +35,33 @@ fn mods(ui: &Ui) -> Mods {
ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl })
}
/// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`];
/// other platforms leave the delta at zero.
///
/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom
/// and two-finger scrolling are left as they are.
pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) {
let delta = ctx.input(|i| i.rotation_delta());
let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 };
let turning = delta.abs() > 1e-4;
let blocked = app.show.is_some()
|| app.backstage_open()
|| app.dialog.as_ref().is_some_and(|d| d.modal())
|| app.palette.is_some()
|| app.session.tool.dragging();
let over = app.canvas_rect.is_some_and(|r| ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)));
let rotatable = app.session.active().is_some_and(|d| {
d.selection.text.is_none() && d.selection.shapes.iter().any(|id| d.shape(*id).is_some_and(|sh| !sh.locked && sh.xfrm.is_some()))
});
if !blocked && turning && rotatable && (app.twisting || over) {
let _ = app.run("shape.rotate", json!({"by": f64::from(delta.to_degrees()), "gesture": "move"}));
app.twisting = true;
} else if app.twisting {
let _ = app.run("shape.rotate", json!({"gesture": "end", "ids": []}));
app.twisting = false;
}
}
/// The slide's rect on screen for the current zoom.
pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
let Some(d) = app.session.active() else { return (avail, 1.0) };
+48
View File
@@ -227,6 +227,8 @@ pub struct SlideApp {
pub(crate) thumb_drag: Option<(usize, usize)>,
/// Selection pane: folded groups, renaming, drag and drop.
pub selpane: selpane::State,
/// A trackpad twist is in progress (one undo step when it ends).
twisting: bool,
}
impl SlideApp {
@@ -267,6 +269,7 @@ impl SlideApp {
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
selpane: selpane::State::default(),
twisting: false,
}
}
@@ -779,6 +782,7 @@ impl SlideApp {
Err(e) => self.set_status(e),
}
}
canvas::trackpad_twist(self, ctx);
}
/// Borrow faces for interface text in scripts the interface fonts lack (#101): the open
@@ -1136,6 +1140,50 @@ mod tests {
use super::*;
fn shape_rot(app: &SlideApp) -> f64 {
let d = app.session.active().unwrap();
let id = d.selection.shapes[0];
d.shape(id).unwrap().xfrm.unwrap().rot
}
/// A two-finger twist is egui's Rotate event. Over the canvas it turns the selection;
/// letting go is one undo step. Off the canvas it does nothing.
#[test]
fn a_trackpad_twist_rotates_the_shape_under_the_canvas() {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.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);
let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 100.0, 60.0]})).unwrap();
let id = id.get("id").and_then(|v| v.as_u64()).unwrap();
harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap();
harness.run_steps(1);
let canvas = harness.state().canvas_rect.expect("canvas");
let outside = egui::pos2(1.0, 1.0);
assert!(!canvas.contains(outside), "the ribbon corner is not the canvas: {canvas:?}");
harness.input_mut().events.push(egui::Event::PointerMoved(outside));
harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians()));
harness.run_steps(1);
assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "off the canvas, still {}", shape_rot(harness.state()));
harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center()));
harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians()));
harness.run_steps(1);
harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center()));
harness.input_mut().events.push(egui::Event::Rotate(10.0_f32.to_radians()));
harness.run_steps(1);
assert!((shape_rot(harness.state()) - 30.0).abs() < 0.05, "twist landed on {}", shape_rot(harness.state()));
harness.run_steps(1);
assert!(harness.state().session.active().unwrap().interaction.is_none());
harness.state_mut().session.execute("edit.undo", &json!({})).unwrap();
assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state()));
}
fn title_text(app: &SlideApp) -> String {
let d = app.session.active().unwrap();
let sh = &d.doc.slides[0].shapes[0];
+3 -2
View File
@@ -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 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)
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,7 @@ 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 (it does not keep the point under the pointer). A two-finger twist does rotate the selected shapes. 3–6 h.
## Localization
@@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open |
| 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 |
+3 -2
View File
@@ -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 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
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,7 @@ 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, two-finger gestures | yes | pinch zoom does not keep the point under the pointer; a two-finger twist rotates the selected shapes |
| 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 +71,6 @@ outlines every frame (#67, fix open), and the decoders run in software.
| Date | Change | Summary |
|---|---|---|
| 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-12 | minor | IME input methods (#101) |
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |