diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index ec2b89d81..878224283 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -357,11 +357,13 @@ pub(crate) fn click_selection(ui: &Ui, anchor_key: &str, mut sel: Vec, o /// A drag released on the panel. enum Drop { /// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or - /// at the end (`None`). - Move { names: Vec, target: Option, after: bool }, + /// at the end (`None`). With Alt held, `with` names the swatch grabbed: dropped on swatch + /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it + /// gives that swatch its paint instead of moving. + Move { names: Vec, target: Option, after: bool, with: Option }, /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch - /// `target` when that is of its kind. + /// `target` when that is of its kind and not a tint swatch. New { paint: Paint, target: Option, replace: bool }, /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). Pattern { ids: Vec }, @@ -369,11 +371,15 @@ enum Drop { impl Drop { /// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows - /// it carries, make a swatch of a paint dragged from elsewhere, or a pattern swatch of art dragged - /// off the canvas (appearances: nothing). + /// it carries (with Alt held, `replace`, a swatch grabbed may replace `target`), make a swatch of + /// a paint dragged from elsewhere, or a pattern swatch of art dragged off the canvas (appearances: + /// nothing). fn of(d: &PanelDrag, target: Option, after: bool, replace: bool) -> Option { match d { - PanelDrag::Paint { rows: Some(r), .. } => Some(Drop::Move { names: r.names.clone(), target, after }), + PanelDrag::Paint { rows: Some(r), .. } => { + let with = (replace && !r.groups).then(|| r.grabbed.clone()); + Some(Drop::Move { names: r.names.clone(), target, after, with }) + } PanelDrag::Paint { paint, .. } => Some(Drop::New { paint: paint.clone(), target, replace }), PanelDrag::Art(ids) => Some(Drop::Pattern { ids: ids.iter().map(|id| id.0).collect() }), _ => None, @@ -532,17 +538,30 @@ fn same_kind(a: &Paint, b: &Paint) -> bool { std::mem::discriminant(a) == std::mem::discriminant(b) && !a.is_none() } -/// Run a drop on the panel: move the swatches or make a swatch of the proxy's paint (a colour -/// dropped on a folder or a grouped swatch joins that group). +/// Can swatch `with` give its paint to swatch `target`? Both are swatches of one kind, neither is +/// None or Registration, and `target` is not a tint swatch (a tint swatch only takes a new name). +fn replaces(d: &Document, with: &str, target: &str) -> bool { + match (d.swatch(with), d.swatch(target)) { + (Some(w), Some(t)) => with != target && !w.is_reserved() && !t.is_reserved() && t.tint_of().is_none() && same_kind(&t.paint, &w.paint), + _ => false, + } +} + +/// Run a drop on the panel: move the swatches, give the paint of a swatch Alt-dropped on another of +/// its kind to that one, or make a swatch of the proxy's paint (a color dropped on a folder or a +/// grouped swatch joins that group). fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { let Some(d) = app.session.active().map(|st| &st.doc) else { return }; let (cmd, params) = match drop { - Drop::Move { names, target, after } => match move_params(d, &names, target.as_deref(), after) { + // Alt: the swatch grabbed replaces the color, gradient or pattern of the swatch of its kind + // it is dropped on, which keeps its name and place. + Drop::Move { with: Some(w), target: Some(t), .. } if replaces(d, &w, &t) => ("swatch.edit", json!({"name": t, "paint": {"swatch": w}})), + Drop::Move { names, target, after, .. } => match move_params(d, &names, target.as_deref(), after) { Some(p) => ("swatch.move", p), None => return, }, // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. - Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| same_kind(&w.paint, &paint)) => { + Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { ("swatch.edit", json!({"name": t, "paint": super::paint_params(&paint)})) } Drop::New { paint, target, .. } => { @@ -1483,11 +1502,16 @@ mod tests { /// Press at `from`, drag to `to` and release there, a frame per step from `time`. fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, time: f64) { - let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; - frame(app, ctx, vec![Event::PointerMoved(from), button(from, true)], time, show); - frame(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], time + 0.1, show); - frame(app, ctx, vec![Event::PointerMoved(to)], time + 0.2, show); - frame(app, ctx, vec![Event::PointerMoved(to), button(to, false)], time + 0.3, show); + drag_with(app, ctx, from, to, Modifiers::NONE, time); + } + + /// [`drag`] with `modifiers` held. + fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) { + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show); + frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show); + frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show); + frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show); } fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec { @@ -1629,6 +1653,58 @@ mod tests { drop_on(&mut app, "Red", grad("#00ff00"), Modifiers::ALT, 3.0); assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1); assert!(app.session.doc().unwrap().doc.swatch("Red").is_some_and(|w| w.paint.color().is_some())); + // A tint swatch only takes a new name, so a color Alt-dropped on one becomes a new swatch. + app.run("swatch.edit", json!({"name": "Orange", "global": true})).unwrap(); + app.run("swatch.new", json!({"swatch": "Orange", "tint": 40})).unwrap(); + frame(&mut app, &ctx, vec![], 4.0, show); + frame(&mut app, &ctx, vec![], 4.5, show); + drop_on(&mut app, "Orange 40%", Paint::solid(Color::from_hex("#123456").unwrap()), Modifiers::ALT, 5.0); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 3, "{}", app.ui.status); + assert!(d.swatch("Orange 40%").is_some_and(|w| w.tint_of().is_some())); + } + + #[test] + fn alt_dragging_a_swatch_onto_another_of_its_kind_replaces_its_color() { + let mut app = app(); + let ctx = context(); + app.run("swatch.edit", json!({"name": "Orange", "global": true})).unwrap(); + app.run("swatch.new", json!({"swatch": "Orange", "tint": 40})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Orange"})).unwrap(); + frame(&mut app, &ctx, vec![], 0.0, show); + frame(&mut app, &ctx, vec![], 0.5, show); + let doc = |app: &VectorcraftApp| app.session.doc().unwrap().doc.clone(); + let (before, undo) = (names_of(&app, None), app.session.doc().unwrap().history.undo.len()); + let red = doc(&app).swatch("Red").and_then(|w| w.paint.color()).unwrap(); + drag_with(&mut app, &ctx, tile_center(&ctx, "Red"), tile_center(&ctx, "Orange"), Modifiers::ALT, 1.0); + let d = doc(&app); + let orange = d.swatch("Orange").expect("Orange keeps its name"); + assert_eq!((orange.paint.color(), orange.global), (Some(red), true), "Orange takes Red's color"); + assert_eq!(names_of(&app, None), before, "Red stays and Orange keeps its place"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + let fill = d.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + assert_eq!(fill.color(), Some(red), "art linked to Orange takes the new color"); + // A gradient dropped on a color, and a swatch dropped without Alt, move as before. + frame(&mut app, &ctx, vec![], 2.0, show); + frame(&mut app, &ctx, vec![], 2.5, show); + drag_with(&mut app, &ctx, tile_center(&ctx, "Sunset"), tile_center(&ctx, "Amber") - vec2(4.0, 0.0), Modifiers::ALT, 3.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Amber").unwrap() - 1], "Sunset"); + assert!(doc(&app).swatch("Amber").is_some_and(|w| w.paint.color().is_some())); + frame(&mut app, &ctx, vec![], 4.0, show); + frame(&mut app, &ctx, vec![], 4.5, show); + drag(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Red") - vec2(4.0, 0.0), 5.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Lime"); + assert_ne!(doc(&app).swatch("Red").and_then(|w| w.paint.color()), doc(&app).swatch("Lime").and_then(|w| w.paint.color())); + // A tint swatch only takes a new name, so a swatch Alt-dropped on one moves. + frame(&mut app, &ctx, vec![], 6.0, show); + frame(&mut app, &ctx, vec![], 6.5, show); + drag_with(&mut app, &ctx, tile_center(&ctx, "Lime"), tile_center(&ctx, "Orange 40%") - vec2(4.0, 0.0), Modifiers::ALT, 7.0); + let n = names_of(&app, None); + assert_eq!(n[n.iter().position(|x| x == "Orange 40%").unwrap() - 1], "Lime", "{}", app.ui.status); + assert_eq!(doc(&app).swatch("Orange 40%").and_then(|w| w.tint_of().map(|(base, _)| base.to_string())).as_deref(), Some("Orange")); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e14ab8989..15ac8f8d0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -127,7 +127,10 @@ Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatc `preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo step and `ui.dialog.cancel` rolls the preview back. For a gradient swatch the dialog has `name` only (it shows the gradient) and OK renames it; pattern swatches open pattern editing instead. Dropping a gradient (or colour) on a -swatch of its kind in the Swatches panel with Alt held replaces it (`swatch.edit {name, paint}`). +swatch of its kind in the Swatches panel with Alt held replaces it (`swatch.edit {name, paint}`), unless that is a +tint swatch. A swatch dragged from the panel with Alt held also replaces the swatch of its kind it is dropped on, +which keeps its name and place (`swatch.edit {name, paint: {swatch}}`); without Alt, or on a tint swatch or a swatch +of another kind, it moves. Confirmations: deleting swatches from the Swatches panel opens a `confirm` dialog (fields `message`, `detail`); `ui.dialog.confirm` runs the command it asks about (here `swatch.delete`) and `ui.dialog.cancel` drops it. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7147b1738..76604e062 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -490,6 +490,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. + In the Swatches panel, Alt-dragging a swatch onto another swatch of its kind (not a tint swatch) replaces that swatch's color, gradient or pattern, and the swatch keeps its name and place. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, Keep selected, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel. Keep selected in the Pencil and Paintbrush Tool Options is on by default; while it is off, the path a stroke draws or continues is not selected afterwards and the rest of the selection stays as it was (`path.freehand` `keepSelected`). - **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score.