diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 4b65b3f3..7059aa32 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -290,6 +290,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // Devices p!("touchWorkspace", "Devices", "", "Enable Touch Workspace", bool), p!("touchGestures", "Devices", "", "Enable Touch Gestures", bool), + // Graphic Styles panel menu + p!("overrideCharColor", "Type", "Graphic Styles", "Override Character Color", bool), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 09d0b482..5c21a831 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -8,10 +8,15 @@ //! Styles keep placed gradients relative to the unit box ([`GraphicStyle::unit_box`]): a new or //! redefined style stores them relative to its source's bounds, and each object a style is applied //! to gets them at the same place relative to its own. +//! +//! With Override Character Color on ([`crate::Prefs::override_char_color`]), type a style is applied +//! to loses its characters' own fill (stroke) when the style has fills (strokes), so the style's +//! paint shows instead of the character colour under it. use std::collections::HashMap; use serde_json::{Value, json}; +use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; @@ -53,7 +58,7 @@ pub fn specs() -> Vec { "List Graphic Styles", [], None, - "{} → {styles: [{id, name, fill, stroke, strokeWidth, fills, strokes, effects: [effect id…], opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", linked: [object ids]}], selected: the style the first selected object is linked to, or null}", + "{} → {styles: [{id, name, fill, stroke, strokeWidth, fills, strokes, effects: [effect id…], opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", linked: [object ids]}], selected: the style the first selected object is linked to, or null, overrideCharColor}", has_doc, style_list ), @@ -102,6 +107,33 @@ pub fn specs() -> Vec { has_doc, style_sort ), + cmd!( + "graphicStyle.merge", + "Merge Graphic Styles", + ["Window", "Graphic Styles"], + None, + "{names: [name…] (two or more), name?} add a style combining the styles' fills, strokes and effects (each style's on top of the ones before it) with the first one's opacity, blend mode, isolate and knockout → {name}", + has_doc, + style_merge + ), + cmd!( + "graphicStyle.move", + "Move Graphic Style", + [], + None, + "{name, to: index} move the style to position `to` of the list (0 = first, clamped), as dragging it in the Graphic Styles panel does", + has_doc, + style_move + ), + cmd!( + query "graphicStyle.setOptions", + "Graphic Styles Options", + [], + None, + "{overrideCharColor?: bool} Override Character Color (the preference `overrideCharColor`, on by default): applying a style to type replaces its characters' fill and stroke with the style's fills and strokes → {overrideCharColor}", + always, + style_set_options + ), ] } @@ -211,11 +243,35 @@ fn captured(n: &Node) -> Appearance { ap } -/// Give `id` style `g` (a group or layer too: its own appearance, above its contents) and link it. -fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) { +/// Override Character Color: type taking appearance `ap` loses its characters' fill when `ap` has +/// fills, and their stroke when it has strokes. +fn override_chars(n: &mut Node, ap: &Appearance) { + let NodeKind::Text(t) = &mut n.kind else { return }; + let (fills, strokes) = (ap.items.iter().any(|i| i.is_fill()), ap.items.iter().any(|i| !i.is_fill())); + for r in &mut t.runs { + if fills { + r.style.fill = Paint::None; + } + if strokes { + r.style.stroke = Paint::None; + } + } +} + +/// Give `n` style `g`: its appearance (a group or layer too: its own, above its contents; type its +/// characters' colour too with `chars`, Override Character Color) and transparency. +fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) { + n.appearance = g.appearance_on(n).into_owned(); + if chars { + override_chars(n, &g.appearance); + } + g.apply_transparency(n); +} + +/// Give `id` style `g` ([`style_node`]) and link it. +fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { if let Some(n) = d.node_mut(id) { - n.appearance = g.appearance_on(n).into_owned(); - g.apply_transparency(n); + style_node(n, g, chars); n.graphic_style = Some(g.id); } } @@ -250,6 +306,14 @@ impl Session { let g = st.doc.graphic_style_by_id(n.graphic_style?)?; Some((g, in_sync(n, g))) } + + /// A copy of `n` with style `g` applied as `graphicStyle.apply` applies it (Override Character + /// Color as set): the Graphic Styles panel's previews. + pub fn styled(&self, n: &Node, g: &GraphicStyle) -> Node { + let mut n = n.clone(); + style_node(&mut n, g, self.prefs.override_char_color); + n + } } // ---------- commands ---------- @@ -258,6 +322,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?; let i = style_index(&s.doc()?.doc, name)?; let add = bool_or(p, "add", false); + let chars = s.prefs.override_char_color; let ids = super::appearance::appearance_targets(s, p)?; if ids.is_empty() { return Err(bad("graphicStyle.apply", "nothing selected (or pass `ids`)")); @@ -277,7 +342,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { - apply_style(d, *id, &g); + apply_style(d, *id, &g, chars); } } Ok(()) @@ -360,7 +425,7 @@ fn style_list(s: &mut Session, _: &Value) -> Result { }) .collect(); let selected = s.selection_graphic_style().filter(|(_, linked)| *linked).map(|(g, _)| g.name.clone()); - Ok(json!({ "styles": styles, "selected": selected })) + Ok(json!({ "styles": styles, "selected": selected, "overrideCharColor": s.prefs.override_char_color })) } fn style_redefine(s: &mut Session, p: &Value) -> Result { @@ -377,6 +442,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { }; let look = GraphicStyle::of(String::new(), captured(n), n); let name = d.graphic_styles[i].name.clone(); + let chars = s.prefs.override_char_color; s.edit(&format!("Redefine Graphic Style \u{201c}{name}\u{201d}"), |d, _| { let id = d.graphic_style_id(i); let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look }); @@ -387,7 +453,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); for nid in follow { - apply_style(d, nid, &g); + apply_style(d, nid, &g, chars); } set_link(d, &[src], Some(id)); Ok(()) @@ -433,3 +499,56 @@ fn style_sort(s: &mut Session, _: &Value) -> Result { })?; ok() } + +fn style_merge(s: &mut Session, p: &Value) -> Result { + let cmd = "graphicStyle.merge"; + let names: Vec<&str> = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + if names.len() < 2 { + return Err(bad(cmd, "give two or more style `names`")); + } + let d = &s.doc()?.doc; + let styles = names.iter().map(|n| style_index(d, n).map(|i| &d.graphic_styles[i])).collect::>>()?; + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => unique_name(n, |x| d.graphic_style(x).is_some()), + None => d.new_graphic_style_name(), + }; + // The first style's contents slot (groups and type) stays where it was in its stack. + let mut ap = styles[0].appearance.clone(); + for g in &styles[1..] { + ap.items.extend(g.appearance.items.iter().cloned()); + ap.effects.extend(g.appearance.effects.iter().cloned()); + } + // Styles saved before unit-box styles keep their placed gradients in document coordinates. + let unit_box = styles.iter().filter(|g| g.appearance.has_placed_gradient()).all(|g| g.unit_box); + let g = GraphicStyle { name: name.clone(), appearance: ap, id: d.next_graphic_style_id(), unit_box, ..styles[0].clone() }; + s.edit("Merge Graphic Styles", |d, _| { + d.graphic_styles.push(g); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +fn style_move(s: &mut Session, p: &Value) -> Result { + let cmd = "graphicStyle.move"; + let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?; + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `to` (an index)"))?; + let d = &s.doc()?.doc; + let from = style_index(d, name)?; + let to = (to as usize).min(d.graphic_styles.len() - 1); + if from == to { + return ok(); + } + s.edit("Move Graphic Style", |d, _| { + let g = d.graphic_styles.remove(from); + d.graphic_styles.insert(to, g); + Ok(()) + })?; + ok() +} + +fn style_set_options(s: &mut Session, p: &Value) -> Result { + if let Some(on) = p.get("overrideCharColor").and_then(Value::as_bool) { + s.prefs.override_char_color = on; + } + Ok(json!({ "overrideCharColor": s.prefs.override_char_color })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f80b262e..4ccdb00a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -306,6 +306,10 @@ pub struct Prefs { // Devices pub touch_workspace: bool, pub touch_gestures: bool, + // Graphic Styles panel + /// Override Character Color: a graphic style applied to type replaces its characters' fill + /// and stroke with the style's fills and strokes. + pub override_char_color: bool, } impl Default for Prefs { @@ -418,6 +422,7 @@ impl Default for Prefs { black_output: s("accurate"), touch_workspace: true, touch_gestures: true, + override_char_color: true, } } } @@ -811,6 +816,8 @@ mod tests_strokegeom; #[cfg(test)] mod tests_strokeux; #[cfg(test)] +mod tests_stylepanel; +#[cfg(test)] mod tests_styles; #[cfg(test)] mod tests_swatchcmds; diff --git a/crates/engine/src/tests_stylepanel.rs b/crates/engine/src/tests_stylepanel.rs new file mode 100644 index 00000000..7717f4f7 --- /dev/null +++ b/crates/engine/src/tests_stylepanel.rs @@ -0,0 +1,144 @@ +//! Graphic Styles panel depth: Merge Graphic Styles, moving styles and Override Character Color. + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{GraphicStyle, NodeId, NodeKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn style(s: &Session, name: &str) -> GraphicStyle { + s.doc().unwrap().doc.graphic_style(name).unwrap_or_else(|| panic!("no style {name}")).clone() +} + +fn names(s: &Session) -> Vec { + s.doc().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect() +} + +/// A style named `name` made from a rectangle filled `fill`, stroked `stroke` `width` pt wide, +/// with `opacity` and, if given, one effect. +fn make(s: &mut Session, name: &str, fill: &str, stroke: &str, width: f64, opacity: f64, effect: Option<&str>) { + let id = id_of(&run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))); + run(s, "paint.setFill", json!({"color": fill, "ids": [id.0]})); + run(s, "paint.setStroke", json!({"color": stroke, "ids": [id.0]})); + run(s, "stroke.set", json!({"weight": width})); + run(s, "transparency.set", json!({"opacity": opacity, "blend": if opacity < 100.0 { "multiply" } else { "normal" }})); + if let Some(e) = effect { + run(s, "effect.apply", json!({ "effect": e })); + } + run(s, "graphicStyle.new", json!({ "id": id.0, "name": name })); +} + +#[test] +fn merge_combines_the_stacks_in_order() { + let mut s = session(); + make(&mut s, "A", "#ff0000", "#000000", 2.0, 50.0, Some("distort.roughen")); + make(&mut s, "B", "#00ff00", "#0000ff", 6.0, 100.0, Some("distort.twist")); + let (a, b) = (style(&s, "A"), style(&s, "B")); + let undo = s.doc().unwrap().history.undo.len(); + let r = run(&mut s, "graphicStyle.merge", json!({"names": ["A", "B"], "name": "AB"})); + assert_eq!(r["name"], "AB"); + let m = style(&s, "AB"); + // Each style's fills and strokes on top of the ones before it, effects likewise. + let items: Vec<_> = a.appearance.items.iter().chain(&b.appearance.items).cloned().collect(); + assert_eq!(m.appearance.items, items); + let fx: Vec<&str> = m.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(fx, ["distort.roughen", "distort.twist"]); + // The first style's transparency, a fresh id, added last, one undo step. + assert_eq!((m.opacity, m.blend), (0.5, BlendMode::Multiply)); + assert!(m.id != 0 && m.id != a.id && m.id != b.id); + assert_eq!(names(&s).last().unwrap(), "AB"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // The merged style paints both: the top fill is B's, and objects take the whole stack. + let t = id_of(&run(&mut s, "shape.rectangle", json!({"x": 200, "y": 10, "width": 40, "height": 40}))); + run(&mut s, "graphicStyle.apply", json!({"name": "AB", "ids": [t.0]})); + let n = s.doc().unwrap().doc.node(t).unwrap().clone(); + assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); + assert_eq!(n.appearance.items.len(), 4); + // Names stay unique, the default name is the next free one, and one style is not a merge. + assert_eq!(run(&mut s, "graphicStyle.merge", json!({"names": ["B", "A"], "name": "AB"}))["name"], "AB 2"); + assert!(run(&mut s, "graphicStyle.merge", json!({"names": ["A", "B"]}))["name"].as_str().unwrap().starts_with("Graphic Style")); + assert!(s.execute("graphicStyle.merge", &json!({"names": ["A"]})).is_err()); + assert!(s.execute("graphicStyle.merge", &json!({"names": ["A", "nope"]})).is_err()); + while s.doc().unwrap().history.undo.len() > undo { + run(&mut s, "edit.undo", json!({})); + } + assert!(!names(&s).iter().any(|n| n.starts_with("AB"))); +} + +#[test] +fn move_reorders_and_undoes_in_one_step() { + let mut s = session(); + let before = names(&s); + let last = before.last().unwrap().clone(); + run(&mut s, "graphicStyle.move", json!({"name": last, "to": 0})); + assert_eq!(names(&s)[0], last); + assert_eq!(names(&s)[1..], before[..before.len() - 1]); + // `to` past the end is clamped to the last place. + let first = before[0].clone(); + run(&mut s, "graphicStyle.move", json!({"name": first, "to": 99})); + assert_eq!(names(&s).last().unwrap(), &first); + // Moving to where it is records nothing. + let undo = s.doc().unwrap().history.undo.len(); + run(&mut s, "graphicStyle.move", json!({"name": first, "to": before.len() - 1})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&s), before); + assert!(s.execute("graphicStyle.move", &json!({"name": "nope", "to": 0})).is_err()); + assert!(s.execute("graphicStyle.move", &json!({"name": first})).is_err()); +} + +#[test] +fn override_character_color_clears_the_run_fill() { + let mut s = session(); + make(&mut s, "Blue", "#0000ff", "#000000", 1.0, 100.0, None); + let text = |s: &mut Session, y: f64| id_of(&run(s, "text.create", json!({"x": 10, "y": y, "text": "Hi", "size": 40}))); + let runs = |s: &Session, id: NodeId| match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone())).collect::>(), + _ => unreachable!(), + }; + // On by default: the characters' own colour gives way to the style's fill and stroke. + assert_eq!(run(&mut s, "graphicStyle.setOptions", json!({}))["overrideCharColor"], true); + let a = text(&mut s, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [a.0]})); + assert!(runs(&s, a).iter().all(|(f, _)| *f != Paint::None)); + run(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": [a.0]})); + assert!(runs(&s, a).iter().all(|(f, st)| *f == Paint::None && *st == Paint::None)); + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::from_hex("#0000ff").unwrap())); + // Off: they keep it (the style's paint draws over them). + assert_eq!(run(&mut s, "graphicStyle.setOptions", json!({"overrideCharColor": false}))["overrideCharColor"], false); + assert_eq!(run(&mut s, "prefs.get", json!({"key": "overrideCharColor"})), false); + assert_eq!(run(&mut s, "graphicStyle.list", json!({}))["overrideCharColor"], false); + let b = text(&mut s, 200.0); + let kept = runs(&s, b); + run(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": [b.0]})); + assert_eq!(runs(&s, b), kept); + // The preference drives it too. + run(&mut s, "prefs.set", json!({"key": "overrideCharColor", "value": true})); + assert!(s.prefs.override_char_color); +} + +#[test] +fn override_character_color_round_trips_and_defaults_on() { + let p = Prefs { override_char_color: false, ..Prefs::default() }; + let back: Prefs = serde_json::from_value(p.to_json()).unwrap(); + assert!(!back.override_char_color); + // Preferences saved before the option had it on. + let mut old = Prefs::default().to_json(); + old.as_object_mut().unwrap().remove("overrideCharColor"); + assert!(serde_json::from_value::(old).unwrap().override_char_color); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7bb4c3d2..859d0c29 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -78,6 +78,11 @@ enum Drag { }, /// Cmd held: temporary selection tool; restore this tool on release. TempSelect, + /// A Selection tool move dragged off the canvas: the panels get the art + /// ([`widgets::PanelDrag::Art`]); `temp` restores the tool Cmd switched from on release. + Art { + temp: bool, + }, } fn drag_id() -> egui::Id { @@ -427,7 +432,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: vm.rotation = vectorcraft_geom::normalize_deg(deg); } } - Drag::ZoomBox { .. } => {} + Drag::ZoomBox { .. } | Drag::Art { .. } => {} + Drag::Tool | Drag::TempSelect if drag_art_out(app, ui, resp, p, view) => { + ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art { temp: d == Drag::TempSelect })); + } Drag::Tool | Drag::TempSelect => { if pointer.delta() != egui::Vec2::ZERO { let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; @@ -454,10 +462,12 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } } } - Drag::Tool | Drag::TempSelect => { - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; - dispatch(app, &ev, view); - if d == Drag::TempSelect + Drag::Tool | Drag::TempSelect | Drag::Art { .. } => { + if !matches!(d, Drag::Art { .. }) { + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + dispatch(app, &ev, view); + } + if matches!(d, Drag::TempSelect | Drag::Art { temp: true }) && let Some(prev) = ui.data(|dd| dd.get_temp::(temp_tool_id())) { app.select_tool(&prev); @@ -483,6 +493,26 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } } +/// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of +/// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it +/// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it). +fn drag_art_out(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2, view: vectorcraft_engine::ViewInfo) -> bool { + let off = !ui.clip_rect().contains(p) || ui.ctx().layer_id_at(p).is_some_and(|l| l != resp.layer_id); + let Some(st) = app.session.active().filter(|_| off && app.session.tool_id() == "selection") else { return false }; + // The Selection tool's interaction while it moves (or Alt-copies) the selection. + if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") { + return false; + } + let ids = st.selection.objects.clone(); + if app.session.cancel_interaction().is_err() { + return false; + } + // With nothing left to commit, the tool's mouse-up just returns it to rest. + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids)); + true +} + /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { match app.session.pointer(ev, view) { @@ -678,7 +708,8 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { + 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, }; if let Err(e) = app.run(cmd, params) { app.status(e); @@ -1197,4 +1234,67 @@ mod tests { drop(&mut app, Point::new(70.0, 70.0)); assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len); } + + #[test] + fn a_graphic_style_dropped_on_art_applies_to_the_object_hit() { + use vectorcraft_doc::NodeId; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 400, "height": 300})); + let a = NodeId(run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + let b = NodeId(run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(&mut app, "select.set", json!({ "ids": [a.0] })); + let name = app.session.active().unwrap().doc.graphic_styles[2].name.clone(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(250.0, 70.0)); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::GraphicStyle(name.clone())); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![up]); + let st = app.session.active().unwrap(); + let g = st.doc.graphic_style(&name).unwrap(); + // `graphicStyle.apply` with the hit object's id: linked, the selection left alone. + assert_eq!(st.doc.node(b).unwrap().graphic_style, Some(g.id)); + assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance); + assert_eq!(st.doc.node(a).unwrap().graphic_style, None); + assert_eq!(st.selection.objects, [a]); + } + + #[test] + fn art_moved_off_the_canvas_becomes_a_panel_drag_and_stays_put() { + 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("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = vectorcraft_doc::NodeId(id); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); + // Moving on the canvas moves the art... + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); + assert!(app.session.active().unwrap().interaction.is_some()); + // ...until the pointer leaves it: the move is dropped and the panels get the art. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); + assert!(app.session.active().unwrap().interaction.is_none()); + assert_eq!(*app.session.active().unwrap().doc, *before, "the art is back where it was"); + assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id]))); + assert!(!app.session.tool_busy()); + // Released anywhere, nothing moves and nothing is recorded. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, false)]); + let st = app.session.active().unwrap(); + assert_eq!(*st.doc, *before); + assert_eq!(st.history.undo.len(), undo); + } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 87180ec4..c7a411fe 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -2,7 +2,6 @@ use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; -use vectorcraft_color::Paint; use vectorcraft_doc::NodeKind; use crate::panels::stroke as stroke_panel; @@ -59,14 +58,12 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { }); } -fn chip_button(ui: &mut Ui, paint: &Paint, stroke_style: bool, tip: &str) -> egui::Response { +/// A Control bar chip that opens something: `draw` paints the chip, a chevron follows it. +fn chip_button(ui: &mut Ui, tip: &str, draw: impl FnOnce(&Ui, egui::Rect)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::click()); let chip = egui::Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(18.0, 18.0)); - paint_chip(ui, chip, paint); - if stroke_style { - ui.painter().rect_filled(chip.shrink(5.0), 0.0, t.panel); - } + draw(ui, chip); ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); icons::paint(ui, "chevron-down", egui::Rect::from_min_size(r.min + vec2(21.0, 5.0), vec2(12.0, 12.0)), t.text_dim); resp.on_hover_text(tip) @@ -105,12 +102,16 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let mixed = crate::panels::stroke_mixed(app, ui.ctx()); let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; - if chip_button(ui, &fill, false, "Fill").clicked() { + if chip_button(ui, "Fill", |ui, r| paint_chip(ui, r, &fill)).clicked() { app.run("paint.toggleActive", json!({})).ok(); app.session.fill_active = true; app.ui.open_panel = Some("swatches".into()); } - if chip_button(ui, &stroke, true, "Stroke").clicked() { + let stroke_chip = |ui: &Ui, r: egui::Rect| { + paint_chip(ui, r, &stroke); + ui.painter().rect_filled(r.shrink(5.0), 0.0, t.panel); + }; + if chip_button(ui, "Stroke", stroke_chip).clicked() { app.session.fill_active = false; app.ui.open_panel = Some("swatches".into()); } @@ -133,6 +134,15 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { { app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok(); } + if !sel.is_empty() { + // Style picker: the selection's graphic style; its menu applies another. + ui.add_space(4.0); + if ui.link(egui::RichText::new("Style:").size(12.0).color(t.text).underline()).clicked() { + app.ui.open_panel = Some("graphicStyles".into()); + } + let resp = chip_button(ui, "Graphic Style", |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)); + egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); + } ui.separator(); if sel.is_empty() { if widgets::flat_button(ui, "Document Setup", 112.0).clicked() { diff --git a/crates/ui-egui/src/dialogs/graphic_style_options.rs b/crates/ui-egui/src/dialogs/graphic_style_options.rs index 33d0982d..0691a7d8 100644 --- a/crates/ui-egui/src/dialogs/graphic_style_options.rs +++ b/crates/ui-egui/src/dialogs/graphic_style_options.rs @@ -1,8 +1,10 @@ //! Graphic Style Options: the style's name. Opened on a style it renames it (`graphicStyle.rename`, //! linked objects stay linked); opened for New Graphic Style it names the style made from the -//! selection (`graphicStyle.new`). +//! selection (`graphicStyle.new`); opened for Merge Graphic Styles it names the style merged from +//! the selected ones (`graphicStyle.merge`). //! -//! Fields: `name` and `__style` (the style being renamed; empty for a new style). +//! Fields: `name`, `__style` (the style being renamed; empty for a new style) and `__merge` (the +//! styles to merge, if merging). use serde_json::{Value, json}; @@ -28,6 +30,19 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result) -> Result { + let d = &app.session.active().ok_or("no document open")?.doc; + if names.len() < 2 { + return Err("select two or more graphic styles to merge".into()); + } + if let Some(n) = names.iter().find(|n| d.graphic_style(n).is_none()) { + return Err(format!("no graphic style `{n}`")); + } + app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": d.new_graphic_style_name(), "__style": "", "__merge": names }))); + Ok(Value::Null) +} + fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { grid(ui, |ui| { label(ui, "Style Name:"); @@ -37,10 +52,13 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } -/// OK: name the new style or rename the style. A name another style has keeps the dialog open. +/// OK: name the new or merged style, or rename the style. A name another style has keeps the +/// dialog open. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (name, style) = (d.str("name"), d.str("__style")); + let merge = d.fields.get("__merge").filter(|m| m.as_array().is_some_and(|a| !a.is_empty())); let r = match style.as_str() { + "" if merge.is_some() => app.run("graphicStyle.merge", json!({ "names": merge, "name": name })), "" => app.run("graphicStyle.new", json!({ "name": name })), s if s == name => Ok(Value::Null), s => app.run("graphicStyle.rename", json!({ "name": s, "to": name })), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4fd22900..9e3708c2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -247,6 +247,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.userSwatchLibrary8", "User Swatch Library 8", "", "{} open the 8. User Defined swatch library"), ("window.userSwatchLibrary9", "User Swatch Library 9", "", "{} open the 9. User Defined swatch library"), ("window.userSwatchLibrary10", "User Swatch Library 10", "", "{} open the 10. User Defined swatch library"), + ( + "ui.mergeGraphicStyles", + "Merge Graphic Styles…", + "", + "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -590,6 +596,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::swatches::open_library(app, &json!({ "library": l.id })), None => Err("no such user library".into()), }, + "ui.mergeGraphicStyles" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::graphic_style_options::open_merge(app, names.unwrap_or_default()) + } _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 7226a414..0e6fbbb7 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -1,41 +1,247 @@ -//! Graphic Styles panel: thumbnail grid or list of the document's styles. Clicking a style applies -//! it to the selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click -//! opens Graphic Style Options. +//! Graphic Styles panel: the document's styles as rendered thumbnails (Thumbnail View) or rows +//! (Small and Large List View), shown on a square or on type. Clicking a style applies it to the +//! selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click opens +//! Graphic Style Options. Dragging a style moves it in the list, or applies it to the art it is +//! dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance panel's +//! thumbnail dropped on the panel becomes a new style. Holding the right button on a style shows it +//! large, on the selected object when there is one. The Control bar's Style picker lists the same +//! tiles ([`picker`]). -use egui::{Rect, Response, Sense, Stroke, StrokeKind, Ui, vec2}; -use serde_json::json; -use vectorcraft_doc::{Appearance, GraphicStyle}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, TextureHandle, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, Point, shapes}; use super::{alt_held, pstate, selection_len, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; -use crate::widgets::{self, menu_item}; +use crate::widgets::{self, PanelDrag, menu_item}; -/// What a click on a style does (run after drawing, once the document is no longer borrowed). +/// The panel's views. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum View { + #[default] + Thumbnail, + SmallList, + LargeList, +} + +impl View { + const ALL: [(View, &'static str); 3] = + [(View::Thumbnail, "Thumbnail View"), (View::SmallList, "Small List View"), (View::LargeList, "Large List View")]; + /// Row height and thumbnail side of the list views; None for the thumbnail grid. + fn row(self) -> Option<(f32, f32)> { + match self { + View::Thumbnail => None, + View::SmallList => Some((18.0, 14.0)), + View::LargeList => Some((26.0, 20.0)), + } + } +} + +/// What the thumbnails show the styles on (Use Square / Use Text for Previews). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +enum Preview { + #[default] + Square, + Text, +} + +/// Side of a thumbnail tile (Thumbnail View and the Control bar's picker). +const TILE: f32 = 40.0; +/// Side of the large preview shown while the right button is held on a style. +const LARGE: f32 = 132.0; +const UV: Rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + +thread_local! { + static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); + /// Thumbnails by (look hash, pixels, preview shape, Override Character Color on type). + static THUMBS: RefCell> = RefCell::new(HashMap::new()); +} + +// ---------- thumbnails ---------- + +/// Does `g` stroke with a brush? +fn uses_brush(g: &GraphicStyle) -> bool { + g.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some())) +} + +/// The look of `g` hashed: its appearance and transparency, and the patterns and brushes it paints +/// with. Thumbnails are cached by it, so a style's thumbnail is re-rendered only when its look +/// changes. +fn look_hash(d: &Document, g: &GraphicStyle) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + serde_json::to_vec(&(&g.appearance, g.opacity, g.blend, g.isolate, g.knockout)).unwrap_or_default().hash(&mut h); + for it in &g.appearance.items { + if let Paint::Pattern { pattern, .. } = it.paint() + && let Some(p) = d.pattern(pattern) + { + serde_json::to_vec(p).unwrap_or_default().hash(&mut h); + } + } + if uses_brush(g) { + d.unknown.get("brushes").map(Value::to_string).hash(&mut h); + } + h.finish() +} + +/// The styles of a document as the panel shows them: their [`look_hash`]es and the preview shape. +struct Shown<'a> { + d: &'a Document, + looks: &'a [u64], + preview: Preview, +} + +impl Shown<'_> { + /// The look hash of style `i`. + fn look(&self, i: usize) -> u64 { + self.looks.get(i).copied().unwrap_or_else(|| look_hash(self.d, &self.d.graphic_styles[i])) + } +} + +/// [`look_hash`] of every style of the active document, computed once per document revision. +fn look_hashes(ctx: &egui::Context, app: &VectorcraftApp) -> Arc> { + let Some(st) = app.session.active() else { return Arc::default() }; + let key = (st.uid, st.revision); + match pstate::>)>>(ctx, "gs-looks") { + Some((k, looks)) if k == key => looks, + _ => { + let looks: Arc> = Arc::new(st.doc.graphic_styles.iter().map(|g| look_hash(&st.doc, g)).collect()); + set_pstate(ctx, "gs-looks", Some((key, looks.clone()))); + looks + } + } +} + +/// Style `g` applied (as `graphicStyle.apply` applies it) to the preview shape, rendered `px` +/// pixels square on white, with the patterns and brushes of `d`. +fn render(app: &VectorcraftApp, d: &Document, g: &GraphicStyle, preview: Preview, px: u32) -> Option { + const SIDE: f64 = 100.0; + let mut doc = Document::new(SIDE, SIDE); + doc.patterns = d.patterns.clone(); + if uses_brush(g) + && let Some(b) = d.unknown.get("brushes") + { + doc.unknown.insert("brushes".into(), b.clone()); + } + let id = doc.alloc_id(); + let shape = match preview { + Preview::Square => Node::path(id, shapes::rectangle(vectorcraft_geom::Rect::new(22.0, 22.0, 78.0, 78.0)), Appearance::default()), + Preview::Text => { + let style = CharStyle { size: 54.0, ..Default::default() }; + let mut n = Node::new(id, NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "Aa", style)))); + if let Some(b) = n.geometric_bounds() { + n.transform(Affine::translate(Point::new(SIDE / 2.0, SIDE / 2.0) - b.center()), false); + } + n + } + }; + let layer = doc.layers[0].id; + doc.insert(Some(layer), 0, app.session.styled(&shape, g)).ok()?; + let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, SIDE, SIDE), px as f64 / SIDE, true)); + Some(egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels)) +} + +/// The thumbnail of style `g` (`look`: its [`look_hash`]) at `size` points. +fn thumb(app: &VectorcraftApp, ctx: &egui::Context, d: &Document, g: &GraphicStyle, look: u64, preview: Preview, size: f32) -> Option { + let px = (size * ctx.pixels_per_point()).round().max(8.0) as u32; + // Override Character Color changes only how type takes a style. + let key = (look, px, preview, preview == Preview::Text && app.session.prefs.override_char_color); + if let Some(t) = THUMBS.with(|c| c.borrow().get(&key).cloned()) { + return Some(t); + } + let tex = ctx.load_texture(format!("gs-{look:x}-{px}-{preview:?}"), render(app, d, g, preview, px)?, egui::TextureOptions::LINEAR); + THUMBS.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 512 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) +} + +/// Draw the thumbnail of style `i` into `r`. +fn paint_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, s: &Shown, i: usize) { + if !ui.is_rect_visible(r) { + return; + } + match thumb(app, ui.ctx(), s.d, &s.d.graphic_styles[i], s.look(i), s.preview, r.width()) { + Some(t) => ui.painter().image(t.id(), r, UV, Color32::WHITE), + None => ui.painter().rect_filled(r, 0.0, Color32::WHITE), + }; +} + +/// Style `g` on a copy of the first selected object, `px` pixels square on white. One texture, +/// re-rendered only when the document, the style's look or the size changes. +fn object_preview(app: &VectorcraftApp, ctx: &egui::Context, g: &GraphicStyle, look: u64, px: u32) -> Option { + let st = app.session.active()?; + let n = st.doc.node(*st.selection.objects.first()?)?; + let slot = egui::Id::new("gs-large-object"); + let key = egui::Id::new((st.uid, st.revision, look, px, app.session.prefs.override_char_color)); + if let Some((k, tex)) = ctx.data(|d| d.get_temp::<(egui::Id, TextureHandle)>(slot)) + && k == key + { + return Some(tex); + } + let styled = app.session.styled(n, g); + let img = RENDERER.with(|r| r.borrow_mut().render_node_thumbnail(&st.doc, &styled, px, Some([255; 4])))?; + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ctx.load_texture("gs-large-object", color, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(slot, (key, tex.clone()))); + Some(tex) +} + +/// The large preview of style `i` next to the pointer while the right button is held on it: on +/// the selected object when there is one, else on the preview shape. +fn large_preview(app: &VectorcraftApp, ctx: &egui::Context, s: &Shown, i: usize) { + let Some(at) = ctx.pointer_hover_pos() else { return }; + let (g, look) = (&s.d.graphic_styles[i], s.look(i)); + let px = (LARGE * ctx.pixels_per_point()).round() as u32; + let tex = object_preview(app, ctx, g, look, px).or_else(|| thumb(app, ctx, s.d, g, look, s.preview, LARGE)); + let t = Tokens::get(ctx); + let area = egui::Area::new(egui::Id::new("gs-large")).order(egui::Order::Tooltip).fixed_pos(at + vec2(14.0, 14.0)); + area.interactable(false).show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(LARGE, LARGE), Sense::hover()); + match &tex { + Some(tex) => ui.painter().image(tex.id(), r, UV, Color32::WHITE), + None => ui.painter().rect_filled(r, 0.0, Color32::WHITE), + }; + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + ui.label(egui::RichText::new(&g.name).size(11.5).color(t.text)); + }); + }); +} + +// ---------- tiles and rows ---------- + +/// What a click on a style does. enum Click { Apply(String), Toggle(String), Options(String), } -fn preview(ui: &Ui, r: Rect, ap: &Appearance) { - ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - let inner = r.shrink(r.width() * 0.18); - widgets::paint_chip(ui, inner, &ap.fill_paint()); - if let Some(s) = ap.stroke() - && let Some(c) = s.paint.color() - { - ui.painter().rect_stroke(inner, 0.0, Stroke::new((s.width as f32).clamp(1.0, 4.0), super::c32(&c)), StrokeKind::Middle); - } - if !ap.effects.is_empty() { - ui.painter().text( - r.right_bottom() - vec2(3.0, 2.0), - egui::Align2::RIGHT_BOTTOM, - "fx", - egui::FontId::proportional(9.0), - egui::Color32::DARK_GRAY, - ); - } +/// What the tiles or rows saw this frame (acted on after drawing, see [`act`]). +#[derive(Default)] +struct Events { + click: Option, + /// A style dragged to index `to` of the list. + moved: Option<(String, usize)>, + /// Art or an appearance dropped on the panel: a new style from this object. + new_from: Option, + /// The style the right button is held on. + large: Option, + /// A style is dragged over a tile or row. + over: bool, } /// The click on a style's row or tile. @@ -50,64 +256,137 @@ fn clicked(ui: &Ui, resp: &Response, name: &str) -> Option { } } -/// The styles selected in the panel. -fn selected(ctx: &egui::Context) -> Vec { - pstate(ctx, "gs-sel") +/// Clicks, drags and the right button on the tile or row `resp` of style `i` of `d` (`list`: rows +/// stack downwards). A drag from it carries the style ([`PanelDrag::GraphicStyle`]); a style +/// dragged over it lands before or after it by the pointer's half. +fn cell_input(ui: &Ui, resp: &Response, d: &Document, i: usize, list: bool, ev: &mut Events) { + let name = &d.graphic_styles[i].name; + widgets::drag_source(ui, resp, || PanelDrag::GraphicStyle(name.clone())); + if let Some(drag) = resp.dnd_hover_payload::() + && let PanelDrag::GraphicStyle(moving) = &*drag + { + ev.over = true; + if moving == name { + // Dropped on itself: it stays where it is. + resp.dnd_release_payload::(); + } else { + 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 }; + let accent = Stroke::new(2.0, Tokens::get(ui.ctx()).accent); + if list { + let y = if after { r.bottom() } else { r.top() }; + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], accent); + } else { + let x = if after { r.right() + 1.5 } else { r.left() - 1.5 }; + ui.painter().line_segment([pos2(x, r.top()), pos2(x, r.bottom())], accent); + } + if resp.dnd_release_payload::().is_some() + && let Some(from) = d.graphic_style_index(moving) + { + let to = i + after as usize; + ev.moved = Some((moving.clone(), if from < to { to - 1 } else { to })); + } + } + } + if resp.contains_pointer() && ui.input(|i| i.pointer.secondary_down()) { + ev.large = Some(i); + } + if let Some(c) = clicked(ui, resp, name) { + ev.click = Some(c); + } } -pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - // The document is shared (an `Arc`): holding it while drawing copies nothing. - let doc = app.session.active().map(|d| d.doc.clone()); - let styles: &[GraphicStyle] = doc.as_ref().map_or(&[], |d| &d.graphic_styles); - let sel = selected(ui.ctx()); - let list: bool = pstate(ui.ctx(), "gs-list"); - let mut click = None; - widgets::list_box(ui, |ui| { - ui.set_min_height(120.0); - ui.set_width(ui.available_width()); - if styles.is_empty() { - super::empty_state(ui, "dc-graphic-styles", "No graphic styles", "Select styled art and click New Graphic Style."); - return; +/// Drags released on the list away from the tiles: a style goes to the end, art (the first +/// object) or the Appearance panel's thumbnail becomes a new style. The list is outlined while one +/// is held over it. +fn zone_input(ui: &Ui, zone: &Response, d: &Document, ev: &mut Events) { + let Some(drag) = zone.dnd_hover_payload::() else { return }; + let (style, source) = match &*drag { + PanelDrag::GraphicStyle(n) => (Some(n), None), + PanelDrag::Art(ids) => (None, ids.first().copied()), + PanelDrag::Appearance(id) => (None, Some(*id)), + PanelDrag::Paint { .. } => (None, None), + }; + if style.is_none() && source.is_none() { + return; + } + if !ev.over { + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if zone.dnd_release_payload::().is_some() { + match style { + Some(n) => ev.moved = Some((n.clone(), d.graphic_styles.len().saturating_sub(1))), + None => ev.new_from = source, } - if list { - for g in styles { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - if sel.contains(&g.name) { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - preview(ui, Rect::from_min_size(r.left_center() + vec2(4.0, -9.0), vec2(18.0, 18.0)), &g.appearance); - ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.0), t.text); - if let Some(c) = clicked(ui, &resp, &g.name) { - click = Some(c); - } - } - } else { - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(3.0, 3.0); - for g in styles { - let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); - preview(ui, r, &g.appearance); - let on = sel.contains(&g.name); - let color = if on { - t.accent - } else if resp.hovered() { - t.text - } else { - t.border - }; - ui.painter().rect_stroke(r, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, color), StrokeKind::Inside); - let resp = resp.on_hover_text(&g.name); - if let Some(c) = clicked(ui, &resp, &g.name) { - click = Some(c); - } - } - }); + } +} + +/// The thumbnail grid (Thumbnail View and the Control bar's picker), `sel` outlined. +fn tiles(app: &VectorcraftApp, ui: &mut Ui, s: &Shown, sel: &[String], ev: &mut Events) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (i, g) in s.d.graphic_styles.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag()); + paint_thumb(app, ui, r, s, i); + let on = sel.contains(&g.name); + let color = if on { + t.accent + } else if resp.hovered() { + t.text + } else { + t.border + }; + ui.painter().rect_stroke(r, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, color), StrokeKind::Inside); + cell_input(ui, &resp.on_hover_text(&g.name), s.d, i, false, ev); } }); - match click { +} + +/// The list views' rows (`row`: height and thumbnail side), `sel` highlighted. +fn rows(app: &VectorcraftApp, ui: &mut Ui, s: &Shown, sel: &[String], row: (f32, f32), ev: &mut Events) { + let t = Tokens::get(ui.ctx()); + let (h, side) = row; + for (i, g) in s.d.graphic_styles.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); + if sel.contains(&g.name) { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let th = Rect::from_min_size(r.left_center() + vec2(4.0, -side / 2.0), vec2(side, side)); + paint_thumb(app, ui, th, s, i); + ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + ui.painter().text(pos2(th.right() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.0), t.text); + cell_input(ui, &resp, s.d, i, true, ev); + } +} + +/// Run `cmd`, reporting a failure in the status bar. +fn run(app: &mut VectorcraftApp, cmd: &str, params: Value) { + if let Err(e) = app.run(cmd, params) { + app.status(e); + } +} + +/// Act on what the tiles or rows saw. In the Control bar's `picker` every click applies the style +/// and closes the menu. +fn act(app: &mut VectorcraftApp, ui: &Ui, s: &Shown, ev: Events, picker: bool) { + if let Some(i) = ev.large { + large_preview(app, ui.ctx(), s, i); + } + if let Some((name, to)) = ev.moved { + run(app, "graphicStyle.move", json!({ "name": name, "to": to })); + } + if let Some(id) = ev.new_from { + run(app, "graphicStyle.new", json!({ "id": id.0 })); + } + match ev.click { + Some(Click::Apply(name) | Click::Toggle(name)) if picker => { + app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); + ui.close(); + } Some(Click::Apply(name)) => { if selection_len(app) > 0 { app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); @@ -115,7 +394,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { set_pstate(ui.ctx(), "gs-sel", vec![name]); } Some(Click::Toggle(name)) => { - let mut sel = sel; + let mut sel = selected(ui.ctx(), s.d); match sel.iter().position(|n| *n == name) { Some(i) => { sel.remove(i); @@ -129,7 +408,44 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } None => {} } - let sel = selected(ui.ctx()); +} + +/// The styles selected in the panel (those still in the document). +fn selected(ctx: &egui::Context, d: &Document) -> Vec { + let mut sel: Vec = pstate(ctx, "gs-sel"); + sel.retain(|n| d.graphic_style(n).is_some()); + sel +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + // The document is shared (an `Arc`): holding it while drawing copies nothing. + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { + super::empty_state(ui, "dc-graphic-styles", "No document", "Open a document to see its graphic styles."); + return; + }; + let looks = look_hashes(ui.ctx(), app); + let shown = Shown { d: &doc, looks: &looks, preview: pstate(ui.ctx(), "gs-preview") }; + let sel = selected(ui.ctx(), &doc); + let view: View = pstate(ui.ctx(), "gs-view"); + let mut ev = Events::default(); + widgets::list_box(ui, |ui| { + let out = egui::ScrollArea::vertical().id_salt("gs-scroll").max_height(220.0).show(ui, |ui| { + ui.set_min_height(120.0); + ui.set_width(ui.available_width()); + if doc.graphic_styles.is_empty() { + super::empty_state(ui, "dc-graphic-styles", "No graphic styles", "Select styled art and click New Graphic Style."); + return; + } + match view.row() { + Some(row) => rows(app, ui, &shown, &sel, row, &mut ev), + None => tiles(app, ui, &shown, &sel, &mut ev), + } + }); + let zone = ui.interact(out.inner_rect, ui.id().with("gs-drop"), Sense::hover()); + zone_input(ui, &zone, &doc, &mut ev); + }); + act(app, ui, &shown, ev, false); + let sel = selected(ui.ctx(), &doc); let has_sel = selection_len(app) > 0; let linked = app.session.selection_graphic_style().is_some(); widgets::bottom_bar(ui, |ui| { @@ -141,12 +457,40 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::icon_button_enabled(ui, "dc-new-item", "New Graphic Style (Alt-click to name it)", false, has_sel, 24.0).clicked() { new_style(app, alt_held(ui)); } - if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style", false, !sel.is_empty(), 24.0).clicked() { - delete(app, ui.ctx(), &sel); + if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style (Alt-click: without asking)", false, !sel.is_empty(), 24.0).clicked() { + delete(app, &sel, alt_held(ui)); } }); } +/// The thumbnail of style `name` in `r` (white when there is no such style): a dragged style's +/// chip and the Control bar's Style chip. +pub(crate) fn paint_style(app: &VectorcraftApp, ui: &Ui, r: Rect, name: &str) { + match app.session.active().and_then(|st| Some((&st.doc, st.doc.graphic_style_index(name)?))) { + Some((d, i)) => paint_thumb(app, ui, r, &Shown { d, looks: &look_hashes(ui.ctx(), app), preview: pstate(ui.ctx(), "gs-preview") }, i), + None => { + ui.painter().rect_filled(r, 0.0, Color32::WHITE); + } + } +} + +/// The Control bar's Style chip: the style the selection is linked to (blank when it is not). +pub(crate) fn paint_linked(app: &VectorcraftApp, ui: &Ui, r: Rect) { + let linked = app.session.selection_graphic_style().filter(|(_, linked)| *linked).map(|(g, _)| g.name.as_str()); + paint_style(app, ui, r, linked.unwrap_or_default()); +} + +/// The Control bar's Style picker menu: the style tiles; clicking one applies it to the selection. +pub(crate) fn picker(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let looks = look_hashes(ui.ctx(), app); + let shown = Shown { d: &doc, looks: &looks, preview: pstate(ui.ctx(), "gs-preview") }; + let mut ev = Events::default(); + ui.set_max_width(6.0 * (TILE + 3.0)); + tiles(app, ui, &shown, &[], &mut ev); + act(app, ui, &shown, ev, true); +} + /// A new style from the selection, first asking for its name (`ask`) in Graphic Style Options. fn new_style(app: &mut VectorcraftApp, ask: bool) { if ask { @@ -156,17 +500,25 @@ fn new_style(app: &mut VectorcraftApp, ask: bool) { } } -fn delete(app: &mut VectorcraftApp, ctx: &egui::Context, sel: &[String]) { - if app.run("graphicStyle.delete", json!({ "names": sel })).is_ok() { - set_pstate(ctx, "gs-sel", Vec::::new()); +/// Delete `names` after asking, or at once with `now` (Alt-click). +fn delete(app: &mut VectorcraftApp, names: &[String], now: bool) { + let params = json!({ "names": names }); + if now { + app.run("graphicStyle.delete", params).ok(); + return; } + let message = match names { + [n] => format!("Delete the graphic style \u{201c}{n}\u{201d}?"), + _ => format!("Delete these {} graphic styles?", names.len()), + }; + crate::dialogs::confirm::ask(app, &message, "Objects using them keep their look but are no longer linked.", "graphicStyle.delete", params); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let sel = selected(ui.ctx()); + let doc = app.session.active().map(|d| d.doc.clone()); + let sel = doc.as_ref().map(|d| selected(ui.ctx(), d)).unwrap_or_default(); let one = (sel.len() == 1).then(|| sel[0].clone()); - let has_doc = app.session.active().is_some(); - let list: bool = pstate(ui.ctx(), "gs-list"); + let has_doc = doc.is_some(); if menu_item(ui, "New Graphic Style…", selection_len(app) > 0, false) { new_style(app, true); } @@ -175,9 +527,16 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { { app.run("graphicStyle.duplicate", json!({ "name": n })).ok(); } - menu_item(ui, "Merge Graphic Styles", false, false); + if menu_item(ui, "Merge Graphic Styles", sel.len() > 1, false) + && let Some(d) = &doc + { + // Merged in panel order. + let mut names = sel.clone(); + names.sort_by_key(|n| d.graphic_style_index(n)); + run(app, "ui.mergeGraphicStyles", json!({ "names": names })); + } if menu_item(ui, "Delete Graphic Style", !sel.is_empty(), false) { - delete(app, ui.ctx(), &sel); + delete(app, &sel, false); } if menu_item(ui, "Break Link to Graphic Style", app.session.selection_graphic_style().is_some(), false) { app.run("graphicStyle.breakLink", json!({})).ok(); @@ -193,11 +552,22 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("graphicStyle.sortByName", json!({})).ok(); } ui.separator(); - if menu_item(ui, "Thumbnail View", true, !list) { - set_pstate(ui.ctx(), "gs-list", false); + let view: View = pstate(ui.ctx(), "gs-view"); + for (v, label) in View::ALL { + if menu_item(ui, label, true, view == v) { + set_pstate(ui.ctx(), "gs-view", v); + } } - if menu_item(ui, "Large List View", true, list) { - set_pstate(ui.ctx(), "gs-list", true); + ui.separator(); + let preview: Preview = pstate(ui.ctx(), "gs-preview"); + for (p, label) in [(Preview::Square, "Use Square for Previews"), (Preview::Text, "Use Text for Previews")] { + if menu_item(ui, label, true, preview == p) { + set_pstate(ui.ctx(), "gs-preview", p); + } + } + let over = app.session.prefs.override_char_color; + if menu_item(ui, "Override Character Color", true, over) { + app.run("graphicStyle.setOptions", json!({ "overrideCharColor": !over })).ok(); } ui.separator(); if menu_item(ui, "Graphic Style Options…", one.is_some(), false) @@ -213,8 +583,8 @@ mod tests { use super::*; use vectorcraft_engine::Session; - /// The texts `draw` paints in one headless frame. - fn frame(ctx: &egui::Context, draw: impl FnMut(&mut Ui)) -> Vec { + /// The texts `draw` paints in one headless frame with `events`. + fn frame_with(ctx: &egui::Context, events: Vec, draw: impl FnMut(&mut Ui)) -> Vec { fn texts(s: &egui::Shape, out: &mut Vec) { match s { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), @@ -222,21 +592,35 @@ mod tests { _ => {} } } - let mut out = ctx.run_ui(egui::RawInput::default(), draw); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, draw); out.textures_delta.clear(); let mut v = vec![]; out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); v } - #[test] - fn list_view_names_the_styles_and_deletes_the_selected_ones() { + fn frame(ctx: &egui::Context, draw: impl FnMut(&mut Ui)) -> Vec { + frame_with(ctx, vec![], draw) + } + + fn app() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app + } + + fn names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect() + } + + #[test] + fn list_view_names_the_styles_and_deletes_the_selected_ones_after_asking() { + let mut app = app(); app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap(); let ctx = egui::Context::default(); - set_pstate(&ctx, "gs-list", true); + set_pstate(&ctx, "gs-view", View::LargeList); let texts = frame(&ctx, |ui| { show(&mut app, ui); menu(&mut app, ui); @@ -244,15 +628,122 @@ mod tests { // The Appearance panel names the style the object is linked to. let shown = frame(&ctx, |ui| super::super::appearance::show(&mut app, ui)); assert!(shown.iter().any(|t| t == "Rectangle: Mine"), "{shown:?}"); - assert!(texts.iter().any(|t| t == "Mine") && texts.iter().any(|t| t == "Sunshine"), "{texts:?}"); + for t in ["Mine", "Sunshine", "Small List View", "Use Text for Previews", "Override Character Color"] { + assert!(texts.iter().any(|x| x.ends_with(t)), "{t}: {texts:?}"); + } // Select All Unused picks every style but the one the rectangle is linked to. let unused: Vec = serde_json::from_value(app.session.execute("graphicStyle.unused", &json!({})).unwrap()["names"].clone()).unwrap(); assert_eq!(unused.len(), 4); set_pstate(&ctx, "gs-sel", unused.clone()); + set_pstate(&ctx, "gs-view", View::SmallList); frame(&ctx, |ui| show(&mut app, ui)); - delete(&mut app, &ctx, &unused); - assert!(selected(&ctx).is_empty()); - let names: Vec<&str> = app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.as_str()).collect(); - assert_eq!(names, ["Mine"]); + // Delete asks first; OK deletes, and the panel selection follows. + delete(&mut app, &unused, false); + assert_eq!(names(&app).len(), 5); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("message"), "Delete these 4 graphic styles?"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&app), ["Mine"]); + assert!(selected(&ctx, &app.session.active().unwrap().doc.clone()).is_empty()); + // Alt-click deletes at once. + delete(&mut app, &["Mine".to_string()], true); + assert!(names(&app).is_empty() && app.ui.dialog.is_none()); + } + + #[test] + fn thumbnails_are_cached_by_look_and_rerendered_when_a_style_changes() { + let mut app = app(); + let ctx = egui::Context::default(); + let tex = |app: &VectorcraftApp, i: usize, preview: Preview| { + let d = app.session.active().unwrap().doc.clone(); + let looks = look_hashes(&ctx, app); + thumb(app, &ctx, &d, &d.graphic_styles[i], looks[i], preview, TILE).unwrap().id() + }; + app.session.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + app.session.execute("graphicStyle.new", &json!({"name": "Red"})).unwrap(); + let i = app.session.active().unwrap().doc.graphic_style_index("Red").unwrap(); + let (red, first) = (tex(&app, i, Preview::Square), tex(&app, 0, Preview::Square)); + // Unchanged styles keep their texture, also across document edits. + app.session.execute("shape.ellipse", &json!({"x": 60, "y": 60, "width": 20, "height": 20})).unwrap(); + assert_eq!(tex(&app, i, Preview::Square), red); + assert_ne!(tex(&app, i, Preview::Text), red, "each preview shape has its own"); + // Redefining the style changes its look: a new thumbnail; the others are kept. + let rect = app.session.active().unwrap().doc.layers[0].children().unwrap()[0].id; + app.session.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [rect.0]})).unwrap(); + app.session.execute("graphicStyle.redefine", &json!({"name": "Red", "id": rect.0})).unwrap(); + assert_ne!(tex(&app, i, Preview::Square), red); + assert_eq!(tex(&app, 0, Preview::Square), first); + // The rendered thumbnail shows the style on the square, on white. + let d = app.session.active().unwrap().doc.clone(); + let img = render(&app, &d, &d.graphic_styles[i], Preview::Square, 40).unwrap(); + let px = |x: usize, y: usize| img.pixels[y * img.size[0] + x]; + assert_eq!(px(20, 20), Color32::from_rgb(0, 0, 255)); + assert_eq!(px(2, 2), Color32::WHITE); + } + + #[test] + fn merge_asks_for_a_name_and_drops_move_styles_or_make_new_ones() { + let mut app = app(); + let ctx = egui::Context::default(); + let n0 = names(&app); + // Merge Graphic Styles names the new style in Graphic Style Options. + app.run("ui.mergeGraphicStyles", json!({"names": [n0[1], n0[2]]})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Both")); + crate::dialogs::confirm(&mut app).unwrap(); + let d = app.session.active().unwrap().doc.clone(); + let (a, b, both) = (d.graphic_style(&n0[1]).unwrap(), d.graphic_style(&n0[2]).unwrap(), d.graphic_style("Both").unwrap()); + assert_eq!(both.appearance.items.len(), a.appearance.items.len() + b.appearance.items.len()); + assert!(app.run("ui.mergeGraphicStyles", json!({"names": [n0[1]]})).is_err()); + + // A style dragged onto the top half of the first row lands first (Small List View: rows + // of 18 points from the top of the list). + set_pstate(&ctx, "gs-view", View::SmallList); + frame(&ctx, |ui| show(&mut app, ui)); + let release = |at: egui::Pos2| { + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + vec![egui::Event::PointerMoved(at), up] + }; + egui::DragAndDrop::set_payload(&ctx, PanelDrag::GraphicStyle("Both".into())); + frame_with(&ctx, release(pos2(60.0, 6.0)), |ui| show(&mut app, ui)); + assert_eq!(names(&app)[0], "Both"); + // Art dragged off the canvas onto the panel becomes a new style. + let rect = app.session.active().unwrap().doc.layers[0].children().unwrap()[0].id; + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![rect])); + frame_with(&ctx, release(pos2(60.0, 116.0)), |ui| show(&mut app, ui)); + let d = app.session.active().unwrap().doc.clone(); + assert_eq!(d.graphic_styles.len(), n0.len() + 2); + assert_eq!(d.node(rect).unwrap().graphic_style, d.graphic_styles.last().map(|g| g.id)); + } + + #[test] + fn the_right_button_shows_a_large_preview_and_the_picker_applies_styles() { + let mut app = app(); + let ctx = egui::Context::default(); + let first = names(&app)[0].clone(); + let button = |button: egui::PointerButton, pressed: bool| { + let at = pos2(20.0, 20.0); + vec![egui::Event::PointerMoved(at), egui::Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }] + }; + frame(&ctx, |ui| show(&mut app, ui)); + // Held on the first tile: the large preview (on the selected rectangle) names the style + // (a new area shows from its second frame). + frame_with(&ctx, button(egui::PointerButton::Secondary, true), |ui| show(&mut app, ui)); + let texts = frame(&ctx, |ui| show(&mut app, ui)); + assert!(texts.contains(&first), "{texts:?}"); + let texts = frame_with(&ctx, button(egui::PointerButton::Secondary, false), |ui| show(&mut app, ui)); + assert!(!texts.contains(&first)); + // The Control bar's picker: a click on a tile applies that style to the selection. + let name = names(&app)[2].clone(); + let at = pos2(2.0 * (TILE + 3.0) + 20.0, 20.0); + let click = |pressed: bool| { + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ] + }; + let ctx = egui::Context::default(); + frame(&ctx, |ui| picker(&mut app, ui)); + frame_with(&ctx, click(true), |ui| picker(&mut app, ui)); + frame_with(&ctx, click(false), |ui| picker(&mut app, ui)); + assert_eq!(app.session.selection_graphic_style().map(|(g, linked)| (g.name.clone(), linked)), Some((name, true))); } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 12f6fb42..e1f09163 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -355,7 +355,7 @@ impl Drop { 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::Appearance(_) => None, + _ => None, } } } @@ -532,23 +532,27 @@ fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { } /// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, -/// a Fill/Stroke proxy, the Gradient panel's thumbnail), or the fill of the object whose -/// appearance the Appearance panel's thumbnail carries. +/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance +/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the +/// thumbnail of a dragged graphic style. pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; + let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); let (paint, registration) = match &*d { // Colour groups paint nothing. PanelDrag::Paint { params, .. } if params.is_null() => return, PanelDrag::Paint { paint, rows, .. } => (std::borrow::Cow::Borrowed(paint), rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION)), - PanelDrag::Appearance(id) => match app.session.active().and_then(|st| st.doc.node(*id)) { - Some(n) => (std::borrow::Cow::Owned(n.appearance.fill_paint()), false), + PanelDrag::Appearance(id) => match fill_of(id) { + Some(p) => (std::borrow::Cow::Owned(p), false), None => return, }, + PanelDrag::Art(ids) => match ids.first().and_then(fill_of) { + Some(p) => (std::borrow::Cow::Owned(p), false), + None => return, + }, + PanelDrag::GraphicStyle(name) => return pointer_chip(ctx, |ui, r| super::graphic_styles::paint_style(app, ui, r, name)), }; - let Some(at) = ctx.pointer_hover_pos() else { return }; - let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); - area.interactable(false).show(ctx, |ui| { - let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + pointer_chip(ctx, |ui, r| { if registration { draw_registration(ui, r); } else { @@ -558,6 +562,16 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { }); } +/// A 16-point chip `draw` paints, following the pointer. +fn pointer_chip(ctx: &egui::Context, draw: impl FnOnce(&Ui, Rect)) { + let Some(at) = ctx.pointer_hover_pos() else { return }; + let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); + area.interactable(false).show(ctx, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + draw(ui, r); + }); +} + /// The id of the find field's text (shown with the panel menu's Show Find Field). fn find_id() -> egui::Id { egui::Id::new("swatch-find") diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 34b93f1f..9a429e55 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -957,8 +957,14 @@ pub enum PanelDrag { /// a swatch of a paint dragged from elsewhere. Paint { paint: Paint, params: serde_json::Value, rows: Option }, /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance - /// (`appearance.copyFrom`). + /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. Appearance(vectorcraft_doc::NodeId), + /// A Graphic Styles panel style: art it is dropped on takes it (`graphicStyle.apply` with its + /// `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`). + Art(Vec), } /// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour @@ -988,7 +994,7 @@ impl PanelDrag { pub fn color(&self) -> Option { match self { Self::Paint { paint, .. } => paint.color(), - Self::Appearance(_) => None, + _ => None, } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e7aff157..50c11c3c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -41,6 +41,8 @@ The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", pa Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and `ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the dialog open); without `name` it names a new style made from the selection (`graphicStyle.new`). +`ui.mergeGraphicStyles {names}` opens the same dialog to name the style `ui.dialog.confirm` merges from them +(`graphicStyle.merge`). Deleting styles from the panel asks first with a `confirm` dialog (`graphicStyle.delete`). Gradient panel: double-clicking a stop on the panel's slider opens the same `gradientStop` popover, with a `screen` field (`[x, y]`, screen points) in place of `x`/`y`. The panel's stop eyedropper selects the Eyedropper tool with the diff --git a/docs/mcp.md b/docs/mcp.md index 2c9ccc7f..f2aebe12 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -261,6 +261,12 @@ style is applied to gets them at the same place relative to its own bounds. {"name":"run_command","arguments":{"command":"graphicStyle.list","params":{}}} ``` +`graphicStyle.merge {names, name?}` adds a style stacking the fills, strokes and effects of two or more styles +(each on top of the ones before it) with the first one's transparency; `graphicStyle.move {name, to}` reorders the +list (what dragging a style in the panel does). Override Character Color (`graphicStyle.setOptions +{overrideCharColor}`, the preference `overrideCharColor`, on by default) makes a style applied to type replace its +characters' fill and stroke with the style's fills and strokes; off, the characters keep their colour under them. + ## Editing appearance stacks `effect.move {from, to, fromItem?, toItem?, copy?}` reorders an effect or moves it between the object's effects