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 <[email protected]>
This commit is contained in:
theironchefandClaude Opus 5.5 authored and bflatastic committed 2026-10-08 03:22:50 +07:00
1 parent 17e654a738
commit 7830a41b3e
6 files changed
+145 -19

No files matched your search

+30
View File
@@ -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());
+69 -4
View File
@@ -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<egui::TextureHandle
})
}
/// The stroke preview of brush definition `def` on white in `r` (the chip a dragged brush shows at
/// the pointer).
pub(crate) fn chip(ui: &Ui, r: egui::Rect, def: &Value) {
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
if let Some(tex) = preview(ui, def, r.size()) {
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
}
}
fn selected_brush(app: &VectorcraftApp) -> Option<String> {
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<String, Value> =
list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect();
let mut clicked: Option<String> = 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<egui::Event>) {
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::<PanelDrag>(&ctx);
assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}");
}
}
+1 -1
View File
@@ -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;
+30 -3
View File
@@ -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<String>, replace: bool },
/// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays).
Pattern { ids: Vec<u64> },
}
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<String>, after: bool, replace: bool) -> Option<Self> {
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<std::sync::Arc<PanelDrag>> {
resp.dnd_hover_payload::<PanelDrag>().filter(|d| matches!(**d, PanelDrag::Paint { .. }))
resp.dnd_hover_payload::<PanelDrag>().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::<PanelDrag>(&ctx).is_none());
}
#[test]
fn a_proxy_dropped_on_the_panel_becomes_a_swatch() {
let mut app = app();
+1 -10
View File
@@ -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<Vec<u64>> {
let drag = zone.dnd_hover_payload::<PanelDrag>()?;
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::<PanelDrag>().map(|_| ids.iter().map(|id| id.0).collect())
}
fn current(app: &mut VectorcraftApp) -> Option<String> {
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);
+14 -1
View File
@@ -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<vectorcraft_doc::NodeId>),
/// 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<Vec<u64>> {
let drag = zone.dnd_hover_payload::<PanelDrag>()?;
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::<PanelDrag>().map(|_| ids.iter().map(|id| id.0).collect())
}
/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour