//! Double-clicks as a mouse sends them (press, release, press, double-click, with a pixel or two //! of hand movement between) into a group's text, and the drag threshold that keeps such clicks //! from moving or resizing what they land on. use serde_json::{Value, json}; use crate::Session; use crate::params::id_of; use crate::tools::{PointerEvent, PointerKind}; use figcraft_doc::NodeId; fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } /// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text). fn scene(s: &mut Session) -> (NodeId, NodeId) { // The text is measured with a font: without craft-fonts that is a system one, scanned here // rather than by whichever test happens to run first (#333). figcraft_text::scan_system_fonts(); let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap(); let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap(); let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap(); let g = id_of(&ex(s, "object.group", json!({"ids": [r, t]}))["id"]).unwrap(); ex(s, "select.none", json!({})); (g, t) } fn send(s: &mut Session, kind: PointerKind, x: f64, y: f64, zoom: f64) { s.pointer(&PointerEvent::at(kind, x, y, zoom)).unwrap(); } /// A click at (x, y) whose release is `jitter` away. fn click(s: &mut Session, x: f64, y: f64, jitter: f64, zoom: f64) { send(s, PointerKind::Down, x, y, zoom); send(s, PointerKind::Move, x + jitter, y, zoom); send(s, PointerKind::Up, x + jitter, y, zoom); } /// What the canvas sends for a double-click: a click, a second press, the double-click (no /// release), then the pointer hovering on. fn double_click(s: &mut Session, x: f64, y: f64, jitter: f64, zoom: f64) { click(s, x, y, jitter, zoom); send(s, PointerKind::Down, x, y, zoom); send(s, PointerKind::Move, x + jitter, y, zoom); send(s, PointerKind::DoubleClick, x + jitter, y, zoom); send(s, PointerKind::Move, x + jitter + 1.0, y, zoom); } fn editing(s: &Session) -> Option { s.ds().unwrap().text_edit.as_ref().map(|t| t.id) } fn origin(s: &Session, id: NodeId) -> (f64, f64, f64, f64) { let b = s.doc().unwrap().world_bounds(id).unwrap(); (b.x0, b.y0, b.width(), b.height()) } #[test] fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() { // The middle of the text (a move would start there), near its edges (a resize would) and // with the hand moving a pixel or two between press and release. for (x, y) in [(100.0, 70.0), (100.0, 61.0), (100.0, 78.0), (61.0, 70.0), (80.0, 70.0)] { for jitter in [0.0, 1.0, 2.0] { let mut s = Session::new(); let (g, t) = scene(&mut s); let before = origin(&s, t); click(&mut s, x, y, jitter, 1.0); assert_eq!(s.selection(), vec![g], "a click picks the group ({x}, {y}, {jitter})"); double_click(&mut s, x, y, jitter, 1.0); assert_eq!(s.selection(), vec![t], "a double-click picks the text ({x}, {y}, {jitter})"); assert_eq!(editing(&s), None); double_click(&mut s, x, y, jitter, 1.0); assert_eq!(editing(&s), Some(t), "the next one edits it ({x}, {y}, {jitter})"); assert_eq!(origin(&s, t), before, "and nothing was dragged ({x}, {y}, {jitter})"); } } } #[test] fn editing_text_keeps_other_document_changes_since_open() { let mut s = Session::new(); let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap(); let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap(); ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}})); ex(&mut s, "edit.delete", json!({"ids": [deleted]})); let bounds = s.doc().unwrap().world_bounds(text).unwrap(); double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0); assert_eq!(editing(&s), Some(text)); crate::textedit::insert(&mut s, "B").unwrap(); crate::textedit::end(&mut s).unwrap(); assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0); assert!(!s.doc().unwrap().contains(deleted)); assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B"); } #[test] fn a_press_drags_only_past_the_threshold_in_screen_pixels() { for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] { let mut s = Session::new(); let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap(); ex(&mut s, "view.snapPixel", json!({"on": false})); // A move from the middle. send(&mut s, PointerKind::Down, 100.0, 100.0, zoom); send(&mut s, PointerKind::Move, 100.0 + by, 100.0, zoom); send(&mut s, PointerKind::Up, 100.0 + by, 100.0, zoom); assert_eq!(origin(&s, r).0 != 0.0, moves, "move by {by} at {zoom}"); // Past the threshold the layer follows the pointer from where it was pressed, and coming // back near the start keeps following. send(&mut s, PointerKind::Down, 100.0, 100.0, zoom); send(&mut s, PointerKind::Move, 100.0 + 40.0 / zoom, 100.0, zoom); send(&mut s, PointerKind::Move, 100.0 + 1.0 / zoom, 100.0, zoom); let x = origin(&s, r).0; send(&mut s, PointerKind::Up, 100.0 + 1.0 / zoom, 100.0, zoom); s.undo().unwrap(); assert!(x != 0.0 || !moves, "back near the start still follows at {zoom}"); } // A resize from the right edge. for (by, resizes) in [(2.0, false), (4.0, true)] { let mut s = Session::new(); let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap(); send(&mut s, PointerKind::Down, 200.0, 100.0, 1.0); send(&mut s, PointerKind::Move, 200.0 + by, 100.0, 1.0); send(&mut s, PointerKind::Up, 200.0 + by, 100.0, 1.0); assert_eq!(origin(&s, r).2 != 200.0, resizes, "resize by {by}"); } } /// A double-click with Alt held throughout, as the canvas sends it. fn alt_double_click(s: &mut Session, x: f64, y: f64, zoom: f64) { for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] { let mut ev = PointerEvent::at(kind, x, y, zoom); ev.alt = true; s.pointer(&ev).unwrap(); } } /// "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to /// set it to Hug contents." and "Hold ⌥ Option or Alt and double-click the vertical or horizontal /// edge of an auto layout object's bounding box to set it to Fill container." (Guide to auto /// layout): a vertical edge sizes the width, a horizontal one the height; a layer auto layout /// doesn't size keeps the double-click's old meaning. #[test] fn a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills() { use figcraft_doc::Sizing; let mut s = Session::new(); let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap(); ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0}})); let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"fill": "#FF0000"}}))["id"]) .unwrap(); let sizing = |s: &Session, id: NodeId| { let cl = &s.doc().unwrap().node(id).unwrap().child_layout; (cl.sizing_h, cl.sizing_v) }; let size = |s: &Session, id: NodeId| { let n = s.doc().unwrap().node(id).unwrap(); (n.width, n.height) }; ex(&mut s, "select.set", json!({"ids": [f]})); assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0))); // The frame's right edge: Hug contents horizontally, and the frame shrinks onto its child. double_click(&mut s, 400.0, 50.0, 0.0, 1.0); assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Fixed)); assert_eq!(size(&s, f), (100.0, 100.0)); assert_eq!(s.selection(), [f], "still selected, not entered"); s.undo().unwrap(); assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)), "one undo step"); // Its bottom edge: Hug contents vertically. double_click(&mut s, 200.0, 100.0, 0.0, 1.0); assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Hug)); assert_eq!(size(&s, f), (400.0, 40.0)); // Alt on the child's right edge: Fill container horizontally, and the child takes the width. ex(&mut s, "select.set", json!({"ids": [c]})); alt_double_click(&mut s, 100.0, 20.0, 1.0); assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed)); assert_eq!(size(&s, c), (400.0, 40.0)); // Without Alt the child, no auto layout frame itself, keeps the old meaning (a frame's // double-click: nothing to enter here), and a plain rectangle's edge still edits its points. double_click(&mut s, 400.0, 20.0, 0.0, 1.0); assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed)); // A text child of the frame: Alt+double-click on its edge sets Fill like any child's, and a // plain double-click on its edge still enters the text. let x = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap(); ex(&mut s, "node.set", json!({"id": x, "props": {"textAutoResize": "none", "width": 60, "height": 20}})); ex(&mut s, "select.set", json!({"ids": [x]})); let b = s.doc().unwrap().world_bounds(x).unwrap(); alt_double_click(&mut s, b.x1, (b.y0 + b.y1) / 2.0, 1.0); assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed), "a text child fills too"); let b = s.doc().unwrap().world_bounds(x).unwrap(); // On the edge handle, a pixel in (the hit test's reach): the text, not the frame edge it shares. double_click(&mut s, b.x1 - 1.0, (b.y0 + b.y1) / 2.0, 0.0, 1.0); assert!(s.ds().unwrap().text_edit.as_ref().is_some_and(|te| te.id == x), "a text's edge still enters it without Alt"); assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed)); crate::textedit::end(&mut s).unwrap(); let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 100, "height": 100}))["id"]).unwrap(); ex(&mut s, "select.set", json!({"ids": [r]})); double_click(&mut s, 99.0, 350.0, 0.0, 1.0); assert_eq!(sizing(&s, r), (Sizing::Fixed, Sizing::Fixed)); assert!(s.ds().unwrap().vector_edit.is_some(), "a shape's double-click still edits its points"); }