Flare: Ctrl/Cmd holds the center and Up/Down and ~ change the rings while they are placed

While the center is dragged out, Ctrl (Command on macOS) keeps the
center's diameter as the last drag showed it, and the drag sizes the
halo through shape.flare's growth: the halo's radius keeps the ratio to
the pointer's distance that it had when the key went down, within the
Flare Tool Options' 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 within 0 to 50, and leave the rays as the center drag left them.
Before, they changed the rays in both drags. Each press of ~ (backquote)
sends a new ringSeed. shape.flare takes ringSeed, a whole number below
2^53, and places the rings along the path from a sequence of its own,
keeping their count and sizes and the other parts. seed and ringSeed
given as a fraction, a negative number or a boolean are refused. The
canvas re-hovers the tool when backquote goes down or up, so ~ works with
the pointer held still.

With Rings off, the arrows and ~ do not change the flare in the second
step. Each new flare starts from the options' counts and growth, without
a ringSeed.
This commit is contained in:
dotCooCoo
2026-11-14 22:39:10 -08:00
parent 0c1ac3bdc5
commit f4dc71aee8
7 changed files with 420 additions and 35 deletions
+24 -8
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
"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<Value> {
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<GradientStop>| {
let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops));
@@ -495,6 +510,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
// 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;
+65
View File
@@ -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<u64>| {
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::<Vec<_>>()
};
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();
+256 -20
View File
@@ -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<u64>,
/// Ring count changed with ↑/↓ while the rings' end point is placed (None = the options' count).
rings: Option<u64>,
/// The rings' placement ~ chose (`shape.flare`'s `ringSeed`; None = the placement `seed` gives).
ring_seed: Option<u64>,
/// 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<f64>,
/// 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<u64>, 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<Point>) -> 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<u64>, 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<Action> {
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<Action> {
// ↑/↓ 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<Action> {
// 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<Overlay> {
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, &quote(PointerKind::Drag, 140.0, 100.0, true))), None);
t.pointer(&cx, &quote(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, &quote(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, &quote(PointerKind::Move, 210.0, 200.0, true))), first);
t.pointer(&cx, &quote(PointerKind::Down, 210.0, 200.0, false));
t.pointer(&cx, &quote(PointerKind::Drag, 220.0, 200.0, false));
let second = seed(&t.pointer(&cx, &quote(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, &quote(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::*;
+5 -4
View File
@@ -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<Drag> = 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::<egui::Modifiers>(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));
+58
View File
@@ -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::<Vec<_>>()
};
// 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<Event>| {
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.
+11 -3
View File
@@ -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
+1
View File
@@ -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.