diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index d151462a..cc89787e 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -182,6 +182,10 @@ pub struct PatternDef { pub dim_copies: f32, #[serde(default = "yes")] pub show_tile_edge: bool, + /// Show Swatch Bounds: pattern editing mode outlines the part of the tiling the swatch repeats + /// (the tile, or the period of a brick or hex tiling), dashed. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub show_swatch_bounds: bool, } impl PatternDef { @@ -200,6 +204,7 @@ impl PatternDef { copies: 5, dim_copies: 70.0, show_tile_edge: true, + show_swatch_bounds: false, } } pub fn width(&self) -> f64 { @@ -226,6 +231,11 @@ impl PatternDef { pub fn period(&self) -> (f64, f64) { tile_period(self.tile_type, self.width(), self.height()) } + /// The swatch bounds (Show Swatch Bounds): the period of the tiling from the tile's origin. + pub fn swatch_bounds(&self) -> Rect { + let (w, h) = self.period(); + Rect::from_origin_size(self.tile.origin(), (w, h)) + } /// Maps the art's coordinates into pattern space for instance offset `o`. pub fn instance_xf(&self, o: Vec2) -> Affine { Affine::translate(o - self.tile.origin().to_vec2()) diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 7da22bf9..17fd8f27 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { "Pattern Options", ["Window", "Pattern Options"], None, - "{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool}", + "{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}", has_doc, pattern_options ), @@ -457,6 +457,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("showTileEdge").and_then(Value::as_bool) { def.show_tile_edge = v; } + if let Some(v) = p.get("showSwatchBounds").and_then(Value::as_bool) { + def.show_swatch_bounds = v; + } if def.size_tile_to_art { let saved = std::mem::take(&mut def.art); def.art = live_art.clone().unwrap_or_else(|| saved.clone()); @@ -574,6 +577,7 @@ fn pattern_list(s: &mut Session, _: &Value) -> Result { "copies": p.copies, "dimCopies": p.dim_copies, "showTileEdge": p.show_tile_edge, + "showSwatchBounds": p.show_swatch_bounds, "artCount": p.art.len(), }) }) diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 4b65b3f3..d9d49afe 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -290,6 +290,10 @@ 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), + // Object → Pattern → Tile Edge Color + p!("patternTileEdgeColor", "Guides & Grid", "Pattern Editing", "Tile Edge Color", color), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { @@ -375,6 +379,7 @@ impl Session { pub fn apply_prefs(&mut self, p: Prefs) { let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions; let history_changed = p.history_states != self.prefs.history_states; + let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color; self.prefs = p; vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok()); for st in &mut self.docs { @@ -383,6 +388,10 @@ impl Session { let over = st.history.undo.len().saturating_sub(st.history.limit); st.history.undo.drain(..over); } + // Documents in pattern editing mode redraw their tile edge. + if tile_edge_changed && st.doc.pattern_edit.is_some() { + st.revision += 1; + } if grid_changed { let d = std::sync::Arc::make_mut(&mut st.doc); d.grid.spacing = self.prefs.gridline_every; 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..cda8edf8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -306,6 +306,14 @@ 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, + // Pattern editing + /// Object → Pattern → Tile Edge Color (`#rrggbb`): the tile edge and swatch bounds in pattern + /// editing mode. + pub pattern_tile_edge_color: String, } impl Default for Prefs { @@ -418,6 +426,11 @@ impl Default for Prefs { black_output: s("accurate"), touch_workspace: true, touch_gestures: true, + override_char_color: true, + pattern_tile_edge_color: { + let [r, g, b] = vectorcraft_doc::LAYER_COLORS[0].1; + Color::rgb8(r, g, b).to_hex() + }, } } } @@ -811,6 +824,8 @@ mod tests_strokegeom; #[cfg(test)] mod tests_strokeux; #[cfg(test)] +mod tests_stylepanel; +#[cfg(test)] mod tests_styles; #[cfg(test)] mod tests_swatchcmds; @@ -821,4 +836,6 @@ mod tests_swatchlib; #[cfg(test)] mod tests_textedit; #[cfg(test)] +mod tests_tileedge; +#[cfg(test)] mod tests_xform; 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/engine/src/tests_tileedge.rs b/crates/engine/src/tests_tileedge.rs new file mode 100644 index 00000000..c6865052 --- /dev/null +++ b/crates/engine/src/tests_tileedge.rs @@ -0,0 +1,63 @@ +//! Pattern editing: the Tile Edge Color preference and Pattern Options' Show Swatch Bounds. + +use serde_json::{Value, json}; +use vectorcraft_doc::pattern::PatternDef; + +use super::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +/// A session editing pattern "Dots" made from a 10×10 rectangle. +fn editing() -> Session { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + s +} + +fn def(s: &Session) -> PatternDef { + s.doc().unwrap().doc.pattern("Dots").unwrap().clone() +} + +#[test] +fn show_swatch_bounds_is_a_pattern_option_that_saves() { + let mut s = editing(); + assert!(!def(&s).show_swatch_bounds); + run(&mut s, "pattern.options", json!({"showSwatchBounds": true})); + assert!(def(&s).show_swatch_bounds); + assert_eq!(run(&mut s, "pattern.list", json!({}))["patterns"][0]["showSwatchBounds"], true); + // One undo step. + run(&mut s, "edit.undo", json!({})); + assert!(!def(&s).show_swatch_bounds); + run(&mut s, "edit.redo", json!({})); + // Saved with the document; files from before the option load with it off. + let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(back.patterns, s.doc().unwrap().doc.patterns); + assert!(back.pattern("Dots").unwrap().show_swatch_bounds); + let mut old = serde_json::to_value(def(&s)).unwrap(); + old.as_object_mut().unwrap().remove("showSwatchBounds"); + assert!(!serde_json::from_value::(old).unwrap().show_swatch_bounds); +} + +#[test] +fn tile_edge_color_is_a_preference_that_redraws_pattern_editing() { + let mut s = editing(); + let default = run(&mut s, "prefs.get", json!({"key": "patternTileEdgeColor"})); + let [r, g, b] = vectorcraft_doc::LAYER_COLORS[0].1; + assert_eq!(default, vectorcraft_color::Color::rgb8(r, g, b).to_hex()); + let rev = s.doc().unwrap().revision; + run(&mut s, "prefs.set", json!({"key": "patternTileEdgeColor", "value": "FF0000"})); + assert_eq!(s.prefs.pattern_tile_edge_color, "#ff0000"); + assert!(s.doc().unwrap().revision > rev, "the canvas redraws the tile edge"); + assert!(s.execute("prefs.set", &json!({"key": "patternTileEdgeColor", "value": "red"})).is_err()); + // Saved with the preferences; older preferences get the default. + let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.pattern_tile_edge_color, "#ff0000"); + let mut old = s.prefs.to_json(); + old.as_object_mut().unwrap().remove("patternTileEdgeColor"); + assert_eq!(json!(serde_json::from_value::(old).unwrap().pattern_tile_edge_color), default); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f95b0334..07a06156 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -79,6 +79,9 @@ pub struct RenderOptions { pub trim: bool, /// Leave template layers out (exports and thumbnails: templates are guides, not artwork). pub skip_templates: bool, + /// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge + /// Color), RGB. + pub tile_edge: [u8; 3], } impl Default for RenderOptions { @@ -93,6 +96,7 @@ impl Default for RenderOptions { overprint_preview: false, trim: false, skip_templates: false, + tile_edge: vectorcraft_doc::LAYER_COLORS[0].1, } } } @@ -1170,3 +1174,5 @@ mod tests_isolation; mod tests_knockout; #[cfg(test)] mod tests_objectfx; +#[cfg(test)] +mod tests_tileedge; diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 8ca8fc09..23a729e7 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -194,9 +194,17 @@ impl Renderer { }); } self.draw_arc(ctx, f, layer); + let [r, g, b] = f.opts.tile_edge; if def.show_tile_edge { - let bp = def.tile.to_path(0.1); - self.hairline(ctx, f, &bp, [0x2d, 0x8c, 0xeb, 255]); + self.hairline(ctx, f, &def.tile.to_path(0.1), [r, g, b, 255]); + } + if def.show_swatch_bounds { + let mut bounds = def.swatch_bounds().to_path(0.1); + bounds.apply_affine(f.view); + ctx.set_transform(Affine::IDENTITY); + ctx.set_stroke(vello_cpu::kurbo::Stroke::new(1.0).with_dashes(0.0, [4.0, 3.0])); + ctx.set_paint(peniko::Color::from_rgba8(r, g, b, 255)); + ctx.stroke_path(&bounds); } true } diff --git a/crates/render/src/tests_tileedge.rs b/crates/render/src/tests_tileedge.rs new file mode 100644 index 00000000..3657f0b3 --- /dev/null +++ b/crates/render/src/tests_tileedge.rs @@ -0,0 +1,69 @@ +//! Pattern editing mode: the tile edge in the Tile Edge Color, and Show Swatch Bounds. + +use super::*; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::pattern::{PatternDef, PatternEdit, TileType}; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::shapes; + +/// A document editing a 20×20 pattern tile of type `tt` (a red 10×10 square in its corner, no +/// preview copies), its tile edge and swatch bounds shown as asked. +fn editing(tt: TileType, edge: bool, bounds: bool) -> Document { + let mut d = Document::new(200.0, 200.0); + let id = d.alloc_id(); + let red = Node::path( + id, + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + let mut def = PatternDef::new("Checks", vec![Arc::new(red.clone())]); + def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + (def.tile_type, def.copies, def.show_tile_edge, def.show_swatch_bounds) = (tt, 1, edge, bounds); + d.patterns.push(def); + let layer = d.add_layer(Some("Pattern Editing Mode")); + d.insert(Some(layer), 0, red).unwrap(); + d.pattern_edit = Some(PatternEdit { pattern: "Checks".into(), layer, original: None }); + d +} + +/// `d` at 1:1 with the tile's corner at the centre of pixel (50, 50), on white. +fn render(d: &Document, tile_edge: Option<[u8; 3]>) -> Rendered { + let opts = RenderOptions { background: Some([255; 4]), tile_edge: tile_edge.unwrap_or(RenderOptions::default().tile_edge), ..Default::default() }; + Renderer::new().render(d, 120, 120, Affine::translate((50.5, 50.5)), &opts) +} + +fn is_white(p: [u8; 4]) -> bool { + p[0] > 245 && p[1] > 245 && p[2] > 245 +} + +#[test] +fn the_tile_edge_takes_the_tile_edge_color() { + let green = render(&editing(TileType::Grid, true, false), Some([0, 200, 0])); + // The left edge below the red square, and the bottom edge. + for (x, y) in [(50, 65), (60, 70), (70, 55)] { + let p = green.pixel(x, y); + assert!(p[1] > 150 && p[0] < 100 && p[2] < 100, "({x},{y}) {p:?}"); + } + assert!(is_white(green.pixel(65, 65)), "inside the tile"); + // The default is the first layer colour (light blue). + let [r, g, b] = RenderOptions::default().tile_edge; + let p = render(&editing(TileType::Grid, true, false), None).pixel(50, 65); + assert!(p[0].abs_diff(r) < 30 && p[1].abs_diff(g) < 30 && p[2].abs_diff(b) < 30, "{p:?}"); + // Hidden: nothing there. + assert!(is_white(render(&editing(TileType::Grid, false, false), Some([0, 200, 0])).pixel(50, 65))); +} + +#[test] +fn swatch_bounds_outline_the_period_dashed() { + // Brick by row repeats every two rows: the swatch is 20×40 from the tile's corner. + let tt = TileType::BrickByRow { offset: 0.5 }; + let on = render(&editing(tt, false, true), Some([0, 200, 0])); + let green = |p: [u8; 4]| p[1] > 150 && p[0] < 100 && p[2] < 100; + let bottom: Vec = (51..70).map(|x| green(on.pixel(x, 90))).collect(); + assert!(bottom.iter().any(|g| *g) && bottom.iter().any(|g| !*g), "dashed: {bottom:?}"); + assert!((51..90).any(|y| green(on.pixel(70, y))), "right side"); + assert!(!(51..70).any(|x| green(on.pixel(x, 70))), "the tile edge itself stays hidden"); + // Off: no outline. + let off = render(&editing(tt, false, false), Some([0, 200, 0])); + assert!((51..70).all(|x| is_white(off.pixel(x, 90)))); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7bb4c3d2..779f821a 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 { @@ -208,6 +213,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { proof: vectorcraft_render::proof::active_proof(), overprint_preview: vectorcraft_render::proof::overprint_preview_on(), trim: app.ui.view.trim_view, + tile_edge: vectorcraft_color::Color::from_hex(&app.session.prefs.pattern_tile_edge_color).map_or(opts.tile_edge, |c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }), ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. @@ -427,7 +436,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 +466,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 +497,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 +712,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 +1238,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/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 6a41958b..c53552a6 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -30,6 +30,7 @@ mod save_changes; pub mod save_swatch_library; mod shapes; pub mod swatch_options; +pub mod tile_edge_color; mod tools; mod transform; @@ -152,6 +153,7 @@ registry! { ColorBalance: [color_balance::KIND] => color_balance::SPEC, Saturate: [saturate::KIND] => saturate::SPEC, SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC, + TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/tile_edge_color.rs b/crates/ui-egui/src/dialogs/tile_edge_color.rs new file mode 100644 index 00000000..3efcdfa2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tile_edge_color.rs @@ -0,0 +1,93 @@ +//! Object → Pattern → Tile Edge Color: the colour pattern editing mode draws the tile edge and the +//! swatch bounds in, a preset (the layer colours) or a custom colour. OK sets the preference +//! `patternTileEdgeColor` (`prefs.set`). +//! +//! Fields: `color` (`#rrggbb`, or a preset's name such as "Light Blue"). + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::LAYER_COLORS; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Tile Edge Color. +pub const KIND: &str = "tileEdgeColor"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Tile Edge Color".into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM }; + +/// Open Tile Edge Color on the current preference. +pub fn open(app: &mut VectorcraftApp) -> Result { + app.ui.dialog = Some(Dialog::new(KIND, json!({ "color": app.session.prefs.pattern_tile_edge_color }))); + Ok(Value::Null) +} + +/// `color` (a preset's name or a hex colour) as a colour. +fn resolve(color: &str) -> Option { + match LAYER_COLORS.iter().find(|(name, _)| name.eq_ignore_ascii_case(color.trim())) { + Some((_, [r, g, b])) => Some(Color::rgb8(*r, *g, *b)), + None => Color::from_hex(color), + } +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let color = resolve(&d.str("color")).unwrap_or(Color::BLACK); + let [r, g, b, _] = color.to_rgba8(1.0); + let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]); + let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect(); + grid(ui, |ui| { + label(ui, "Color:"); + ui.horizontal(|ui| { + if let Some((_, [r, g, b])) = + widgets::dropdown(ui, "tile-edge", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 140.0).and_then(|i| LAYER_COLORS.get(i)) + { + d.fields.insert("color".into(), json!(Color::rgb8(*r, *g, *b).to_hex())); + } + // Any other colour (the preset list shows Custom then). + let mut rgb = [r, g, b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert("color".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex())); + } + }); + ui.end_row(); + }); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let color = d.str("color"); + let hex = resolve(&color).ok_or_else(|| format!("`{color}` is not a colour (#rrggbb or a preset name)"))?.to_hex(); + run_and_close(app, "prefs.set", json!({ "key": "patternTileEdgeColor", "value": hex })) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn presets_or_custom_colours_set_the_preference() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("ui.tileEdgeColor", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("color"), app.session.prefs.pattern_tile_edge_color); + // Drawn headlessly in the shared frame: the preset's name shows. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let set = |app: &mut VectorcraftApp, v: &str| { + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!(v)); + super::super::confirm(app) + }; + set(&mut app, "Red").unwrap(); + assert_eq!(app.session.prefs.pattern_tile_edge_color, "#ff4f4f"); + assert!(app.ui.dialog.is_none()); + app.run("ui.tileEdgeColor", json!({})).unwrap(); + set(&mut app, "#00FF00").unwrap(); + assert_eq!(app.session.prefs.pattern_tile_edge_color, "#00ff00"); + app.run("ui.tileEdgeColor", json!({})).unwrap(); + assert!(set(&mut app, "nope").is_err()); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4fd22900..6802cfe3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -247,6 +247,18 @@ 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)", + ), + ( + "ui.tileEdgeColor", + "Tile Edge Color…", + "", + "{} open Tile Edge Color (dialog `tileEdgeColor`, field `color`: #rrggbb or a preset name such as \"Light Blue\"); OK sets the preference patternTileEdgeColor (prefs.set), the colour of the tile edge and swatch bounds in pattern editing mode", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -590,6 +602,11 @@ 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()) + } + "ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app), _ => return None, }; Some(r) @@ -986,7 +1003,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ], ), sub("Shape", vec![c("Convert to Shape", "object.shape.convertToShape"), c("Expand Shape", "object.expandShape")]), - sub("Pattern", vec![c("Make", "object.pattern.make"), c("Edit Pattern", "object.pattern.edit"), todo("Tile Edge Color…")]), + sub( + "Pattern", + vec![c("Make", "object.pattern.make"), c("Edit Pattern", "object.pattern.edit"), c("Tile Edge Color…", "ui.tileEdgeColor")], + ), sub( "Repeat", vec![ 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/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs index 2abb93da..5df83a37 100644 --- a/crates/ui-egui/src/panels/pattern_options.rs +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -1,6 +1,6 @@ //! Pattern Options panel: active in pattern editing mode (Object → Pattern → Make / Edit //! Pattern). Name, Tile Type, Brick Offset, Width/Height, Size Tile to Art with H/V spacing, -//! Overlap, Copies, Dim Copies, Show Tile Edge; Save a Copy / Done / Cancel. Outside editing +//! Overlap, Copies, Dim Copies, Show Tile Edge, Show Swatch Bounds; Save a Copy / Done / Cancel. Outside editing //! mode it lists the document's patterns with an Edit button. use egui::Ui; @@ -140,6 +140,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, "Show Tile Edge", def.show_tile_edge, true) { set(app, &name, json!({"showTileEdge": !def.show_tile_edge})); } + if widgets::check(ui, "Show Swatch Bounds", def.show_swatch_bounds, true) { + set(app, &name, json!({"showSwatchBounds": !def.show_swatch_bounds})); + } widgets::divider(ui); ui.horizontal(|ui| { if widgets::flat_button(ui, "Save a Copy", 90.0).clicked() @@ -214,3 +217,38 @@ pub fn repeat_fields(app: &VectorcraftApp) -> Option { RepeatKind::Mirror { angle, .. } => json!({"angle": angle}), }) } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + /// One headless frame of the panel with `events`: where each text was drawn. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + for c in &out.shapes { + if let egui::Shape::Text(t) = &c.shape { + v.push((t.galley.text().to_string(), t.visual_bounding_rect())); + } + } + v + } + + #[test] + fn show_swatch_bounds_toggles_the_pattern_option() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("object.pattern.make", json!({"name": "Dots"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame(&mut app, &ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == "Show Swatch Bounds").expect("the checkbox").1.center(); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + assert!(app.session.active().unwrap().doc.pattern("Dots").unwrap().show_swatch_bounds); + } +} 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..5c616691 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 @@ -88,3 +90,7 @@ Add to Swatches. opening a `.vcswatches` or `.gpl` file with `app.open` does the same. `ui.saveSwatchLibrary {names?}` opens the `saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`css`, `user`: save to the user library folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path). + +Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`) opens the `tileEdgeColor` dialog (field +`color`: `#rrggbb` or a preset name such as "Light Blue"); `ui.dialog.confirm` sets the preference +`patternTileEdgeColor` (`prefs.set`), the colour pattern editing mode draws the tile edge and the swatch bounds in. diff --git a/docs/mcp.md b/docs/mcp.md index 2c9ccc7f..09cdeaf9 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 @@ -365,4 +371,14 @@ that split into many regions. The result reports `{ids, vector, rasterized, opti ```json {"name":"run_command","arguments":{"command":"object.flattenTransparency","params":{"ids":[12,15],"preset":"high"}}} {"name":"run_command","arguments":{"command":"object.flattenTransparency","params":{"balance":0,"lineArtPpi":150}}} + +## Pattern editing display + +In pattern editing mode the tile edge (`pattern.options {showTileEdge}`) and the swatch bounds (`pattern.options +{showSwatchBounds}`: the part of the tiling the swatch repeats, dashed) are drawn in the preference +`patternTileEdgeColor`. + +```json +{"name":"run_command","arguments":{"command":"pattern.options","params":{"showSwatchBounds":true}}} +{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}} ```