Merge pull request #38 from storytold/pr/m3-graphic-styles-panel

M3: Graphic Styles panel depth, Tile Edge Color, Show Swatch Bounds (M3.71, M3.72)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 01:59:14 +07:00
commit 8dc6396971
22 files changed
+1414 -147

No files matched your search

+10
View File
@@ -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())
+5 -1
View File
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Value> {
"copies": p.copies,
"dimCopies": p.dim_copies,
"showTileEdge": p.show_tile_edge,
"showSwatchBounds": p.show_swatch_bounds,
"artCount": p.art.len(),
})
})
+9
View File
@@ -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;
+127 -8
View File
@@ -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<CommandSpec> {
"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<CommandSpec> {
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<Value> {
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<Value> {
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<Value> {
})
.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<Value> {
@@ -377,6 +442,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
};
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<Value> {
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<Value> {
})?;
ok()
}
fn style_merge(s: &mut Session, p: &Value) -> Result<Value> {
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::<Result<Vec<_>>>()?;
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<Value> {
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<Value> {
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 }))
}
+17
View File
@@ -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;
+144
View File
@@ -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<String> {
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::<Vec<_>>(),
_ => 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::<Prefs>(old).unwrap().override_char_color);
}
+63
View File
@@ -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::<PatternDef>(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::<Prefs>(old).unwrap().pattern_tile_edge_color), default);
}
+6
View File
@@ -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;
+10 -2
View File
@@ -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
}
+69
View File
@@ -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<bool> = (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))));
}
+110 -6
View File
@@ -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::<String>(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<vectorcraft_doc::
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries
/// (`appearance.copyFrom`). A chip follows the pointer meanwhile.
/// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on
/// top of its appearance). A chip follows the pointer meanwhile.
fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
crate::panels::swatches::drag_preview(app, ui.ctx());
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
@@ -707,6 +742,12 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
}
("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]}))
}
widgets::PanelDrag::GraphicStyle(name) => {
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::<widgets::PanelDrag>(&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);
}
}
+18 -8
View File
@@ -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() {
@@ -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<Value, Strin
Ok(Value::Null)
}
/// Open Graphic Style Options to name the style Merge Graphic Styles makes of `names`.
pub fn open_merge(app: &mut VectorcraftApp, names: Vec<String>) -> Result<Value, String> {
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<Value, String> {
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 })),
+2
View File
@@ -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).
@@ -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<Value, String> {
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<Color> {
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<Value, String> {
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());
}
}
+21 -1
View File
@@ -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<R
Some(l) => 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<Item>)> {
],
),
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![
+597 -106
View File
@@ -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<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
/// Thumbnails by (look hash, pixels, preview shape, Override Character Color on type).
static THUMBS: RefCell<HashMap<(u64, u32, Preview, bool), TextureHandle>> = 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<Vec<u64>> {
let Some(st) = app.session.active() else { return Arc::default() };
let key = (st.uid, st.revision);
match pstate::<Option<((u64, u64), Arc<Vec<u64>>)>>(ctx, "gs-looks") {
Some((k, looks)) if k == key => looks,
_ => {
let looks: Arc<Vec<u64>> = 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<egui::ColorImage> {
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<TextureHandle> {
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<TextureHandle> {
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<Click>,
/// 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<NodeId>,
/// The style the right button is held on.
large: Option<usize>,
/// 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<Click> {
}
}
/// The styles selected in the panel.
fn selected(ctx: &egui::Context) -> Vec<String> {
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::<PanelDrag>()
&& let PanelDrag::GraphicStyle(moving) = &*drag
{
ev.over = true;
if moving == name {
// Dropped on itself: it stays where it is.
resp.dnd_release_payload::<PanelDrag>();
} 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::<PanelDrag>().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::<PanelDrag>() 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::<PanelDrag>().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<String> {
let mut sel: Vec<String> = 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::<String>::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<String> {
/// The texts `draw` paints in one headless frame with `events`.
fn frame_with(ctx: &egui::Context, events: Vec<egui::Event>, draw: impl FnMut(&mut Ui)) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
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<String> {
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<String> {
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<String> = 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)));
}
}
+39 -1
View File
@@ -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<Value> {
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<egui::Event>) -> 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);
}
}
+23 -9
View File
@@ -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::<PanelDrag>(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")
+8 -2
View File
@@ -957,8 +957,14 @@ pub enum PanelDrag {
/// a swatch of a paint dragged from elsewhere.
Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows> },
/// 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<vectorcraft_doc::NodeId>),
}
/// 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<vectorcraft_color::Color> {
match self {
Self::Paint { paint, .. } => paint.color(),
Self::Appearance(_) => None,
_ => None,
}
}
}
+6
View File
@@ -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.
+16
View File
@@ -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"}}}
```