diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fa13825f..885488b9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -44,16 +44,19 @@ pub fn specs() -> Vec { } fn restore(s: &mut Session, redo: bool) -> Result { + let vertex_kind = s.tool.vertex_kind.clone(); let st = s.doc_mut()?; let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) }; let Some(e) = from.pop() else { return ok() }; - to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }); + to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind }); st.doc = e.doc; st.selection = e.selection; st.interaction = None; // A later shape can get an undone copy's id: Duplicate starts over with the usual offset. st.last_duplicate = None; st.revision += 1; + // The point type Edit Points remembered with the restored document: handle drags follow it. + s.tool.vertex_kind = e.vertex_kind; Ok(json!({"label": e.label})) } diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs index 7354dff4..90c77e40 100644 --- a/crates/engine/src/cmd/points.rs +++ b/crates/engine/src/cmd/points.rs @@ -837,7 +837,7 @@ fn point_type(s: &mut Session, p: &Value) -> Result { _ => return Err(bad("shape.pointType", "`type` is smooth, straight or corner")), }; // A straight point's handles can be as long as each other; remember the choice for drags. - s.tool.vertex_kind = Some((path, index, kind.clone())); + s.tool.vertex_kind = Some((id, path, index, kind.clone())); edit_path(s, id, path, "shape.pointType", move |segs, vs, _| { let si = *vs.get(index).ok_or_else(|| bad("shape.pointType", "no such point"))?; set_type(segs, si, &kind); @@ -931,7 +931,7 @@ fn close_path(s: &mut Session, p: &Value) -> Result { /// Opening or closing renumbers the points of `path`: a point type remembered for one of them /// would follow the wrong point. fn forget_kinds(s: &mut Session, path: usize) { - if s.tool.vertex_kind.as_ref().is_some_and(|k| k.0 == path) { + if s.tool.vertex_kind.as_ref().is_some_and(|k| k.1 == path) { s.tool.vertex_kind = None; } } diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 2e3e2bda..c90e004e 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -1646,7 +1646,7 @@ mod tests { 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() }); + st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind: None }); s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap(); assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move"); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 54380bd1..4b8128cc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -163,6 +163,8 @@ pub struct HistoryEntry { pub label: String, pub doc: Arc, pub selection: Selection, + /// The point type Edit Points remembered with `doc` (`ToolState::vertex_kind`). + pub vertex_kind: Option<(ShapeId, usize, usize, String)>, } #[derive(Clone, Debug, Default)] @@ -184,6 +186,8 @@ pub struct Interaction { pub label: String, pub doc: Arc, pub selection: Selection, + /// The point type Edit Points remembered when it began. + pub vertex_kind: Option<(ShapeId, usize, usize, String)>, } /// Text an input method is composing (#101): its preedit sits in the edited text body, at `start` @@ -599,6 +603,7 @@ impl Session { } } let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone())); + let vertex_kind = self.tool.vertex_kind.clone(); let outer = self.depth == 0; self.depth += 1; let r = (spec.run)(self, params); @@ -617,6 +622,9 @@ impl Session { st.selection = sel; st.revision += 1; } + if outer { + self.tool.vertex_kind = vertex_kind; + } return Err(e); } }; @@ -630,7 +638,7 @@ impl Session { && spec.undoable && outer { - push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel }); + push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel, vertex_kind }); } if spec.journal && outer { self.journal.push((id.to_string(), params.clone())); @@ -661,10 +669,11 @@ impl Session { /// Keep an input method's composition as it stands (#101): its text stays and the whole /// composition becomes one undo step. Nothing happens without one. pub fn end_composition(&mut self) { + let vertex_kind = self.tool.vertex_kind.clone(); let Some(st) = self.active_mut() else { return }; let Some(c) = st.composition.take() else { return }; if !Arc::ptr_eq(&c.before, &st.doc) { - push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection }); + push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection, vertex_kind }); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d324b7d5..b3a13743 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2242,6 +2242,90 @@ fn edit_points_handle_drag_modifiers_set_the_point_type() { assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "straight"); } +/// A handle drag without a modifier follows the point type remembered with the document that +/// Undo or Redo restores, and only on the shape the type was chosen on. +#[test] +fn edit_points_remembered_point_type_follows_undo_redo_and_its_shape() { + use crate::tools::{Mods, PointerEvent, PointerKind}; + use deckcraft_geom::Vec2; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let id = deckcraft_model::ShapeId(s.execute("shape.points", &json!({})).unwrap()["id"].as_u64().unwrap() as u32); + let handles = |s: &Session| { + let st = s.doc().unwrap(); + let (hi, ho, v) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 0).unwrap(); + (hi.unwrap(), ho.unwrap(), v) + }; + let ev = |kind, p: deckcraft_geom::Point, mods: Mods| PointerEvent { kind, x: p.x, y: p.y, mods, ..Default::default() }; + let drag = |s: &mut Session, from: deckcraft_geom::Point, to: deckcraft_geom::Point, mods: Mods| { + s.pointer(ev(PointerKind::Down, from, Mods::default())).unwrap(); + s.pointer(ev(PointerKind::Drag, to, mods)).unwrap(); + s.pointer(ev(PointerKind::Up, to, mods)).unwrap(); + }; + let (_, ho, v) = handles(&s); + drag(&mut s, v, v, Mods::default()); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "smooth"); + drag(&mut s, ho, ho + Vec2::new(10.0, -25.0), Mods { alt: true, ..Default::default() }); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "corner"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "smooth"); + // A plain drag moves the other handle with the dragged one, as for a smooth point. + let (_, ho1, _) = handles(&s); + let to = v + Vec2::new(0.0, 40.0); + drag(&mut s, ho1, to, Mods::default()); + let (hi2, ho2, _) = handles(&s); + assert!((ho2 - to).hypot() < 1e-3, "{ho2:?}"); + assert!((hi2 - (v - Vec2::new(0.0, 40.0))).hypot() < 1e-3, "the other handle mirrors the dragged one: {hi2:?}"); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "smooth"); + // Undo of a Corner Point choice that left the outline as it was: the point is smooth again. + s.execute("shape.pointType", &json!({"index": 0, "type": "corner"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let to = v + Vec2::new(0.0, 50.0); + drag(&mut s, v + Vec2::new(0.0, 40.0), to, Mods::default()); + let (hi3, ho3, _) = handles(&s); + assert!((ho3 - to).hypot() < 1e-3, "{ho3:?}"); + assert!((hi3 - (v - Vec2::new(0.0, 50.0))).hypot() < 1e-3, "the other handle mirrors the dragged one: {hi3:?}"); + // Undo of a step that leaves the outline alone keeps a Straight choice, which equal handles + // cannot show: a plain drag keeps the other handle's length. + s.execute("shape.pointType", &json!({"index": 0, "type": "straight"})).unwrap(); + s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let to2 = v + Vec2::new(0.0, 70.0); + drag(&mut s, to, to2, Mods::default()); + let (hi4, ho4, _) = handles(&s); + assert!((ho4 - to2).hypot() < 1e-3, "{ho4:?}"); + assert!((hi4 - (v - Vec2::new(0.0, 50.0))).hypot() < 1e-3, "the other handle keeps its length: {hi4:?}"); + // Redo brings a Corner Point choice back with the step: a plain drag moves one handle. + s.execute("shape.pointType", &json!({"index": 0, "type": "corner"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + drag(&mut s, to2, to2 + Vec2::new(10.0, 0.0), Mods::default()); + let (hi5, ho5, _) = handles(&s); + assert!((ho5 - (to2 + Vec2::new(10.0, 0.0))).hypot() < 1e-3, "{ho5:?}"); + assert!((hi5 - hi4).hypot() < 1e-3, "the other handle stays: {hi5:?}"); + // A Point Type choice that fails leaves the remembered type as it was. + assert!(s.execute("shape.pointType", &json!({"index": 99, "type": "smooth"})).is_err()); + assert_eq!(s.tool.vertex_kind, Some((id, 0, 0, "corner".to_string()))); + // The remembered type belongs to its shape: the same point of another ellipse stays smooth. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 300, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let other = deckcraft_model::ShapeId(s.execute("shape.points", &json!({})).unwrap()["id"].as_u64().unwrap() as u32); + assert_ne!(other, id); + let handles_of = |s: &Session| { + let st = s.doc().unwrap(); + let (hi, ho, v) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(other).unwrap(), 0, 0).unwrap(); + (hi.unwrap(), ho.unwrap(), v) + }; + let (_, ho6, v6) = handles_of(&s); + drag(&mut s, v6, v6, Mods::default()); + let to6 = v6 + Vec2::new(0.0, 40.0); + drag(&mut s, ho6, to6, Mods::default()); + let (hi6, _, _) = handles_of(&s); + assert!((hi6 - (v6 - Vec2::new(0.0, 40.0))).hypot() < 1e-3, "the other handle mirrors the dragged one: {hi6:?}"); +} + #[test] fn edit_points_straight_and_curved_segments() { let mut s = Session::with_new(); @@ -2300,8 +2384,7 @@ fn edit_points_open_and_close_path() { }; assert!(s.execute("shape.closePath", &json!({"index": 0})).is_err(), "the outline is closed already"); // A point type remembered for handle drags belongs to the old numbering: opening forgets it. - s.execute("shape.pointType", &json!({"index": 2, "type": "straight"})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); + s.tool.vertex_kind = s.tool.edit_points.map(|id| (id, 0, 2, "straight".to_string())); assert!(s.tool.vertex_kind.is_some()); // Open Path at the bottom-right corner: the outline now starts and ends there, as two points. s.execute("shape.openPath", &json!({"index": 2})).unwrap(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index e29ff260..e7e85b75 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -269,8 +269,8 @@ pub struct ToolState { /// The segment (path, index of the vertex ending it) last right-clicked in Edit Points, for /// the segment menu. pub edit_segment: Option<(usize, usize)>, - /// The point type last chosen for a vertex (path, index, type): handle drags follow it. - pub vertex_kind: Option<(usize, usize, String)>, + /// The point type last chosen for a vertex (shape, path, index, type): handle drags follow it. + pub vertex_kind: Option<(ShapeId, usize, usize, String)>, /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. pub guides_visible: bool, /// The guide being dragged and where it is now (slide points); `None` once dropped. @@ -522,10 +522,11 @@ impl Session { } pub(crate) fn begin(&mut self, label: &str) { + let vertex_kind = self.tool.vertex_kind.clone(); if let Some(st) = self.active_mut() && st.interaction.is_none() { - st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone() }); + st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind }); } } @@ -534,7 +535,7 @@ impl Session { && let Some(i) = st.interaction.take() && !Arc::ptr_eq(&i.doc, &st.doc) { - crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection }); + crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection, vertex_kind: i.vertex_kind }); } } @@ -1028,12 +1029,12 @@ impl Session { fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point, held: Option<&str>) -> Result { let kind = match held { Some(k) => Some(k.to_string()), - None => self.tool.vertex_kind.clone().filter(|(pp, i, _)| (*pp, *i) == (path, index)).map(|k| k.2), + None => self.tool.vertex_kind.clone().filter(|(s, pp, i, _)| (*s, *pp, *i) == (id, path, index)).map(|k| k.3), }; let r = cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))?; if let Some(k) = held { - self.tool.vertex_kind = Some((path, index, k.to_string())); + self.tool.vertex_kind = Some((id, path, index, k.to_string())); } Ok(r) } @@ -1711,8 +1712,9 @@ impl Session { } Ok(()) })?; + let vertex_kind = self.tool.vertex_kind.clone(); if let Some(st) = self.active_mut() { - crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before }); + crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before, vertex_kind }); } Ok(Value::Null) }