Merge PR #674 from NikitkaRa: Canvas: a two-finger twist rotates the selection

# Conflicts:
#	crates/engine/src/cmd/shape.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:42:12 -04:00
7 changed files with 157 additions and 4 deletions
+1
View File
@@ -216,6 +216,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero |
| 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-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
@@ -42,7 +42,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.sizePosition",
"Size and Position…",
@@ -345,6 +353,15 @@ fn size_position(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() {
@@ -1331,3 +1348,51 @@ fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"shapes": new.iter().map(|i| i.0).collect::<Vec<_>>(), "kept": remaining}))
})
}
#[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
@@ -365,7 +365,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()
{
@@ -382,6 +382,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
@@ -341,6 +341,8 @@ pub struct SlideApp {
pub sorter: sorter::State,
/// The slide thumbnail pane was the last thing clicked, so ⌘A selects slides, not shapes.
pub(crate) slides_focused: bool,
/// A trackpad twist is in progress (one undo step when it ends).
twisting: bool,
}
impl SlideApp {
@@ -388,6 +390,7 @@ impl SlideApp {
doc_window: None,
sorter: sorter::State::default(),
slides_focused: false,
twisting: false,
}
}
@@ -1077,6 +1080,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
@@ -1490,6 +1494,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 -1
View File
@@ -26,6 +26,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 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 (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)
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
@@ -371,7 +372,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
@@ -453,6 +454,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme. The checklist row stays P, so breadth stays 79%. Dialogs stay 27 |
| 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-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 |
+3 -1
View File
@@ -1,6 +1,7 @@
# Hardware parity with PowerPoint
> **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
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
@@ -44,7 +45,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) |
@@ -73,5 +74,6 @@ outlines every frame (#67, fix open), and the decoders run in software.
| Date | Change | Summary |
|---|---|---|
| 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-12 | minor | IME input methods (#101) |
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |