diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 37e94859..938e7d34 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -71,10 +71,35 @@ fn select_stop(s: &mut Session, p: &Value) -> Result { Some(i) } }; - s.gradient_stop = index; + s.gradient_stop = index.map(|i| (i, StopOwner::of(s))); Ok(json!({ "index": index })) } +/// Whose gradient a selected stop belongs to: the active document, its first selected object +/// (None: the default paint) and the proxy in front. Selecting other art or toggling the proxy +/// leaves no stop selected. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct StopOwner { + doc: Option, + object: Option, + fill: bool, +} + +impl StopOwner { + fn of(s: &Session) -> Self { + Self { doc: s.active, object: s.active().and_then(|d| d.selection.objects.first().copied()), fill: s.fill_active } + } +} + +impl Session { + /// The selected gradient stop (`gradient.selectStop`), while the gradient it was selected on + /// is still the one behind the active proxy. Callers check it against the stop count (an undo + /// can remove stops). + pub fn selected_stop(&self) -> Option { + self.gradient_stop.filter(|(_, owner)| *owner == StopOwner::of(self)).map(|(i, _)| i) + } +} + type Parsed = std::result::Result; /// Parse a kind name. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 11231ac4..e1e1818e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,7 +15,7 @@ mod edit; mod effectcmd; pub mod fileio; mod fonts; -mod gradient; +pub(crate) mod gradient; pub(crate) mod graph; pub mod help; mod layer; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fc962147..9b182b9e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -443,9 +443,10 @@ pub struct Session { untitled_counter: u32, /// Session-level state of the menu commands (saved selections, guide lock). pub(crate) menu: cmd::menucmds::MenuState, - /// The selected gradient stop (`gradient.selectStop`), shared by the Gradient tool's annotator, - /// the Gradient and Color panels and agents. - pub gradient_stop: Option, + /// The selected gradient stop (`gradient.selectStop`) and whose gradient it was selected on, + /// shared by the Gradient tool's annotator, the Gradient and Color panels and agents: read it + /// with [`Session::selected_stop`]. + pub(crate) gradient_stop: Option<(usize, cmd::gradient::StopOwner)>, } impl Default for Session { diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 934e8b5b..19964c9f 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -285,12 +285,12 @@ fn stop_count(s: &Session, id: NodeId) -> usize { fn select_stop_is_validated_and_shared() { let (mut s, _) = annotated(); assert_eq!(s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(), json!({"index": 1})); - assert_eq!(s.gradient_stop, Some(1)); + assert_eq!(s.selected_stop(), Some(1)); assert!(s.execute("gradient.selectStop", &json!({"index": 2})).is_err(), "two stops"); assert!(s.execute("gradient.selectStop", &json!({"index": "a"})).is_err()); assert!(s.execute("gradient.selectStop", &json!({})).is_err()); s.execute("gradient.selectStop", &json!({"index": null})).unwrap(); - assert_eq!(s.gradient_stop, None); + assert_eq!(s.selected_stop(), None); // The stroke proxy's paint is solid: nothing to select there. s.execute("paint.toggleActive", &json!({})).unwrap(); assert!(s.execute("gradient.selectStop", &json!({"index": 0})).is_err()); @@ -304,7 +304,7 @@ fn annotator_gestures_edit_the_gradient_as_single_undo_steps() { let stops = fill_gradient(&s, id).gradient.stops; assert_eq!(stops.len(), 3); assert!((stops[1].offset - 0.3).abs() < 1e-6); - assert_eq!(s.gradient_stop, Some(1)); + assert_eq!(s.selected_stop(), Some(1)); // Dragging the end handle changes only the end, as one undo step. let undo = s.doc().unwrap().history.undo.len(); gesture( @@ -320,9 +320,29 @@ fn annotator_gestures_edit_the_gradient_as_single_undo_steps() { assert!(s.tool_claims_key(ToolKey::Delete, view)); s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap(); assert_eq!(stop_count(&s, id), 2); - assert_eq!(s.gradient_stop, Some(1)); + assert_eq!(s.selected_stop(), Some(1)); // Never below two stops. s.tool_key(ToolKey::Backspace, Mods::default(), view).unwrap(); assert_eq!(stop_count(&s, id), 2); assert!(s.doc().unwrap().doc.node(id).is_some()); } + +#[test] +fn the_selected_stop_belongs_to_its_gradient() { + let (mut s, a) = annotated(); + let b = rect(&mut s, 300.0, 100.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {}})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(); + let view = crate::ViewInfo::default(); + assert!(s.tool_claims_key(ToolKey::Delete, view)); + // Other art selected: no stop is selected there, so Delete is not the tool's. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(s.selected_stop(), None); + assert!(!s.tool_claims_key(ToolKey::Delete, view)); + // Back on the art it was selected on, it is selected again; the other proxy has none. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.selected_stop(), Some(1)); + s.execute("paint.toggleActive", &json!({})).unwrap(); + assert_eq!(s.selected_stop(), None); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 87d13fdf..d0ca84df 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -79,7 +79,7 @@ impl Session { snap_to_point: view.snap_to_point, corner_widgets: view.corner_widgets, fill_active: self.fill_active, - gradient_stop: self.gradient_stop, + gradient_stop: self.selected_stop(), }; f(self.tool.as_mut(), &cx) } diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 53bc7db2..9ea5772c 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -26,7 +26,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); /// The stops of the gradient behind the active proxy and the selected stop's index. fn selected(app: &VectorcraftApp) -> Option<(Vec, usize)> { let Paint::Gradient(g) = active_paint(app) else { return None }; - let i = app.session.gradient_stop.filter(|i| *i < g.gradient.stops.len())?; + let i = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len())?; Some((g.gradient.stops, i)) } @@ -34,7 +34,7 @@ fn selected(app: &VectorcraftApp) -> Option<(Vec, usize)> { fn apply(app: &mut VectorcraftApp, stops: &[GradientStop], i: usize, phase: Live) { let params = json!({ "stops": stops_json(stops), "stroke": !app.session.fill_active }); live_run(app, "Gradient", "paint.editGradient", params, phase); - if phase == Live::Released && app.session.gradient_stop != Some(i) { + if phase == Live::Released && app.session.selected_stop() != Some(i) { app.run("gradient.selectStop", json!({ "index": i })).ok(); } } @@ -107,7 +107,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if let (Some(tab), Some(d)) = (tab, app.ui.dialog.as_mut()) { d.fields.insert("tab".into(), json!(tab)); } - if resp.clicked_elsewhere() { + // The double-click that opened the popover lands on its chip, outside it: only a later click + // elsewhere closes it. + if resp.clicked_elsewhere() && !ctx.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)) { app.ui.dialog = None; } } @@ -200,7 +202,7 @@ mod tests { crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0 }, view); let d = app.ui.dialog.clone().expect("the popover opened"); assert_eq!((d.kind.as_str(), d.fields["index"].clone(), d.str("tab")), ("gradientStop", json!(1), "color".into())); - assert_eq!(app.session.gradient_stop, Some(1)); + assert_eq!(app.session.selected_stop(), Some(1)); frame(&mut app, vec![]); app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("swatches")); frame(&mut app, vec![]); @@ -221,11 +223,28 @@ mod tests { // Moving it past the last stop keeps it selected at its new index. app.ui.dialog = Some(Dialog::new("gradientStop", json!({"location": 100}))); super::super::confirm(&mut app).unwrap(); - assert_eq!((app.session.gradient_stop, stops(&app)[2].color.to_hex()), (Some(2), "#ff0000".to_string())); + assert_eq!((app.session.selected_stop(), stops(&app)[2].color.to_hex()), (Some(2), "#ff0000".to_string())); app.ui.dialog = Some(Dialog::new("gradientStop", json!({"color": "red"}))); assert!(super::super::confirm(&mut app).is_err()); } + fn click(at: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + #[test] + fn the_opening_double_click_keeps_it_open_and_a_later_click_elsewhere_closes_it() { + let mut app = app(); + app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160}))); + // The frame the double-click lands in (the popover opens beside the pointer, not under it). + let at = egui::pos2(5.0, 5.0); + let double = vec![egui::Event::PointerMoved(at), click(at, true), click(at, false), click(at, true), click(at, false)]; + frame(&mut app, double); + assert!(app.ui.dialog.is_some(), "the opening double-click must not close it"); + frame(&mut app, vec![egui::Event::PointerMoved(at), click(at, true), click(at, false)]); + assert!(app.ui.dialog.is_none(), "a click elsewhere closes it"); + } + #[test] fn the_popover_closes_when_its_stop_goes_away() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 412dd9aa..e9fbf897 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -163,7 +163,7 @@ fn target(app: &VectorcraftApp) -> Target { let p = active_paint(app); match &p { Paint::Gradient(g) => { - let i = app.session.gradient_stop.unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1)); + let i = app.session.selected_stop().unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1)); let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK); Target::Stop { paint: p.clone(), index: i, color } } diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 09371949..80583ce0 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -125,7 +125,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ramp(app, ui, &g.gradient, is_grad); ui.add_space(4.0); // Stop fields. - let sel = app.session.gradient_stop.filter(|i| *i < g.gradient.stops.len()); + let sel = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len()); ui.horizontal(|ui| { let enabled = is_grad && sel.is_some(); let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied(); @@ -210,7 +210,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { } ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); let x_of = |o: f32| bar.left() + o * bar.width(); - let sel = app.session.gradient_stop; + let sel = app.session.selected_stop(); let mut drag: Drag = pstate(ui.ctx(), "grad-drag"); let stops = &g.stops; // While dragging, edits are computed against the stops as they were when the drag began (the