diff --git a/ROADMAP.md b/ROADMAP.md index ed6ba219..d4ff8b6a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,6 +112,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View. - **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked. - **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. + - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped. - **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, Gradient, Eyedropper, Blend and the Liquify tools their panels or options. - **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 41fc86b1..298d1f95 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -926,6 +926,14 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) return; } let Some(d) = resp.dnd_release_payload::() else { return }; + // A symbol from the Symbols panel: an instance centred where it is dropped, on art or not. + if let widgets::PanelDrag::Symbol(name) = &*d { + let at = xf.to_doc(pos); + if let Err(e) = app.run("symbol.place", json!({"name": name, "x": at.x, "y": at.y})) { + app.status(e); + } + return; + } let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; let Some(st) = app.session.active() else { return }; let (cmd, params) = match &*d { @@ -950,8 +958,9 @@ 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})) } - // Art dragged back onto the canvas: its move was already dropped. - widgets::PanelDrag::Art(_) => return, + // Art dragged back onto the canvas: its move was already dropped. (A symbol was placed + // above.) + widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) => return, }; if let Err(e) = app.run(cmd, params) { app.status(e); @@ -1685,6 +1694,29 @@ mod tests { assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}"); } + #[test] + fn a_symbol_dropped_on_the_canvas_is_placed_there() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); + let name = app.session.execute("symbol.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + 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()); + // Released over empty canvas: an instance centred there. + let at = xf.to_screen(Point::new(250.0, 180.0)); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Symbol(name.clone())); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]); + let st = app.session.active().unwrap(); + let placed = st.selection.objects.first().copied().unwrap(); + let n = st.doc.node(placed).unwrap(); + assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name)); + let c = n.geometric_bounds().unwrap().center(); + assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}"); + } + #[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/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index d9372972..6b350f76 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 { .. } => (None, None), + PanelDrag::Paint { .. } | PanelDrag::Symbol(_) => (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 3af7cb0a..0ba82527 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -571,6 +571,10 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { None => return, }, PanelDrag::GraphicStyle(name) => return pointer_chip(ctx, |ui, r| super::graphic_styles::paint_style(app, ui, r, name)), + PanelDrag::Symbol(name) => match app.session.active() { + Some(st) => return pointer_chip(ctx, |ui, r| super::symbols::chip(ui, &st.doc, r, name)), + None => return, + }, }; pointer_chip(ctx, |ui, r| { if registration { diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 54dead93..6b3bf4c4 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeKind}; use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; -use crate::widgets::{self, menu_item}; +use crate::widgets::{self, PanelDrag, menu_item}; /// A thumbnail of symbol `name` (an instance at its natural size), cached by the art's identity. fn thumb(ui: &Ui, doc: &Document, name: &str, size: f32) -> Option { @@ -50,6 +50,24 @@ fn thumb(ui: &Ui, doc: &Document, name: &str, size: f32) -> Option 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)) } @@ -73,28 +91,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let list: bool = pstate(ui.ctx(), "sym-list"); let sel = current(app); let mut clicked: Option<(String, bool)> = 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 names.is_empty() { super::empty_state(ui, "spray-can", tl!("No symbols in this document"), tl!("Select art and click New Symbol to make one.")); - return; + return ui.min_rect(); } - let paint_thumb = |ui: &Ui, r: egui::Rect, n: &str| { - ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - if let Some(tex) = thumb(ui, &doc, n, r.width()) { - 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); - } - }; if list { for n in &names { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); + // Dragged onto the canvas, the symbol is placed there. + widgets::drag_source(ui, &resp, || PanelDrag::Symbol(n.clone())); if sel.as_deref() == Some(n.as_str()) { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - paint_thumb(ui, egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(22.0, 22.0)), n); + chip(ui, &doc, egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(22.0, 22.0)), n); ui.painter().text(r.left_center() + vec2(32.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text); if resp.double_clicked() { clicked = Some((n.clone(), true)); @@ -105,8 +119,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else { ui.horizontal_wrapped(|ui| { for n in &names { - let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click()); - paint_thumb(ui, r.shrink(2.0), n); + let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click_and_drag()); + widgets::drag_source(ui, &resp, || PanelDrag::Symbol(n.clone())); + chip(ui, &doc, r.shrink(2.0), n); let on = sel.as_deref() == Some(n.as_str()); ui.painter().rect_stroke( r, @@ -123,7 +138,14 @@ 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) + && let Err(e) = app.run("symbol.new", json!({ "ids": ids })) + { + app.status(e); + } if let Some((n, place)) = clicked { app.run("symbol.setCurrent", json!({"name": n})).ok(); if place { @@ -182,3 +204,54 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { set_pstate(ui.ctx(), "sym-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, 400.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() } + } + + fn symbols(app: &mut VectorcraftApp) -> Vec { + let list = app.run("symbol.list", json!({})).unwrap(); + list["symbols"].as_array().unwrap().iter().map(|s| s["name"].as_str().unwrap_or_default().to_string()).collect() + } + + #[test] + fn art_dropped_on_the_panel_becomes_a_symbol_and_a_symbol_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.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); + // Selected, as art dragged off the canvas is. + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + // Art dragged off the canvas and released over the list. + let over = pos2(100.0, 50.0); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)])); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(over), button(over, false)]); + let names = symbols(&mut app); + assert_eq!(names.len(), 1, "{names:?}"); + let doc = &app.session.active().unwrap().doc; + let first = doc.layers[0].children().unwrap().first().unwrap(); + assert!(matches!(first.kind, NodeKind::SymbolInstance { .. }), "the art became an instance"); + // Dragging the symbol's tile out of the panel carries the symbol. + frame(&ctx, &mut app, vec![]); + let tile = pos2(26.0, 26.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))]); + assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&PanelDrag::Symbol(names[0].clone()))); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b547671a..7973521b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1140,8 +1140,12 @@ pub enum PanelDrag { /// `ids`); the panel moves it to where it is dropped. 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`). + /// makes a style of the first object (`graphicStyle.new`), the Symbols panel a symbol of it all + /// (`symbol.new`). Art(Vec), + /// A Symbols panel symbol: the canvas places an instance of it centred where it is dropped + /// (`symbol.place`). + Symbol(String), } /// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour