//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush //! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking //! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or //! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with, //! for Delete Brush and Duplicate Brush. use std::collections::HashMap; use egui::{Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, Document, Node, PressureProfile, color::Color, color::Paint}; use super::{first_selected, pstate, set_pstate}; use crate::VectorcraftApp; use crate::dialogs::brush_options; use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, menu_item}; const KINDS: [(&str, &str); 5] = [("calligraphic", "Calligraphic"), ("scatter", "Scatter"), ("art", "Art"), ("bristle", "Bristle"), ("pattern", "Pattern")]; /// (name, type) of every brush in the active document's library. pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option) { let Ok(v) = app.run("brush.list", json!({})) else { return (vec![], None) }; let list = v["brushes"] .as_array() .map(|a| a.iter().map(|b| (b["name"].as_str().unwrap_or("").to_string(), b["type"].as_str().unwrap_or("").to_string())).collect()) .unwrap_or_default(); (list, v["current"].as_str().map(str::to_string)) } /// A stroke preview for brush definition `def`, rendered at `size` and cached by the definition's /// JSON. The stroke goes from light to heavy pen pressure and back, so pressure-sensitive brushes /// show how they vary. fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option { widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| { let mut doc = Document::new(w, h); doc.unknown.insert("brushes".into(), json!([def])); let name = def["name"].as_str()?.to_string(); let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); let st = ap.stroke_mut()?; st.brush = Some(name); st.pressure = Some(PressureProfile::sample()); // A gentle S-curve across the swatch. let mut bp = vectorcraft_geom::BezPath::new(); let (x0, x1) = (h * 0.5, w - h * 0.5); bp.move_to((x0, h * 0.5)); bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5)); let id = doc.alloc_id(); let l = doc.layers[0].id; doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; Some(doc) }) } /// 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 { first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone())) } /// What the panel's last click was made with: the document, its selected objects and the current /// brush. type ClickKey = (u64, Vec, Option); fn click_key(app: &VectorcraftApp, current: Option<&str>) -> Option { app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect(), current.map(str::to_string))) } /// Whether the panel's last click was made with the document, objects and current brush active /// now. Panel state `br-selected` holds the multi-selection and the key of that click. fn same_click(app: &VectorcraftApp, ctx: &egui::Context, current: Option<&str>) -> bool { let (_, key): (Option>, Option) = pstate(ctx, "br-selected"); key.is_some() && key == click_key(app, current) } /// The brushes that Shift-clicks, Cmd/Ctrl-clicks and Select All Unused selected, of those in /// `list`. It is `None` after a plain click, once none of its brushes is left, and once another /// document, other objects or another current brush is active, which also clears it and the key /// of the last click; `Some` of an empty list after Cmd/Ctrl-clicks deselected every brush or /// Select All Unused found none. fn multi(app: &VectorcraftApp, ctx: &egui::Context, list: &[(String, String)], current: Option<&str>) -> Option> { if !same_click(app, ctx, current) { set_pstate(ctx, "br-selected", (None::>, None::)); return None; } let (names, _): (Option>, Option) = pstate(ctx, "br-selected"); let names = names?; if names.is_empty() { return Some(names); } let kept: Vec = names.into_iter().filter(|n| list.iter().any(|b| &b.0 == n)).collect(); (!kept.is_empty()).then_some(kept) } /// The brushes Delete Brush and Duplicate Brush act on: the multi-selection, else the selected /// path's brush, else the current one. fn targets(app: &VectorcraftApp, ctx: &egui::Context, list: &[(String, String)], current: Option<&str>) -> Vec { multi(app, ctx, list, current).unwrap_or_else(|| selected_brush(app).or(current.map(str::to_string)).into_iter().collect()) } /// Runs `brush.delete` or `brush.duplicate` on `names`. fn run_on(app: &mut VectorcraftApp, id: &str, names: &[String]) { let p = match names { [name] => json!({ "name": name }), _ => json!({ "names": names }), }; app.run(id, p).ok(); } /// The names in `list` in the order the panel shows them: by type in [`KINDS`] order, without the /// hidden types. fn shown<'a>(list: &'a [(String, String)], hidden: &[String]) -> Vec<&'a str> { let kinds = KINDS.iter().filter(|(ty, _)| !hidden.iter().any(|h| h == ty)); kinds.flat_map(|(ty, _)| list.iter().filter(move |b| b.1 == *ty).map(|b| b.0.as_str())).collect() } /// A brush dragged over the tile or row `resp` of brush `name`, the `i`th brush of type `ty` /// (`list`: rows stack downwards), lands before or after it by the pointer's half; a bar marks the /// slot when the dragged brush is of type `ty` too (`types`: the (name, type) of every brush). /// Released there, it sets `moved` to the `brush.move` params, which refuse a brush of another type. fn reorder_drop( ui: &Ui, resp: &egui::Response, types: &[(String, String)], (name, ty, i): (&str, &str, usize), list: bool, moved: &mut Option, ) { let Some(drag) = resp.dnd_hover_payload::() else { return }; let PanelDrag::Brush { name: moving, .. } = &*drag else { return }; if moving == name { // Dropped on itself: it stays where it is. resp.dnd_release_payload::(); return; } let r = resp.rect; let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center()); let after = if list { at.y > r.center().y } else { at.x > r.center().x }; if types.iter().any(|b| &b.0 == moving && b.1 == ty) { let accent = egui::Stroke::new(2.0, Tokens::get(ui.ctx()).accent); if list { let y = if after { r.bottom() } else { r.top() }; ui.painter().line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], accent); } else { let x = if after { r.right() + 1.5 } else { r.left() - 1.5 }; ui.painter().line_segment([egui::pos2(x, r.top()), egui::pos2(x, r.bottom())], accent); } } if resp.dnd_release_payload::().is_some() { *moved = Some(json!({ "name": moving, "to": i + usize::from(after), "type": ty })); } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let (list, current) = brushes(app); let sel_brush = selected_brush(app); let mut multi = multi(app, ui.ctx(), &list, current.as_deref()); let list_view: bool = pstate(ui.ctx(), "br-list"); let hidden: Vec = pstate(ui.ctx(), "br-hidden"); 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<(String, egui::Modifiers)> = None; let mut options: Option = None; let mut moved: Option = None; 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 ui.min_rect(); } for (ty, _) in KINDS { if hidden.iter().any(|h| h == ty) { continue; } let group: Vec<&(String, String)> = list.iter().filter(|b| b.1 == ty).collect(); if group.is_empty() { 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_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 = match &multi { Some(multi) => multi.iter().any(|m| m == name), None => 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); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } let pw = if label { 72.0 } else { size.x - 4.0 }; let pr = egui::Rect::from_min_size(r.left_top() + vec2(2.0, 2.0), vec2(pw, size.y - 4.0)); ui.painter().rect_filled(pr, 0.0, egui::Color32::WHITE); if let Some(def) = defs.get(name) && let Some(tex) = preview(ui, def, pr.size()) { ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); } if label { ui.painter().text( r.left_center() + vec2(pw + 10.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text, ); } resp.on_hover_text(name) }; // A click applies or selects the brush; the second click of a double-click opens its // options. let mut pick = |resp: egui::Response, name: &String| { if resp.double_clicked() { options = Some(name.clone()); } else if resp.clicked() { clicked = Some((name.clone(), resp.ctx.input(|i| i.modifiers))); } }; if list_view { for (i, (name, _)) in group.into_iter().enumerate() { let resp = row(ui, name, vec2(ui.available_width(), 26.0), true); reorder_drop(ui, &resp, &list, (name, ty, i), true, &mut moved); pick(resp, name); } } else { ui.horizontal_wrapped(|ui| { for (i, (name, _)) in group.into_iter().enumerate() { let resp = row(ui, name, vec2(64.0, 28.0), false); reorder_drop(ui, &resp, &list, (name, ty, i), false, &mut moved); pick(resp, name); } }); } ui.separator(); } ui.min_rect() }); // Art dragged off the canvas and dropped on the list opens New Brush for that art, as the New // Brush button opens it for the selection. 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("ui.newBrush", json!({ "ids": ids })) { app.status(e); } // A brush dragged within the list moves among the brushes of its type. if let Some(p) = moved && let Err(e) = app.run("brush.move", p) { app.status(e); } let highlighted = sel_brush.clone().or(current.clone()); match clicked { // Shift and Cmd/Ctrl only select, as in the Swatches panel: they start from the // multi-selection, else from the highlighted brush. Some((name, m)) if m.shift || m.command => { let order = shown(&list, &hidden); // A range starts from the last brush clicked without Shift while the same document, // art and current brush are active, else from the highlighted brush. let anchor: String = pstate(ui.ctx(), "br-anchor"); if !same_click(app, ui.ctx(), current.as_deref()) || !order.contains(&anchor.as_str()) { set_pstate(ui.ctx(), "br-anchor", highlighted.clone().unwrap_or_default()); } let from = multi.unwrap_or_else(|| highlighted.iter().cloned().collect()); let sel = super::swatches::click_selection(ui, "br-anchor", from, &order, &name, m); set_pstate(ui.ctx(), "br-selected", (Some(sel.clone()), click_key(app, current.as_deref()))); multi = Some(sel); } Some((name, _)) => { set_pstate(ui.ctx(), "br-anchor", name.clone()); multi = None; let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty()); if has_sel { app.run("brush.apply", json!({"name": name})).ok(); } else { app.run("brush.setCurrent", json!({"name": name})).ok(); } let now = brushes(app).1; set_pstate(ui.ctx(), "br-selected", (None::>, click_key(app, now.as_deref()))); } None => {} } if let Some(name) = options && let Err(e) = app.run("ui.brushOptions", json!({ "name": name })) { app.status(e); } let has_brush = sel_brush.is_some(); let targets = multi.unwrap_or_else(|| highlighted.into_iter().collect()); let has_doc = app.session.active().is_some(); widgets::bottom_bar(ui, |ui| { widgets::icon_button_enabled(ui, "library", tl!("Brush Libraries (on the roadmap)"), false, false, 24.0); if widgets::icon_button_enabled(ui, "dc-remove-brush", tl!("Remove Brush Stroke"), false, has_brush, 24.0).clicked() { app.run("brush.remove", json!({})).ok(); } if widgets::icon_button_enabled(ui, "dc-options", tl!("Expand Brush Strokes"), false, has_brush, 24.0).clicked() { app.run("object.expandBrush", json!({})).ok(); } ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); // New Brush asks for the type, then opens that type's Brush Options. if widgets::icon_button_enabled(ui, "dc-new-item", tl!("New Brush"), false, has_doc, 24.0).clicked() && let Err(e) = app.run("ui.newBrush", json!({})) { app.status(e); } if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Brush"), false, !targets.is_empty(), 24.0).clicked() { run_on(app, "brush.delete", &targets); } }); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let sel = selected_brush(app); let (list, current) = brushes(app); let targets = targets(app, ui.ctx(), &list, current.as_deref()); let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty()); if menu_item(ui, tl!("New Brush…"), app.session.active().is_some(), false) && let Err(e) = app.run("ui.newBrush", json!({})) { app.status(e); } // The types' own items make a brush at once, without its Brush Options. for (label, ty) in [(tl!("New Calligraphic Brush"), "calligraphic"), (tl!("New Bristle Brush"), "bristle")] { if menu_item(ui, label, true, false) { app.run("brush.new", json!({"type": ty})).ok(); } } for (label, ty) in [ (tl!("New Art Brush from Selection"), "art"), (tl!("New Scatter Brush from Selection"), "scatter"), (tl!("New Pattern Brush from Selection"), "pattern"), ] { if menu_item(ui, label, has_sel, false) { app.run("brush.new", json!({"type": ty})).ok(); } } if menu_item(ui, tl!("Duplicate Brush"), !targets.is_empty(), false) { run_on(app, "brush.duplicate", &targets); } if menu_item(ui, tl!("Delete Brush"), !targets.is_empty(), false) { run_on(app, "brush.delete", &targets); } if menu_item(ui, tl!("Remove Brush Stroke"), sel.is_some(), false) { app.run("brush.remove", json!({})).ok(); } if menu_item(ui, tl!("Expand Brush Strokes"), sel.is_some(), false) { app.run("object.expandBrush", json!({})).ok(); } // On the selected path's brush, else the current one. if menu_item(ui, tl!("Brush Options…"), brush_options::available(app), false) && let Err(e) = app.run("ui.brushOptions", json!({})) { app.status(e); } ui.separator(); // Selects the brushes no stroke paints with, for Delete Brush and Duplicate Brush. if menu_item(ui, tl!("Select All Unused"), app.session.active().is_some(), false) && let Ok(r) = app.run("brush.unused", json!({})) { let names: Vec = serde_json::from_value(r["names"].clone()).unwrap_or_default(); set_pstate(ui.ctx(), "br-selected", (Some(names), click_key(app, current.as_deref()))); } ui.separator(); let mut hidden: Vec = pstate(ui.ctx(), "br-hidden"); for (ty, label) in KINDS { let shown = !hidden.iter().any(|h| h == ty); if menu_item(ui, &crate::i18n::fmt(tl!("Show {kind} Brushes"), &[("kind", tl!(label))]), true, shown) { if shown { hidden.push(ty.to_string()); } else { hidden.retain(|h| h != ty); } set_pstate(ui.ctx(), "br-hidden", hidden.clone()); } } ui.separator(); let list: bool = pstate(ui.ctx(), "br-list"); if menu_item(ui, tl!("Thumbnail View"), true, !list) { set_pstate(ui.ctx(), "br-list", false); } if menu_item(ui, tl!("List View"), true, list) { 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() } } /// One frame of `draw` at `time` with `modifiers` held, its output kept. fn frame_with( ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, modifiers: egui::Modifiers, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui), ) -> egui::FullOutput { let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))); let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); let raw = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| draw(app, ui)); out.textures_delta.clear(); out } /// A click at `at` with `modifiers` held, in a frame of `draw` at `time`. fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, modifiers: egui::Modifiers, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) { let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; frame_with(ctx, app, vec![egui::Event::PointerMoved(at), press(true), press(false)], modifiers, time, draw); } /// The accessibility nodes of `out` that take a click, with their rectangles, top to bottom /// and then left to right. fn clickable(out: &egui::FullOutput) -> Vec<(Option, Rect)> { let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; let mut v: Vec<(Option, Rect)> = nodes .iter() .filter(|(_, n)| n.supports_action(egui::accesskit::Action::Click)) .filter_map(|(_, n)| { let b = n.bounds()?; Some((n.label().map(|l| l.trim().to_string()), Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)))) }) .collect(); v.sort_by(|a, b| a.1.min.y.total_cmp(&b.1.min.y).then(a.1.min.x.total_cmp(&b.1.min.x))); v } /// #852: double-clicking a brush opens its Brush Options; OK changes the brush (and strokes /// painted with it). A single click only applies the brush. #[test] fn double_clicking_a_brush_opens_its_options() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let ctx = egui::Context::default(); let clicks = |at: Pos2, n: usize| -> Vec { std::iter::once(egui::Event::PointerMoved(at)).chain((0..n).flat_map(|_| [button(at, true), button(at, false)])).collect() }; let at_time = |app: &mut VectorcraftApp, time: f64, events: Vec| { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))), time: Some(time), events, ..Default::default() }; ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear(); }; at_time(&mut app, 0.0, vec![]); let (list, _) = brushes(&mut app); let first = list[0].0.clone(); assert_eq!(list[0].1, "calligraphic"); // The first tile, clicked once: it becomes current, no dialog. let tile = pos2(36.0, 16.0); at_time(&mut app, 1.0, clicks(tile, 1)); assert!(app.ui.dialog.is_none()); assert_eq!(brushes(&mut app).1.as_deref(), Some(first.as_str())); // Double-clicked: Calligraphic Brush Options on that brush. at_time(&mut app, 3.0, clicks(tile, 2)); let d = app.ui.dialog.as_mut().expect("Brush Options opened"); assert_eq!((d.kind.as_str(), d.str("name"), d.str("sizeMode")), (crate::dialogs::brush_options::KIND, first.clone(), "fixed".into())); d.fields.insert("sizeMode".into(), json!("pressure")); d.fields.insert("sizeVariation".into(), json!(2)); d.fields.insert("name".into(), json!("Pressure Round")); crate::dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); let def = app.run("brush.get", json!({"name": "Pressure Round"})).unwrap(); assert_eq!((def["modes"].clone(), def["variation"][2].clone()), (json!(["fixed", "fixed", "pressure"]), json!(2.0))); assert_eq!(brushes(&mut app).1.as_deref(), Some("Pressure Round"), "renamed, still current"); } /// The New Brush button and the panel menu's New Brush… open New Brush, with nothing selected /// too, and make no brush. #[test] fn the_new_brush_button_and_menu_item_open_new_brush() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let ctx = egui::Context::default(); ctx.enable_accesskit(); let before = names(&mut app); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); let new = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some("New Brush")).expect("a New Brush button").1.center(); click(&ctx, &mut app, new, egui::Modifiers::NONE, 1.0, show); let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::new_brush::KIND)); app.ui.dialog = None; menu_click(&ctx, &mut app, "New Brush…", 2.0); assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::new_brush::KIND)); assert_eq!(names(&mut app), before); } /// The rectangles `out` fills with the selected-row color. fn lit(ctx: &egui::Context, out: &egui::FullOutput) -> Vec { let t = Tokens::get(ctx); out.shapes .iter() .filter_map(|c| match &c.shape { egui::Shape::Rect(r) if r.fill == t.row_selected => Some(r.rect), _ => None, }) .collect() } fn brush_of(app: &VectorcraftApp, id: NodeId) -> Option { app.session.active().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|s| s.brush.clone()) } fn names(app: &mut VectorcraftApp) -> Vec { brushes(app).0.into_iter().map(|b| b.0).collect() } /// Click the panel menu's item `label` (frames at `time`, `time + 0.5`). fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) { let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, time, menu); let at = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some(label)).unwrap_or_else(|| panic!("no `{label}`")).1.center(); click(ctx, app, at, egui::Modifiers::NONE, time + 0.5, menu); } /// The first three brushes (the Calligraphic ones): a click on the first applies it to the /// selected rectangle, and then a Shift-click on the third selects all three without applying /// the third. fn select_three(ctx: &egui::Context, app: &mut VectorcraftApp, rect: NodeId) -> (Vec, Vec) { let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, 0.0, show); let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect(); let (list, _) = brushes(app); assert!(list[..3].iter().all(|b| b.1 == "calligraphic"), "{list:?}"); let three: Vec = list[..3].iter().map(|b| b.0.clone()).collect(); click(ctx, app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show); assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str())); click(ctx, app, tiles[2].center(), egui::Modifiers::SHIFT, 2.0, show); assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str()), "a Shift-click only selects"); (three, tiles) } /// Shift-click selects a range of brushes and Cmd/Ctrl-click adds or removes one; the panel /// highlights them, and Duplicate Brush and Delete Brush act on all of them in one undo step. #[test] fn shift_and_command_clicks_select_brushes_for_delete_and_duplicate() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let rect = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); let ctx = egui::Context::default(); ctx.enable_accesskit(); let (three, tiles) = select_three(&ctx, &mut app, rect); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show); let lit_tiles = |out: &egui::FullOutput| -> Vec { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() }; assert_eq!(lit_tiles(&out), [0, 1, 2]); // Cmd/Ctrl-click takes the second out again. click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::COMMAND, 3.0, show); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show); assert_eq!(lit_tiles(&out), [0, 2]); let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); let before = (names(&mut app), undo(&app)); menu_click(&ctx, &mut app, "Duplicate Brush", 4.0); let copies = [format!("{} copy", three[0]), format!("{} copy", three[2])]; assert_eq!(names(&mut app)[before.0.len()..], copies); assert_eq!(undo(&app), before.1 + 1); // The bottom bar's last button, Delete Brush. let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 5.0, show); let trash = clickable(&out).last().unwrap().1.center(); click(&ctx, &mut app, trash, egui::Modifiers::NONE, 6.0, show); let left = names(&mut app); assert!(!left.contains(&three[0]) && !left.contains(&three[2]) && left.contains(&three[1]), "{left:?}"); assert!(copies.iter().all(|c| left.contains(c)), "{left:?}"); assert_eq!(brush_of(&app, rect), None); assert_eq!(undo(&app), before.1 + 2); } /// Selecting other art ends the multi-selection: selecting the first art again does not bring /// it back, and with a path stroked with another brush selected, the panel highlights that /// brush and Delete Brush deletes it alone. #[test] fn selecting_other_art_ends_the_brush_multi_selection() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let rect = |app: &mut VectorcraftApp, x: f64| { NodeId(app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()) }; let a = rect(&mut app, 10.0); let ctx = egui::Context::default(); ctx.enable_accesskit(); let (three, tiles) = select_three(&ctx, &mut app, a); let lit_tiles = |out: &egui::FullOutput| -> Vec { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() }; // A path without a brush, then the first one again: the current brush is highlighted. rect(&mut app, 60.0); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.0, show); assert_eq!(lit_tiles(&out), [0]); app.run("select.set", json!({"ids": [a.0]})).unwrap(); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show); assert_eq!(lit_tiles(&out), [0]); // A path stroked with the second brush. let c = rect(&mut app, 110.0); app.run("brush.apply", json!({"name": three[1]})).unwrap(); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 4.0, show); assert_eq!(lit_tiles(&out), [1]); menu_click(&ctx, &mut app, "Delete Brush", 5.0); let left = names(&mut app); assert!(left.contains(&three[0]) && !left.contains(&three[1]) && left.contains(&three[2]), "{left:?}"); assert_eq!((brush_of(&app, a).as_deref(), brush_of(&app, c)), (Some(three[0].as_str()), None)); } /// After other art is selected, a Shift-click selects the range from that art's brush, not from /// the brush clicked before. #[test] fn a_shift_click_after_selecting_other_art_starts_from_its_brush() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); let ctx = egui::Context::default(); ctx.enable_accesskit(); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect(); let (list, _) = brushes(&mut app); click(&ctx, &mut app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show); app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 40})).unwrap(); app.run("brush.apply", json!({"name": list[2].0})).unwrap(); click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::SHIFT, 2.0, show); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show); assert_eq!(lit(&ctx, &out).iter().filter(|r| tiles.contains(r)).collect::>(), [&tiles[1], &tiles[2]]); } /// Cmd/Ctrl-clicks can deselect every brush: none is highlighted, and Delete Brush and /// Duplicate Brush are off. #[test] fn command_clicks_deselect_every_brush() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); let ctx = egui::Context::default(); ctx.enable_accesskit(); let (_, tiles) = select_three(&ctx, &mut app, a); for (i, tile) in tiles.iter().enumerate() { click(&ctx, &mut app, tile.center(), egui::Modifiers::COMMAND, 3.0 + i as f64, show); } let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 7.0, show); assert!(!lit(&ctx, &out).iter().any(|r| tiles.contains(r))); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 8.0, menu); let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; for label in ["Duplicate Brush", "Delete Brush"] { let item = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no `{label}`")); assert!(item.1.is_disabled(), "{label}"); } } /// Select All Unused in the panel menu highlights every brush but the one the rectangle is /// painted with, and Delete Brush then deletes all of them. #[test] fn select_all_unused_selects_the_brushes_no_stroke_paints_with() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let (list, _) = brushes(&mut app); let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); app.run("brush.apply", json!({"name": list[1].0})).unwrap(); let ctx = egui::Context::default(); ctx.enable_accesskit(); menu_click(&ctx, &mut app, "Select All Unused", 1.0); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.0, show); let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); assert_eq!(tiles.len(), list.len()); let on: Vec = (0..tiles.len()).filter(|i| lit(&ctx, &out).contains(&tiles[*i])).collect(); assert_eq!(on, (0..tiles.len()).filter(|i| *i != 1).collect::>()); menu_click(&ctx, &mut app, "Delete Brush", 3.0); assert_eq!(names(&mut app), [list[1].0.clone()]); assert_eq!(brush_of(&app, a), Some(list[1].0.clone())); } #[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)]); // New Brush opens on Art for the dropped art, and OK twice makes the brush. let d = app.ui.dialog.as_ref().expect("New Brush opened"); assert_eq!((d.kind.as_str(), d.str("type"), d.fields.get("__ids")), (crate::dialogs::new_brush::KIND, "art".into(), Some(&json!([id])))); assert_eq!(brushes(&mut app).0, before); crate::dialogs::confirm(&mut app).unwrap(); crate::dialogs::confirm(&mut app).unwrap(); 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:?}"); } /// A brush dragged onto the left half of another brush of its type goes before it, with a bar /// marking the slot while it is held there, as one undo step; dropped on a brush of another /// type, it stays where it is and the status bar says why. #[test] fn a_brush_dragged_onto_another_of_its_type_moves_before_it() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let ctx = egui::Context::default(); ctx.enable_accesskit(); let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); let (list, _) = brushes(&mut app); let order: Vec = shown(&list, &[]).into_iter().map(str::to_string).collect(); assert_eq!(order[..4], ["3 pt. Round", "6 pt. Flat", "10 pt. Oval", "Dots"]); let accent = Tokens::get(&ctx).accent; let bars = |out: &egui::FullOutput| -> Vec { let lines = out.shapes.iter().filter_map(|c| match &c.shape { egui::Shape::LineSegment { points, stroke } if stroke.color == accent => Some(points[0].x), _ => None, }); lines.collect() }; // Press on a tile, drag it to `to`, hold it there for a frame (its output returned) and release. let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2, time: f64| -> egui::FullOutput { frame_with(&ctx, app, vec![egui::Event::PointerMoved(from), button(from, true)], egui::Modifiers::NONE, time, show); frame_with(&ctx, app, vec![egui::Event::PointerMoved(from + vec2(20.0, 0.0))], egui::Modifiers::NONE, time + 0.1, show); frame_with(&ctx, app, vec![egui::Event::PointerMoved(to)], egui::Modifiers::NONE, time + 0.2, show); let held = frame_with(&ctx, app, vec![], egui::Modifiers::NONE, time + 0.3, show); frame_with(&ctx, app, vec![button(to, false)], egui::Modifiers::NONE, time + 0.4, show); held }; let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); let steps = undo(&app); let calligraphic = |app: &mut VectorcraftApp| -> Vec { brushes(app).0.into_iter().filter(|b| b.1 == "calligraphic").map(|b| b.0).collect() }; // "10 pt. Oval" onto the left half of "3 pt. Round". let held = drag(&mut app, tiles[2].center(), tiles[0].center() - vec2(16.0, 0.0), 1.0); assert_eq!(bars(&held), [tiles[0].left() - 1.5]); assert_eq!(calligraphic(&mut app), ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]); assert_eq!(undo(&app), steps + 1); app.run("edit.undo", json!({})).unwrap(); assert_eq!(calligraphic(&mut app), ["3 pt. Round", "6 pt. Flat", "10 pt. Oval"]); // "3 pt. Round" onto "Dots", a scatter brush: no bar, and nothing moves. let held = drag(&mut app, tiles[0].center(), tiles[3].center(), 3.0); assert!(bars(&held).is_empty(), "{:?}", bars(&held)); assert_eq!(brushes(&mut app).0, list); assert_eq!(undo(&app), steps); assert!(app.ui.status.contains("brushes move only within their type"), "{}", app.ui.status); } }