From 7830a41b3ea0164a235f304771605b666ba00bf3 Mon Sep 17 00:00:00 2001 From: theironchef <6278036+theironchef@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:35:03 -0700 Subject: [PATCH] Fix #297: the Swatches and Brushes panels take drag and drop Art dragged off the canvas and released over the Swatches panel becomes a pattern swatch of a copy of it (object.pattern.make without entering pattern editing; the art stays), and over the Brushes panel an Art brush (brush.new, as the panel's New Brush button makes). A brush dragged out of the Brushes panel (PanelDrag::Brush, with a preview chip) is applied to the path it is dropped on (brush.apply). The art drop zone helper moves to widgets, shared with the Symbols panel. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 30 +++++++++ crates/ui-egui/src/panels/brushes.rs | 73 +++++++++++++++++++-- crates/ui-egui/src/panels/graphic_styles.rs | 2 +- crates/ui-egui/src/panels/swatches.rs | 33 +++++++++- crates/ui-egui/src/panels/symbols.rs | 11 +--- crates/ui-egui/src/widgets.rs | 15 ++++- 6 files changed, 145 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d55ffe889..18ced610f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -966,6 +966,8 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) let add = ui.input(|i| i.modifiers.alt); ("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add})) } + // A brush from the Brushes panel: the path it lands on takes it. + widgets::PanelDrag::Brush { name, .. } => ("brush.apply", json!({"name": name, "ids": [hit.leaf.0]})), // Art dragged back onto the canvas: its move was already dropped. (A symbol was placed // above.) widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) => return, @@ -1769,6 +1771,34 @@ mod tests { assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}"); } + #[test] + fn a_brush_dropped_on_a_path_is_applied_to_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = + app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let brush = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().and_then(|s| s.brush.clone()) + }; + let drop = |app: &mut VectorcraftApp, at: Point| { + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: "Arrow".into(), def: json!({}) }); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); + }; + // Off the path: nothing. + drop(&mut app, Point::new(200.0, 250.0)); + assert_eq!(brush(&app), None); + // On it: the path's stroke takes the brush. + drop(&mut app, Point::new(200.0, 150.0)); + assert_eq!(brush(&app).as_deref(), Some("Arrow")); + } + #[test] fn middle_drag_pans_the_view_with_any_tool() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 7976cff7c..3eb2ecc1c 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -10,7 +10,7 @@ use vectorcraft_doc::{Appearance, Document, Node, color::Color, color::Paint}; use super::{first_selected, pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; -use crate::widgets::{self, menu_item}; +use crate::widgets::{self, PanelDrag, menu_item}; const KINDS: [(&str, &str); 5] = [("calligraphic", "Calligraphic"), ("scatter", "Scatter"), ("art", "Art"), ("bristle", "Bristle"), ("pattern", "Pattern")]; @@ -46,6 +46,15 @@ fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option Option { first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone())) } @@ -59,12 +68,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let defs: HashMap = list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect(); let mut clicked: Option = None; - widgets::list_box(ui, |ui| { + let list_rect = widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.set_width(ui.available_width()); if list.is_empty() { super::empty_state(ui, "paintbrush", tl!("No brushes"), tl!("Select art and use New Brush to make one.")); - return; + return ui.min_rect(); } for (ty, _) in KINDS { if hidden.iter().any(|h| h == ty) { @@ -75,7 +84,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { continue; } let row = |ui: &mut Ui, name: &str, size: egui::Vec2, label: bool| -> egui::Response { - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag()); + // Dragged onto a path, the brush is applied to it. + widgets::drag_source(ui, &resp, || PanelDrag::Brush { name: name.to_string(), def: defs.get(name).cloned().unwrap_or_default() }); let on = sel_brush.as_deref() == Some(name) || (sel_brush.is_none() && current.as_deref() == Some(name)); if on { ui.painter().rect_filled(r, 0.0, t.row_selected); @@ -118,7 +129,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); } + ui.min_rect() }); + // Art dragged off the canvas and dropped on the list becomes an Art brush, as New Brush makes. + let zone = ui.interact(list_rect, ui.id().with("brushes-drop"), Sense::hover()); + if let Some(ids) = widgets::art_drop(ui, &zone) + && let Err(e) = app.run("brush.new", json!({ "type": "art", "ids": ids })) + { + app.status(e); + } if let Some(name) = clicked { let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty()); if has_sel { @@ -207,3 +226,49 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { set_pstate(ui.ctx(), "br-list", true); } } + +#[cfg(test)] +mod tests { + use egui::{Pos2, Rect, pos2}; + use vectorcraft_doc::NodeId; + use vectorcraft_engine::Session; + + use super::*; + + /// One headless frame of the panel, 236 pt wide as in the dock. + fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + } + + fn button(at: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + #[test] + fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = app.run("shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 9, "points": 5})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + let (before, _) = brushes(&mut app); + // Art dragged off the canvas and released over the list. + let over = pos2(100.0, 60.0); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)])); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(over), button(over, false)]); + let (after, _) = brushes(&mut app); + assert_eq!(after.len(), before.len() + 1); + let new: Vec<_> = after.iter().filter(|b| !before.contains(b)).collect(); + assert!(matches!(new.as_slice(), [(_, ty)] if ty == "art"), "{new:?}"); + // Dragging the first tile out of the panel carries that brush. + frame(&ctx, &mut app, vec![]); + let tile = pos2(36.0, 16.0); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile), button(tile, true)]); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(40.0, 40.0))]); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(80.0, 80.0))]); + let drag = egui::DragAndDrop::payload::(&ctx); + assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}"); + } +} diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 6b350f761..5a18c1f54 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -351,7 +351,7 @@ fn zone_input(ui: &Ui, zone: &Response, d: &Document, ev: &mut Events) { PanelDrag::GraphicStyle(n) => (Some(n), None), PanelDrag::Art(ids) => (None, ids.first().copied()), PanelDrag::Appearance(id) => (None, Some(*id)), - PanelDrag::Paint { .. } | PanelDrag::Symbol(_) => (None, None), + PanelDrag::Paint { .. } | PanelDrag::Symbol(_) | PanelDrag::Brush { .. } => (None, None), }; if style.is_none() && source.is_none() { return; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 0ba82527a..9482f333b 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -361,23 +361,28 @@ enum Drop { /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch /// `target` when that is of its kind. 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 }, } impl Drop { /// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows - /// it carries, or make a swatch of a paint dragged from elsewhere (appearances: nothing). + /// it carries, 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 { paint, .. } => Some(Drop::New { paint: paint.clone(), target, replace }), + PanelDrag::Art(ids) => Some(Drop::Pattern { ids: ids.iter().map(|id| id.0).collect() }), _ => None, } } } -/// The drag held over `resp`, if the panel takes it (paints, not the Appearance panel's thumbnail). +/// The drag held over `resp`, if the panel takes it (paints and art dragged off the canvas, not the +/// Appearance panel's thumbnail). fn held(resp: &Response) -> Option> { - resp.dnd_hover_payload::().filter(|d| matches!(**d, PanelDrag::Paint { .. })) + resp.dnd_hover_payload::().filter(|d| matches!(**d, PanelDrag::Paint { .. } | PanelDrag::Art(_))) } /// The drag a tile or row of `e` starts, moving rows `names`. Colour groups have no paint of their @@ -545,6 +550,7 @@ fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { } ("swatch.new", p) } + Drop::Pattern { ids } => ("object.pattern.make", json!({"ids": ids, "edit": false})), }; if let Err(e) = app.run(cmd, params) { app.status(e); @@ -575,6 +581,7 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { Some(st) => return pointer_chip(ctx, |ui, r| super::symbols::chip(ui, &st.doc, r, name)), None => return, }, + PanelDrag::Brush { def, .. } => return pointer_chip(ctx, |ui, r| super::brushes::chip(ui, r, def)), }; pointer_chip(ctx, |ui, r| { if registration { @@ -1472,6 +1479,26 @@ mod tests { assert_eq!((names_of(&app, None), app.session.doc().unwrap().history.undo.len()), (before, undo)); } + #[test] + fn art_dropped_on_the_panel_becomes_a_pattern_swatch() { + let mut app = app(); + let id = app.run("shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 9, "points": 5})).unwrap()["id"].as_u64().unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let ctx = context(); + frame(&mut app, &ctx, vec![], 0.0, show); + // Released over a swatch tile: the list takes it. + let at = tile_center(&ctx, "Bright Red"); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])); + let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show); + let patterns = app.run("pattern.list", json!({})).unwrap(); + assert_eq!(patterns["patterns"].as_array().map(Vec::len), Some(1), "{patterns}"); + assert_eq!(patterns["editing"], Value::Null, "no pattern editing mode"); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.node(vectorcraft_doc::NodeId(id)), before.node(vectorcraft_doc::NodeId(id)), "the art stays as it was"); + assert!(egui::DragAndDrop::payload::(&ctx).is_none()); + } + #[test] fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 6b3bf4c49..111315e6c 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -59,15 +59,6 @@ pub(crate) fn chip(ui: &Ui, doc: &Document, r: egui::Rect, name: &str) { } } -/// Art dragged off the canvas over the symbol list: outline it; dropped, the art becomes a new -/// symbol (`ids`). -fn art_drop(ui: &Ui, zone: &egui::Response) -> Option> { - let drag = zone.dnd_hover_payload::()?; - let PanelDrag::Art(ids) = &*drag else { return None }; - ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - zone.dnd_release_payload::().map(|_| ids.iter().map(|id| id.0).collect()) -} - fn current(app: &mut VectorcraftApp) -> Option { app.run("symbol.list", json!({})).ok().and_then(|v| v["current"].as_str().map(str::to_string)) } @@ -141,7 +132,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.min_rect() }); let zone = ui.interact(list_rect, ui.id().with("symbols-drop"), Sense::hover()); - if let Some(ids) = art_drop(ui, &zone) + if let Some(ids) = widgets::art_drop(ui, &zone) && let Err(e) = app.run("symbol.new", json!({ "ids": ids })) { app.status(e); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 7973521b1..06a2425c1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1141,11 +1141,24 @@ pub enum PanelDrag { GraphicStyle(String), /// The selected art, dragged off the canvas with the Selection tool: the Graphic Styles panel /// makes a style of the first object (`graphicStyle.new`), the Symbols panel a symbol of it all - /// (`symbol.new`). + /// (`symbol.new`), the Swatches panel a pattern swatch of a copy (`object.pattern.make`) and the + /// Brushes panel an Art brush (`brush.new`). Art(Vec), /// A Symbols panel symbol: the canvas places an instance of it centred where it is dropped /// (`symbol.place`). Symbol(String), + /// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is + /// dropped on takes it (`brush.apply`). + Brush { name: String, def: serde_json::Value }, +} + +/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it; +/// → the dragged ids when it is released there. +pub fn art_drop(ui: &Ui, zone: &Response) -> Option> { + let drag = zone.dnd_hover_payload::()?; + let PanelDrag::Art(ids) = &*drag else { return None }; + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + zone.dnd_release_payload::().map(|_| ids.iter().map(|id| id.0).collect()) } /// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour