Merge the startup, clipboard, animation-preview and UI PRs integrated on land-r5 (#644 #652 #655 #702 #571 #539 #674 #648 #697 #479 #369 #368)

# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/ribbon.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:58:48 -04:00
46 changed files with 3238 additions and 235 deletions
+74 -5
View File
@@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_model::text::TextBody;
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
use serde_json::{Value, json};
@@ -13,9 +14,17 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy),
cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste),
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy),
cmd!(
"edit.paste",
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{text?: plain text to paste, html?: rich text from another app, scope?: slides}",
has_doc,
paste
),
cmd!(
"edit.pasteSpecial",
"Paste Special",
@@ -134,15 +143,41 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
Ok(plain)
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// A caret copies nothing; the previous clipboard must not leak into this answer.
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
Ok(json!({"text": t}))
let html = if caret { String::new() } else { copied_html(s) };
Ok(json!({"text": t, "html": html}))
}
/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).
fn copied_html(s: &Session) -> String {
if let Some(body) = &s.clipboard.text {
return richclip::body_to_html(body);
}
let mut paragraphs = Vec::new();
for sh in &s.clipboard.shapes {
if let Some(t) = &sh.text {
paragraphs.extend(t.paragraphs.clone());
}
}
if paragraphs.is_empty() {
return String::new();
}
richclip::body_to_html(&TextBody { paragraphs, ..Default::default() })
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
let html = if caret { String::new() } else { copied_html(s) };
delete(s, p)?;
Ok(json!({"text": t}))
Ok(json!({"text": t, "html": html}))
}
/// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends
@@ -249,9 +284,16 @@ fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// External plain text (from the system clipboard) wins when given and differs from ours.
// HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.
if let Some(t) = str_param(p, "text")
&& t != s.clipboard.plain
{
if let Some(html) = str_param(p, "html")
&& let Some(body) = richclip::html_to_body(html)
&& richclip::same_words(&body.text(), t)
{
return paste_external_body(s, &body);
}
return paste_plain(s, t);
}
let editing = s.doc()?.selection.text.is_some();
@@ -328,6 +370,33 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn paste_external_body(s: &mut Session, body: &TextBody) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return super::text::paste_body(s, body);
}
let plain = body.text();
let st = s.doc()?;
let size = st.doc.slide_size;
let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0];
let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?;
let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?;
let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?;
let body = body.clone();
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?;
let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?;
if let Some(t) = sh.text.as_mut() {
let (wrap, autofit) = (t.body.wrap, t.body.autofit);
*t = body.clone();
t.body.wrap = wrap;
t.body.autofit = autofit;
}
sel.shapes = vec![ShapeId(id)];
sel.text = None;
Ok(json!({"id": id}))
})
}
fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return super::text::insert_text(s, text, None);
+1
View File
@@ -17,6 +17,7 @@ pub mod picture_style;
pub mod points;
pub mod recolor;
pub mod review;
mod richclip;
pub mod selection;
pub mod shape;
pub mod slide;
+346
View File
@@ -0,0 +1,346 @@
//! Rich text on the system clipboard, as HTML.
//!
//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
const MAX_HTML: usize = 1 << 20;
const MAX_PARAS: usize = 2_000;
const MAX_RUNS: usize = 10_000;
/// HTML for `body`: one `<p>` per paragraph, `<b>`, `<i>` and `<u>` for the formatting.
pub fn body_to_html(body: &TextBody) -> String {
let mut out = String::new();
for (i, p) in body.paragraphs.iter().enumerate() {
if i > 0 {
out.push_str("<br>");
}
out.push_str("<p>");
for r in &p.runs {
let (open, close) = tags(&r.props);
out.push_str(&open);
match &r.kind {
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
_ => push_escaped(&mut out, &r.text),
}
out.push_str(&close);
if out.len() > MAX_HTML {
break;
}
}
out.push_str("</p>");
}
out
}
fn tags(props: &RunProps) -> (String, String) {
let mut open = String::new();
let mut close = String::new();
if props.bold == Some(true) {
open.push_str("<b>");
close.insert_str(0, "</b>");
}
if props.italic == Some(true) {
open.push_str("<i>");
close.insert_str(0, "</i>");
}
if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) {
open.push_str("<u>");
close.insert_str(0, "</u>");
}
(open, close)
}
fn push_escaped(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => out.push(c),
}
}
}
/// The formatted text of an HTML fragment, or `None` when it has no text.
///
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
/// says bold, italic or underline. Other tags are ignored. The result is capped.
pub fn html_to_body(html: &str) -> Option<TextBody> {
if html.len() > MAX_HTML {
return None;
}
let bytes = html.as_bytes();
let mut i = 0;
let mut bold = 0i32;
let mut italic = 0i32;
let mut underline = 0i32;
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
let mut runs = 0usize;
let mut skip = 0i32;
while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS {
if bytes[i] == b'<' {
let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len());
let raw = &html[i + 1..end];
i = (end + 1).min(bytes.len());
let token = raw.trim();
if token.starts_with('!') || token.starts_with('?') {
continue;
}
let closing = token.starts_with('/');
let name_src = token.trim_start_matches('/').trim();
let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len());
let name = name_src[..name_end].to_ascii_lowercase();
if name == "script" || name == "style" || name == "head" {
if closing {
skip = (skip - 1).max(0);
} else if !token.ends_with('/') {
skip += 1;
}
continue;
}
if skip > 0 {
continue;
}
if closing {
let kind = tag_kind(&name);
if !kind.is_empty()
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
{
let (_, db, di, du) = stack[pos];
bold -= db;
italic -= di;
underline -= du;
stack.truncate(pos);
}
if is_block(&name) {
new_para(&mut paragraphs);
}
continue;
}
if is_break(&name) {
new_para(&mut paragraphs);
continue;
}
let (db, di, du) = tag_style(&name, name_src);
if db + di + du > 0 {
bold += db;
italic += di;
underline += du;
if !token.ends_with('/') {
stack.push((tag_kind(&name), db, di, du));
} else {
bold -= db;
italic -= di;
underline -= du;
}
}
continue;
}
if skip > 0 {
i += 1;
continue;
}
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
let text = decode_text(&html[i..end]);
i = end;
if text.is_empty() {
continue;
}
push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0);
}
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
paragraphs.pop();
}
if paragraphs.iter().all(Paragraph::is_empty) {
return None;
}
for p in &mut paragraphs {
p.normalize();
}
Some(TextBody { paragraphs, ..Default::default() })
}
fn tag_kind(name: &str) -> &'static str {
match name {
"b" | "strong" => "b",
"i" | "em" => "i",
"u" => "u",
"span" | "font" => "span",
"p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block",
_ => "",
}
}
fn is_break(name: &str) -> bool {
name == "br" || name == "hr"
}
fn is_block(name: &str) -> bool {
matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote")
}
fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
let mut bold = i32::from(name == "b" || name == "strong");
let mut italic = i32::from(name == "i" || name == "em");
let mut underline = i32::from(name == "u");
if let Some(style) = attr(src, "style") {
let s = style.to_ascii_lowercase();
if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) {
bold = 1;
}
if s.contains("font-style:italic") || s.contains("font-style: italic") {
italic = 1;
}
if s.contains("text-decoration:underline")
|| s.contains("text-decoration: underline")
|| s.contains("text-decoration-line:underline")
|| s.contains("text-decoration-line: underline")
{
underline = 1;
}
}
(bold, italic, underline)
}
fn weight_bold(style: &str) -> bool {
let Some(at) = style.find("font-weight") else { return false };
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9')
}
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
let at = lower.find(key)?;
let after = tag.get(at + key.len()..)?.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let quote = after.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = after.get(quote.len_utf8()..)?;
let end = rest.find(quote)?;
rest.get(..end)
}
fn new_para(paragraphs: &mut Vec<Paragraph>) {
if paragraphs.len() >= MAX_PARAS {
return;
}
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
paragraphs.push(Paragraph::default());
}
}
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
if *runs >= MAX_RUNS {
return;
}
let Some(p) = paragraphs.last_mut() else { return };
let mut props = RunProps::default();
if bold {
props.bold = Some(true);
}
if italic {
props.italic = Some(true);
}
if underline {
props.underline = Some("sng".into());
}
p.runs.push(Run::with(text, props));
*runs += 1;
}
fn decode_text(raw: &str) -> String {
let mut out = String::new();
let mut rest = raw;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at].replace(['\n', '\r'], ""));
rest = &rest[at..];
let Some(end) = rest.find(';') else {
out.push_str(&rest.replace(['\n', '\r'], ""));
break;
};
let entity = &rest[1..end];
rest = &rest[end + 1..];
match entity {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
"nbsp" => out.push(' '),
_ => {
let ch = entity.strip_prefix('#').and_then(numeric_char);
if let Some(c) = ch {
out.push(c);
}
}
}
}
if !rest.is_empty() && !rest.starts_with('&') {
out.push_str(&rest.replace(['\n', '\r'], ""));
}
out
}
fn numeric_char(digits: &str) -> Option<char> {
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
}
/// Do the HTML's words and the paste's plain text say the same thing?
pub fn same_words(html_plain: &str, pasted: &str) -> bool {
let a = fold(html_plain);
let b = fold(pasted);
a == b
}
fn fold(s: &str) -> String {
s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bold_italic_and_underline_round_trip() {
let mut body = TextBody::from_text("Hi there");
body.paragraphs[0].runs = vec![
Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }),
Run::new(" "),
Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }),
];
let html = body_to_html(&body);
assert!(html.contains("<b>Hi</b>"), "{html}");
assert!(html.contains("<i><u>there</u></i>") || html.contains("<u><i>there</i></u>"), "{html}");
let back = html_to_body(&html).unwrap();
assert_eq!(back.text(), "Hi there");
assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true));
let last = back.paragraphs[0].runs.last().unwrap();
assert_eq!(last.props.italic, Some(true));
assert!(last.props.underline.is_some());
}
#[test]
fn pages_style_and_escapes() {
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A &amp; B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
let body = html_to_body(html).unwrap();
assert_eq!(body.text(), "A & B\nC");
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true));
assert!(same_words(&body.text(), "A & B\nC"));
assert!(!same_words(&body.text(), "other"));
}
#[test]
fn a_huge_fragment_is_refused() {
let html = "x".repeat(MAX_HTML + 1);
assert!(html_to_body(&html).is_none());
}
}
+151 -1
View File
@@ -29,11 +29,28 @@ pub fn specs() -> Vec<CommandSpec> {
has_slide,
freeform
),
cmd!(
"draw.inkToShape",
"Ink to Shape",
["Draw", "Convert"],
None,
"{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles",
has_slide,
ink_to_shape
),
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
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…",
@@ -336,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() {
@@ -1246,3 +1272,127 @@ mod size_position_tests {
assert!(err.to_string().contains("nothing selected"), "{err}");
}
}
/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape
/// outlined in the stroke's colour and width; the rest stays ink. One undo step.
fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
use crate::inkshape::{Recognized, recognize};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
let shapes = s.doc()?.shapes();
let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else {
return Ok(json!({"shapes": [], "kept": 0}));
};
let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0)));
let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) };
let found: Vec<Option<Recognized>> = strokes
.iter()
.map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::<Vec<_>>()) })
.collect();
if found.iter().all(Option::is_none) {
return Ok(json!({"shapes": [], "kept": strokes.len()}));
}
s.edit(|doc, sel| {
let mut new = vec![];
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) };
let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) };
let all = std::mem::take(strokes);
let mut kept = vec![];
let mut made = vec![];
for (st, r) in all.into_iter().zip(found.iter()) {
let Some(r) = r else {
kept.push(st);
continue;
};
let (preset, xfrm) = match *r {
Recognized::Line { x0, y0, x1, y1 } => {
("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0))
}
Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)),
Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)),
Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }),
};
made.push((preset, xfrm, st.color, st.width));
}
let remaining = kept.len();
*strokes = kept;
if remaining == 0 {
list.remove(pos);
}
for (preset, xfrm, color, width) in made {
let id = ShapeId(doc.alloc_id());
let name = format!(
"{} {}",
match preset {
"line" => "Straight Connector",
"rect" => "Rectangle",
"ellipse" => "Oval",
_ => "Isosceles Triangle",
},
id.0
);
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
list.push(Shape {
id,
name,
xfrm: Some(xfrm),
geom: Geom::preset(preset),
fill: Some(Fill::None),
line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))),
kind: ShapeKind::Shape,
..Default::default()
});
new.push(id);
}
sel.shapes = new.clone();
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");
}
}