diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index ebef729fd3..08944940a2 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Flare", [], None, - "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}", + "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?, ringSeed? (whole number below 2^53: places the rings along the path anew, keeping their count and sizes)} → {id}", has_doc, flare ), @@ -429,6 +429,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { "direction", "pathLength", "seed", + "ringSeed", ]; check_numbers(p, &numbers, "shape.flare")?; let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0); @@ -450,14 +451,28 @@ fn flare(s: &mut Session, p: &Value) -> Result { c + vectorcraft_geom::Vec2::new(a.cos(), -a.sin()) * f64_or(p, "pathLength", 300.0) } }; - // Deterministic pseudo-random sizes (same parameters → same flare). - let mut seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0x2545_f491_4f6c_dd1d) | 1; - let mut rnd = move || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - (seed >> 11) as f64 / (1u64 << 53) as f64 + // Deterministic pseudo-random sizes (same parameters → same flare). `ringSeed` places the rings + // along the path from a sequence of its own; their count and sizes and the other parts stay. + let seed_of = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| bad("shape.flare", format!("`{key}` must be a whole number of at least 0, not {v}"))), }; + // The generator's state is odd (never 0). + let random = |mut state: u64| { + move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 11) as f64 / (1u64 << 53) as f64 + } + }; + let mut rnd = random(seed_of("seed")?.unwrap_or(0x2545_f491_4f6c_dd1d) | 1); + // Each `ringSeed` below 2^53 (what a JSON number holds exactly) gets a state of its own. + let ring_seed = match seed_of("ringSeed")? { + Some(r) if r >= 1 << 53 => return Err(bad("shape.flare", format!("`ringSeed` must be below 2^53, not {r}"))), + r => r, + }; + let mut ring_at = ring_seed.map(|r| random((r << 1) | 1)); let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) }; let radial = |centre: Point, radius: f64, stops: Vec| { let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops)); @@ -495,6 +510,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { // Rings along the path to the end point (Largest = % of the average ring size). for _ in 0..rings { let t = rnd(); + let t = ring_at.as_mut().map_or(t, |next| next()); let at = c + (end - c) * t; let rr = (r * largest * (0.25 + rnd() * 0.75)).max(0.5); let solid = rnd() < 0.5; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f89cc98ff8..87b8bea0bc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1544,6 +1544,7 @@ fn shape_commands_refuse_a_number_or_boolean_given_as_text() { ("shape.flare", json!({"cx": 100, "cy": 100, "x2": "300", "y2": 300}), "x2"), ("shape.flare", with(&flare, "direction", json!("45")), "direction"), ("shape.flare", with(&flare, "seed", json!("7")), "seed"), + ("shape.flare", with(&flare, "ringSeed", json!("7")), "ringSeed"), ("shape.spiral", with(&spiral, "decay", json!("80")), "decay"), ("shape.spiral", with(&spiral, "segments", json!("10")), "segments"), ] @@ -3268,6 +3269,70 @@ fn flare_rotation_turns_only_the_rays() { } } +/// `shape.flare`'s `ringSeed` places the rings along the path anew and keeps their count and +/// sizes and the other parts; the same `ringSeed` draws the same flare, neighboring ones differ, +/// and a seed that is not a whole number of at least 0 (for `ringSeed`, below 2^53) is refused. +#[test] +fn flare_ring_seed_moves_only_the_rings() { + let mut s = session(); + let parts = |s: &mut Session, ring_seed: Option| { + let mut p = json!({"cx": 200, "cy": 200, "diameter": 60, "rays": 9, "rings": 6, "x2": 400, "y2": 300, "seed": 3}); + if let Some(r) = ring_seed { + p["ringSeed"] = json!(r); + } + let id = NodeId(s.execute("shape.flare", &p).unwrap()["id"].as_u64().unwrap()); + let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("flare is a group") }; + children.iter().map(|c| (c.name.clone(), c.geometric_bounds().unwrap())).collect::>() + }; + let plain = parts(&mut s, None); + let placed = parts(&mut s, Some(11)); + assert_eq!(placed, parts(&mut s, Some(11))); + assert_eq!(plain.len(), placed.len()); + let mut moved = 0; + for ((name, a), (_, b)) in plain.iter().zip(&placed) { + if name.as_deref() == Some("Ring") { + assert!((a.width() - b.width()).abs() < 1e-9, "a ring keeps its size"); + moved += usize::from(a.center().distance(b.center()) > 1e-6); + } else { + assert_eq!(a, b, "{name:?}"); + } + } + assert_eq!(moved, 6, "every ring moves"); + assert_ne!(parts(&mut s, Some(0)), parts(&mut s, Some(1)), "neighboring seeds place the rings differently"); + let undo = s.doc().unwrap().history.undo.len(); + for bad in [json!(-1), json!(2.5), json!(true), json!(1u64 << 53)] { + assert!(s.execute("shape.flare", &json!({"cx": 0, "cy": 0, "ringSeed": bad})).is_err(), "{bad}"); + } + for bad in [json!(-1), json!(2.5), json!(true)] { + assert!(s.execute("shape.flare", &json!({"cx": 0, "cy": 0, "seed": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +/// Through the session, Ctrl/Cmd in the Flare tool's center drag keeps the center's size while the +/// drag sizes the halo, and the flare drawn keeps both. +#[test] +fn flare_tool_ctrl_keeps_the_center_while_the_drag_sizes_the_halo() { + let mut s = session(); + let v = ViewInfo::default(); + let cmd = vectorcraft_tools::Mods { cmd: true, ..Default::default() }; + s.select_tool("flare", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 240.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 200.0).with_mods(cmd), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 200.0).with_mods(cmd), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 500.0, 400.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 500.0, 400.0), v).unwrap(); + let st = s.doc().unwrap(); + let g = st.doc.node(st.selection.objects[0]).unwrap(); + let NodeKind::Group { children, .. } = &g.kind else { panic!("flare is a group") }; + let width = |name: &str| children.iter().find(|c| c.name.as_deref() == Some(name)).unwrap().geometric_bounds().unwrap().width(); + // The center as the key found it (80); the halo as a 160-point center with 20 % growth would + // have it: 80 × 1.2 × 1.6 = 153.6 radius. + assert!((width("Center") - 80.0).abs() < 0.5, "{}", width("Center")); + assert!((width("Halo") - 2.0 * 153.6).abs() < 0.5, "{}", width("Halo")); +} + #[test] fn reshape_moves_the_grabbed_point_and_its_neighbourhood() { let mut s = session(); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index d1a2672402..fee24c340b 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -1,8 +1,10 @@ //! Flare, Reshape, Shaper and Graph tools. //! //! Flare: drag sets the center and its size and turns the rays as the pointer moves up or down -//! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click -//! sets the end point of the rings; Space held during either drag moves the flare. The rest comes +//! (Shift holds their turn, ↑/↓ add or remove rays and Ctrl/Cmd keeps the center's size while the +//! drag sizes the halo; click without dragging opens the Flare Tool Options), then a click sets the +//! end point of the rings (↑/↓ add or remove rings and ~ places them anew); Space held during +//! either drag moves the flare. The rest comes //! from the tool's options (the Flare Tool Options, which last). Reshape: a click on a selected //! path sets a focal point (adding an anchor on a segment), Shift-click adds or removes more, and //! a drag from a focal point moves them all; the engine's `path.reshape` stretches the selected @@ -57,8 +59,19 @@ pub struct FlareTool { /// The pointer at the last press or drag, from which Space held moves the flare. last: Point, began: bool, - /// Ray count changed with ↑/↓ during the gesture (None = the options' count). + /// Ray count changed with ↑/↓ during the center drag (None = the options' count). rays: Option, + /// Ring count changed with ↑/↓ while the rings' end point is placed (None = the options' count). + rings: Option, + /// The rings' placement ~ chose (`shape.flare`'s `ringSeed`; None = the placement `seed` gives). + ring_seed: Option, + /// Whether backquote (the ~ key) was held at the last pointer event. + quote: bool, + /// Ctrl/Cmd held in the center drag: the center's diameter, the Halo growth (%) and the + /// pointer's distance from the center when the key went down. + held: Option<(f64, f64, f64)>, + /// The Halo growth (%) the drag set while Ctrl/Cmd held the center (None = the options'). + growth: Option, /// The rays' turn (`shape.flare`'s `rotation`, degrees) the center drag set. turn: f64, /// The options' values, in [`FLARE_OPTIONS`] order. @@ -77,6 +90,11 @@ impl Default for FlareTool { last: Point::ZERO, began: false, rays: None, + rings: None, + ring_seed: None, + quote: false, + held: None, + growth: None, turn: 0.0, values: FLARE_OPTIONS.map(|(_, default, _, _)| default), rays_on: true, @@ -123,9 +141,27 @@ fn ray_turn(turn: f64, dy: f64, shift: bool) -> f64 { (turn + dy * RAY_DEGREES_PER_POINT).rem_euclid(360.0) } +/// The ring placement ~ chooses after `last`, with the pointer at `at`: a new number below 2^53, so +/// a JSON client reads it exactly, and never `last` again. +fn next_ring_seed(last: Option, at: Point) -> u64 { + let mut x = last.unwrap_or(0x9e37_79b9_7f4a_7c15) ^ at.x.to_bits() ^ at.y.to_bits().rotate_left(29); + loop { + // SplitMix64. + x = x.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + let seed = (z ^ (z >> 31)) & ((1 << 53) - 1); + if Some(seed) != last { + return seed; + } + } +} + impl FlareTool { /// `shape.flare`'s params: the options, with the dragged center and diameter, the rays' turn, - /// the ray count ↑/↓ chose and the rings' end point once clicked. + /// the ray and ring counts ↑/↓ chose, the Halo growth Ctrl/Cmd set, the ring placement ~ chose + /// and the rings' end point once placed. fn params(&self, c: Point, diameter: f64, end: Option) -> Value { let mut v = flare_params(&self.options(), c); v["diameter"] = json!(diameter.max(1.0)); @@ -133,12 +169,62 @@ impl FlareTool { if let Some(r) = self.rays.filter(|_| self.rays_on) { v["rays"] = json!(r); } + if let Some(r) = self.rings.filter(|_| self.rings_on) { + v["rings"] = json!(r); + } + if let Some(g) = self.growth { + v["growth"] = json!(g); + } + if let Some(s) = self.ring_seed.filter(|_| self.rings_on) { + v["ringSeed"] = json!(s); + } if let Some(e) = end { v["x2"] = json!(e.x); v["y2"] = json!(e.y); } v } + + /// Flare Tool Options value `key` as the tool has it. + fn option(&self, key: &str) -> f64 { + FLARE_OPTIONS.iter().position(|(k, ..)| *k == key).map_or(0.0, |i| self.values[i]) + } + + /// Count `now` (else the options' `key`) one up or down, within the options' range for `key`. + fn step_count(&self, key: &str, now: Option, up: bool) -> u64 { + let most = FLARE_OPTIONS.iter().find(|(k, ..)| *k == key).map_or(0.0, |o| o.3) as u64; + let r = now.unwrap_or(self.option(key) as u64); + if up { (r + 1).min(most) } else { r.saturating_sub(1).min(most) } + } + + /// Ctrl/Cmd in the center drag, with the pointer `dist` from the center after `before` at the + /// last drag: once the flare has begun, the center keeps the diameter it showed when the key + /// went down, and the halo's radius keeps its ratio to the pointer's distance through the Halo + /// growth, within the options' 0 to 300 %. Letting the key go sizes the center with the drag + /// again, and the flare keeps the new Halo growth. The key is pressed while dragging: a press + /// made with it already held goes to the selection tool the session lends for Ctrl/Cmd. + fn hold_center(&mut self, before: f64, dist: f64, cmd: bool) { + match (cmd, self.held) { + (true, None) if self.began && before > 0.0 => { + let growth = self.growth.unwrap_or_else(|| self.option("growth")); + self.held = Some((2.0 * before, growth, before)); + } + (false, Some(_)) => self.held = None, + _ => {} + } + if let Some((_, growth, at)) = self.held { + let most = FLARE_OPTIONS.iter().find(|(k, ..)| *k == "growth").map_or(0.0, |o| o.3); + let g = (100.0 + growth) * dist / at - 100.0; + if g.is_finite() { + self.growth = Some(g.clamp(0.0, most)); + } + } + } + + /// The center's diameter with the pointer `dist` from it: twice that, unless Ctrl/Cmd holds it. + fn center_diameter(&self, dist: f64) -> f64 { + self.held.map_or(2.0 * dist, |(d, ..)| d) + } } impl Tool for FlareTool { @@ -150,34 +236,47 @@ impl Tool for FlareTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.hover = ev.pos; + // ~ pressed while the rings' end point is placed places the rings anew (Rings on). + let quote_pressed = ev.backquote && !self.quote; + self.quote = ev.backquote; + if quote_pressed && self.rings_on && self.placed.is_some() && matches!(ev.kind, PointerKind::Drag | PointerKind::Move | PointerKind::Up) { + self.ring_seed = Some(next_ring_seed(self.ring_seed, ev.pos)); + } match ev.kind { PointerKind::Down => { if self.placed.is_none() { self.drag = Some((ev.pos, ev.pos)); self.rays = None; + self.rings = None; + self.ring_seed = None; + self.held = None; + self.growth = None; self.turn = 0.0; } self.last = ev.pos; vec![] } PointerKind::Drag => { - if let Some((mut c, _)) = self.drag { + if let Some((mut c, p)) = self.drag { // Space held moves the center with the pointer, so the size and the turn stay. + let before = p.distance(c); let dy = ev.pos.y - self.last.y; crate::shape::space_moves(&mut c, &mut self.last, ev, self.began); self.drag = Some((c, ev.pos)); if !ev.mods.space { self.turn = ray_turn(self.turn, dy, ev.mods.shift); } - if !self.began && ev.pos.distance(c) >= cx.tol(3.0) { - self.began = true; - return vec![ - Action::Begin("Flare".into()), - Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None)), - ]; + let dist = ev.pos.distance(c); + let begins = !self.began && dist >= cx.tol(3.0); + self.began |= begins; + // With the key held from the start, the center keeps the first size the flare shows. + self.hold_center(if begins { dist } else { before }, dist, ev.mods.cmd); + let preview = Action::Preview("shape.flare".into(), self.params(c, self.center_diameter(dist), None)); + if begins { + return vec![Action::Begin("Flare".into()), preview]; } if self.began { - return vec![Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None))]; + return vec![preview]; } } else if let Some((mut c, d)) = self.placed { // Space held moves the whole flare, rings' end point included. @@ -196,7 +295,8 @@ impl Tool for FlareTool { let Some((c, p)) = self.drag.take() else { return vec![] }; if self.began { // Keep the interaction open: the next click places the end point. - self.placed = Some((c, 2.0 * p.distance(c))); + self.placed = Some((c, self.center_diameter(p.distance(c)))); + self.held = None; vec![] } else { vec![Action::Dialog("flare".into(), json!({ "x": c.x, "y": c.y }))] @@ -212,19 +312,28 @@ impl Tool for FlareTool { } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - // ↑/↓ while drawing add/remove rays. - if matches!(key, ToolKey::Up | ToolKey::Down) && self.began && self.rays_on { - let r = self.rays.unwrap_or_else(|| self.options()["rays"].as_u64().unwrap_or(0)); - self.rays = Some(if key == ToolKey::Up { (r + 1).min(50) } else { r.saturating_sub(1) }); + // ↑/↓ add/remove rays in the center drag and rings while the rings' end point is placed. + if matches!(key, ToolKey::Up | ToolKey::Down) && self.began { + let up = key == ToolKey::Up; let preview = match (self.drag, self.placed) { - (Some((c, p)), _) => self.params(c, 2.0 * p.distance(c), None), - (_, Some((c, d))) => self.params(c, d, Some(self.hover)), + (Some((c, p)), _) if self.rays_on => { + self.rays = Some(self.step_count("rays", self.rays, up)); + self.params(c, self.center_diameter(p.distance(c)), None) + } + (_, Some((c, d))) if self.rings_on => { + self.rings = Some(self.step_count("rings", self.rings, up)); + self.params(c, d, Some(self.hover)) + } _ => return vec![], }; return vec![Action::Preview("shape.flare".into(), preview)]; } if key == ToolKey::Escape && self.busy() { self.rays = None; + self.rings = None; + self.ring_seed = None; + self.held = None; + self.growth = None; self.drag = None; self.placed = None; let began = std::mem::take(&mut self.began); @@ -235,12 +344,14 @@ impl Tool for FlareTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { // Switching away after the centre drag keeps the flare with the rings its options give. self.drag = None; + self.held = None; if self.placed.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] } } fn overlays(&self, _cx: &ToolContext) -> Vec { match (self.drag, self.placed) { (Some((c, p)), _) => { - vec![Overlay::Path { path: ellipse_path(c, p.distance(c), p.distance(c), 0.0), color: BLUE, width: 1.0, dashed: false }] + let r = self.center_diameter(p.distance(c)) / 2.0; + vec![Overlay::Path { path: ellipse_path(c, r, r, 0.0), color: BLUE, width: 1.0, dashed: false }] } (_, Some((c, _))) => vec![Overlay::Line { a: c, b: self.hover, color: BLUE, dashed: true }], _ => vec![], @@ -839,6 +950,131 @@ mod tests { assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(120.0), Some(110.0), Some(340.0))); } + /// ↑/↓ change the rays in the center drag and the rings while the rings' end point is placed, + /// within the options' 0 to 50; with Rings off they leave the second drag alone. + #[test] + fn flare_arrows_change_the_rays_then_the_rings() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let counts = |v: &Value| (v["rays"].as_u64(), v["rings"].as_u64()); + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Up, Mods::default()))), (Some(16), Some(10))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 300.0)); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Up, Mods::default()))), (Some(16), Some(11))); + t.key(&cx, ToolKey::Down, Mods::default()); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Down, Mods::default()))), (Some(16), Some(9))); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 330.0, 300.0)); + assert_eq!(a.last(), Some(&Action::Commit)); + assert_eq!(counts(&flare_preview(&a)), (Some(16), Some(9))); + // The next flare starts from the options' counts; the counts stop at 0 and 50. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + let mut v = Value::Null; + for _ in 0..12 { + v = flare_preview(&t.key(&cx, ToolKey::Down, Mods::default())); + } + assert_eq!(counts(&v), (Some(15), Some(0))); + for _ in 0..60 { + v = flare_preview(&t.key(&cx, ToolKey::Up, Mods::default())); + } + assert_eq!(counts(&v), (Some(15), Some(50))); + t.key(&cx, ToolKey::Escape, Mods::default()); + // Rings off: the arrows do nothing in the second drag. + t.set_option("ringsOn", &json!(false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + assert!(t.key(&cx, ToolKey::Up, Mods::default()).is_empty()); + } + + /// ~ (backquote) pressed while the rings' end point is placed gives the rings a new placement + /// each time, which the flare drawn keeps; holding it, the center drag, Rings off and the next + /// flare give none. + #[test] + fn flare_tilde_places_the_rings_anew() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let quote = |kind, x, y, held| PointerEvent { backquote: held, ..PointerEvent::new(kind, x, y) }; + let seed = |a: &[Action]| flare_preview(a).get("ringSeed").and_then(Value::as_u64); + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + // In the center drag ~ does nothing. + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Drag, 140.0, 100.0, true))), None); + t.pointer(&cx, "e(PointerKind::Drag, 141.0, 100.0, false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 141.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 200.0, 200.0))), None); + let first = seed(&t.pointer(&cx, "e(PointerKind::Move, 200.0, 200.0, true))); + assert!(first.is_some_and(|s| s < 1 << 53)); + // Held, it places them once; pressed again, anew. + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Move, 210.0, 200.0, true))), first); + t.pointer(&cx, "e(PointerKind::Down, 210.0, 200.0, false)); + t.pointer(&cx, "e(PointerKind::Drag, 220.0, 200.0, false)); + let second = seed(&t.pointer(&cx, "e(PointerKind::Drag, 220.0, 200.0, true))); + assert!(second.is_some() && second != first); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 200.0)); + assert_eq!((a.last(), seed(&a)), (Some(&Action::Commit), second)); + // The next flare starts without one, and with Rings off ~ does not change the flare. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0))), None); + t.set_option("ringsOn", &json!(false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Move, 200.0, 200.0, true))), None); + assert_eq!(t.ring_seed, None); + } + + /// Ctrl/Cmd in the center drag keeps the center's diameter as the last drag showed it, and the + /// drag sizes the halo through the Halo growth (0 to 300 %); let go, the drag sizes the center + /// again and the flare keeps the new growth. + #[test] + fn flare_ctrl_keeps_the_center_and_the_drag_sizes_the_halo() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let cmd = Mods { cmd: true, ..Default::default() }; + let sized = |a: &[Action]| { + let v = flare_preview(a); + (v["diameter"].as_f64().unwrap_or(0.0), v["growth"].as_f64().unwrap_or(0.0)) + }; + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0))), (80.0, 20.0)); + // The key goes down with the pointer's next move: the center stays as the last drag showed it. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 100.0).with_mods(cmd))), (80.0, 140.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(cmd))), (80.0, 0.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 900.0, 100.0).with_mods(cmd))), (80.0, 300.0)); + assert_eq!(sized(&t.key(&cx, ToolKey::Up, Mods::default())), (80.0, 300.0)); + // Let go: the center follows the drag again and the growth stays. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0))), (100.0, 300.0)); + // Pressed again, the hold starts from the new center and growth. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 125.0, 100.0).with_mods(cmd))), (100.0, 100.0)); + // A release with the key held places the held center; the second drag draws with it. + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 125.0, 100.0).with_mods(cmd)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 330.0)); + assert_eq!((a.last(), sized(&a)), (Some(&Action::Commit), (100.0, 100.0))); + // With the key held from the press, the center keeps the first size the flare shows; the + // next flare starts from the options' growth. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 110.0).with_mods(cmd))), (20.0, 20.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 120.0).with_mods(cmd))), (20.0, 140.0)); + } + #[test] fn reshape_drag_honors_measurement_labels() { use crate::testutil::*; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 88bb262844..a9410260b6 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -22,7 +22,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo // double-taps) where it was seen last (#491). let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); let view = app.view_info(); - let (_, backquote_before) = backquote_seen(ui); + let (backquote_now, backquote_before) = backquote_seen(ui); let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view, backquote_before, modal }; touch_gestures(app, ui, rect, view, modal); // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the @@ -31,14 +31,15 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo drop_press(app, ui, view); } let drag: Option = ui.data(|d| d.get_temp(drag_id())); - // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows - // without moving the mouse (Alt switches the Shape Builder to erase mode). + // A modifier or Backtick pressed or released over the canvas re-hovers the tool, so what it + // changes shows without moving the mouse (Alt switches the Shape Builder to erase mode, and ~ + // places the rings of a flare being drawn anew). let mods_changed = ui.data_mut(|d| { let id = egui::Id::unique("canvas-mods"); let prev = d.get_temp::(id); d.insert_temp(id, m); prev.is_some_and(|prev| prev != m) - }); + }) || backquote_before.is_some_and(|before| before != backquote_now); // Where the tool hovers: the pointer over the canvas itself, not over a floating panel, a menu, // a popup or a dialog drawn on top of it ([`on_canvas`]). let hover_tool = hover.filter(|p| on_canvas(ui, resp, *p)); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6e48b0a357..c27fc75526 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -9539,6 +9539,64 @@ fn backtick_changed_mid_selection_drag_applies_with_the_pointer_held_still() { frame(&mut app, &ctx, vec![button(to, false)]); } +/// Backtick pressed while a flare's rings' end point is placed places the rings anew, with the +/// pointer held still. +#[test] +fn backtick_places_a_flares_rings_anew_with_the_pointer_held_still() { + use egui::Event; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("flare"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.ui.view.smart_guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let from = xf.to_screen(Point::new(100.0, 100.0)); + let to = xf.to_screen(Point::new(130.0, 100.0)); + let end = xf.to_screen(Point::new(180.0, 160.0)); + assert!(app.canvas_rect.unwrap().contains(end)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |pressed| Event::Key { key: egui::Key::Backtick, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let rings = |app: &VectorcraftApp| { + let st = app.session.doc().unwrap(); + let g = st.doc.node(st.selection.objects[0]).unwrap(); + let vectorcraft_doc::NodeKind::Group { children, .. } = &g.kind else { panic!("flare is a group") }; + children.iter().filter(|c| c.name.as_deref() == Some("Ring")).map(|c| c.geometric_bounds().unwrap()).collect::>() + }; + // Frames a sixtieth of a second apart while the pointer moves, and a second apart with it + // still, so it counts as still once it stops. + let mut time = 0.0; + let mut step = |app: &mut VectorcraftApp, events: Vec| { + time += if events.iter().any(|e| matches!(e, Event::PointerMoved(_))) { 1.0 / 60.0 } else { 1.0 }; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + step(&mut app, vec![Event::PointerMoved(from), button(from, true)]); + step(&mut app, vec![Event::PointerMoved(to)]); + step(&mut app, vec![button(to, false)]); + step(&mut app, vec![Event::PointerMoved(end - vec2(5.0, 5.0))]); + step(&mut app, vec![Event::PointerMoved(end)]); + step(&mut app, vec![]); + step(&mut app, vec![]); + let before = rings(&app); + assert_eq!(before.len(), 10); + assert!(before.iter().all(|r| r.center().y > 90.0 && r.center().x > 90.0), "the rings run towards the pointer: {before:?}"); + step(&mut app, vec![key(true)]); + let after = rings(&app); + assert_eq!(after.len(), 10); + assert_ne!(after, before, "~ places the rings anew"); + step(&mut app, vec![key(false)]); + assert_eq!(rings(&app), after, "letting ~ go keeps the placement"); +} + /// #834 review: a press held still is not a drag. Before the press has dragged, the first held /// frame without a delta at the press's own position sends nothing, so a click stays a click; /// the first move after it does. diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..456521633c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1975,8 +1975,9 @@ and `type.fractionalWidths`; `flip` in `type.dynamicTextPreset`; `remove` and `o These commands refuse a string or another value that is not a number with `` `radius` must be a number, not "2" ``, naming the key, and change nothing: `radius` in `shape.rectangle`; `sides` and `rotation` in `shape.polygon`; `radius2`, `points` and `rotation` in `shape.star`; `diameter`, `opacity`, `brightness`, `growth`, `fuzziness`, -`rays`, `longest`, `rayFuzziness`, `rotation`, `rings`, `largest`, `x2`, `y2`, `direction`, `pathLength` and `seed` -in `shape.flare`; `decay` and `segments` in `shape.spiral`; `rows` and `columns` in `shape.rectangularGrid`; `concentric` +`rays`, `longest`, `rayFuzziness`, `rotation`, `rings`, `largest`, `x2`, `y2`, `direction`, `pathLength`, `seed` and +`ringSeed` in `shape.flare` (`seed` must also be a whole number of at least 0, and `ringSeed` a whole number from +0 to 2^53 - 1); `decay` and `segments` in `shape.spiral`; `rows` and `columns` in `shape.rectangularGrid`; `concentric` and `radial` in `shape.polarGrid`; `size` and the `width`, `height` and `fitMinPercent` of `area` in `text.create`; `size` and `fitMinPercent` in `text.createInPath`; `start`, `end`, `size`, `tracking`, `baselineShift`, `hScale`, `vScale`, `rotation` and `strokeWidth` in `text.setRangeStyle`; `strokeWidth` in `text.setStyle`; `start` and `end` in @@ -10461,7 +10462,14 @@ same dialog, and its OK also draws a flare there. While the center is dragged ou pointer's document y grows adds one degree per document point to `shape.flare`'s `rotation` (it turns only the rays), decreasing y subtracts the same rate, and unchanged y leaves it. This application rate is independent of event frequency; Shift held keeps the turn as it is. Space held keeps the turn as well. Once the center drag has passed its starting distance, and in the -drag that places the rings' end point, Space held moves the flare at its current size. The Artboard tool's are the active artboard's Artboard Options +drag that places the rings' end point, Space held moves the flare at its current size. In the center drag, Up and Down +Arrow add and remove rays, and Ctrl (Command on macOS; `mods.cmd`) keeps the center's diameter as the last drag showed +it while the drag sizes the halo: the halo's radius keeps the ratio to the pointer's distance that it had when the key +went down, through `growth` within 0 to 300 %. Letting the key go sizes the center with the drag again, and the flare +keeps the new `growth`. While the rings' end point is placed, Up and Down Arrow add and remove rings (0 to 50; with +Rings off they do nothing), and each press of backquote (the ~ key; a pointer event's `backquote` turning true) sends a +new `ringSeed`, which moves the rings along the path and keeps their count and sizes. Each new flare starts from the +options' counts and growth, without a `ringSeed`. The Artboard tool's are the active artboard's Artboard Options (dialog `artboardOptions`; the Artboard tool's active artboard while it is in use, else the window's; its `x` and `y` are the artboard's top-left corner in document points, while the dialog shows X and Y at the reference point the Transform panel uses, counted from the ruler origin), a graph tool's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4424700be..34efdd57d0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1028,6 +1028,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. A field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere, such as a panel scrolled under a still pointer, stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field (VectorCraft's own rule and value). - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). + - While a flare's center is dragged out, Ctrl (Command on macOS) keeps the center's size and the drag sizes the halo; while its rings' end point is placed, Up/Down Arrow add and remove rings and ~ places them anew (`shape.flare`'s `ringSeed`) ([Draw and edit flares](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-and-edit-flares.html); `flare_arrows_change_the_rays_then_the_rings`, `flare_tilde_places_the_rings_anew`, `flare_ctrl_keeps_the_center_and_the_drag_sizes_the_halo`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. - The Pattern Options panel's Name field and the name fields that a double-click opens in the Layers, Artboards and Asset Export panels take Shift+Enter (Shift+Return) the same way: it applies the name and keeps the field active with the name selected.