mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 23:35:10 +00:00
Merge main into feat/revolve-graphics
This commit is contained in:
@@ -167,9 +167,13 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "ids": [], "before": 0, "after": 0 }));
|
||||
}
|
||||
let ids: Vec<NodeId> = plan.iter().map(|(id, _)| *id).collect();
|
||||
s.edit("Smooth", |d, _| {
|
||||
s.edit("Smooth", |d, sel| {
|
||||
for (id, np) in plan {
|
||||
*path_mut(d, id)? = np;
|
||||
// The smoothed path has new anchors, so the old anchor and segment indices are
|
||||
// dropped and the path stays selected whole.
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -346,6 +346,36 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
/// Name item `r` `name`, in its place in its kind's list; an item keyed by its name keeps its
|
||||
/// group under the new name (a graphic is keyed by its id, which stays).
|
||||
fn rename_item(&mut self, r: &ItemRef, name: &str) {
|
||||
fn set<'a>(mut names: impl Iterator<Item = &'a mut String>, from: &str, to: &str) {
|
||||
if let Some(n) = names.find(|n| n.as_str() == from) {
|
||||
*n = to.to_string();
|
||||
}
|
||||
}
|
||||
let from = r.item.as_str();
|
||||
match r.kind {
|
||||
ItemKind::Color => set(self.colors.iter_mut().map(|c| &mut c.name), from, name),
|
||||
ItemKind::Gradient => set(self.gradients.iter_mut().map(|g| &mut g.name), from, name),
|
||||
ItemKind::Pattern => set(self.patterns.iter_mut().map(|p| &mut p.name), from, name),
|
||||
ItemKind::CharStyle => set(self.char_styles.iter_mut().map(|t| &mut t.name), from, name),
|
||||
ItemKind::ParaStyle => set(self.para_styles.iter_mut().map(|t| &mut t.name), from, name),
|
||||
ItemKind::Graphic => {
|
||||
if let Some(g) = self.graphics.iter_mut().find(|g| g.id == from) {
|
||||
g.name = name.to_string();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ItemKind::Unknown => return,
|
||||
}
|
||||
for x in self.groups.iter_mut().flat_map(|g| g.items.iter_mut()) {
|
||||
if x == r {
|
||||
x.item = name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move item `r` next to `to`, an item of its kind: just before it, or just after it when
|
||||
/// `after`, in their kind's list (the order the panel shows them in).
|
||||
fn place_next_to(&mut self, r: &ItemRef, to: &ItemRef, after: bool) {
|
||||
@@ -683,7 +713,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Use Library Item",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; a spot color links to the document's spot swatch of its ink's name, added when the document has none of that colour (a name taken by another colour gets a number), one undo step; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
|
||||
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; a spot color links to the document's spot swatch of its ink's name, added when the document has none of that colour (a name taken by another colour gets a number), one undo step; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number; a character style's color linked to a swatch that isn't a global or spot swatch of the document keeps the color it shows, unlinked) and apply it to the selected text, one undo step → {kind, ids?, style?}",
|
||||
has_doc,
|
||||
use_item
|
||||
),
|
||||
@@ -696,6 +726,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
remove_item
|
||||
),
|
||||
cmd!(
|
||||
"library.renameItem",
|
||||
"Rename Library Item",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name), name: string (trimmed; 1 to 256 characters)} rename an item; it keeps its place and its group; another item of its kind with that name is an error, and its own name changes nothing; not undoable → {library, kind, item: its key now (graphic: its id), name}",
|
||||
always,
|
||||
rename_item
|
||||
),
|
||||
cmd!(
|
||||
"library.addToCharacterStyles",
|
||||
"Add to Character Styles",
|
||||
[],
|
||||
None,
|
||||
"{library?, item: a character style's name, recorded?: the style as it was when added (recorded in the journal so a replay matches)} add a library character style to the document's character styles without applying it (no text needs to be selected): a document style of that name with the same attributes is reused, a name taken by different attributes gets a number; a color linked to a swatch that isn't a global or spot swatch of the document keeps the color it shows, unlinked; a new style is one undo step, a reused one none → {style: its name in the document, existing: bool}",
|
||||
has_doc,
|
||||
add_to_char_styles
|
||||
),
|
||||
cmd!(
|
||||
"library.createGroup",
|
||||
"New Library Group",
|
||||
@@ -1231,8 +1279,13 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
|
||||
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let mut attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let doc = &s.doc()?.doc;
|
||||
if !para {
|
||||
unlink_missing_swatches(doc, &mut attrs);
|
||||
}
|
||||
// Compared in the form the document stores them in.
|
||||
let attrs = super::textstyles::stored_style_attrs(doc, para, &attrs).unwrap_or(attrs);
|
||||
// The document's style of that name when it is the same; else a new one.
|
||||
let same = super::textstyles::style_attrs(doc, para, &name).is_some_and(|a| a == attrs);
|
||||
let style = if same { name } else { unique(&name, |n| super::textstyles::style_attrs(doc, para, n).is_some()) };
|
||||
@@ -1361,6 +1414,109 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn rename_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.renameItem";
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let name = match p.get("name") {
|
||||
None | Some(Value::Null) => return Err(bad(C, "missing `name`")),
|
||||
Some(Value::String(n)) => n.trim().to_string(),
|
||||
Some(_) => return Err(bad(C, "`name` must be a string")),
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(bad(C, "missing `name`"));
|
||||
}
|
||||
if name.chars().count() > MAX_NAME {
|
||||
let (label, max) = ("`name`", MAX_NAME);
|
||||
return Err(bad(C, format!("{label} must be at most {max} characters")));
|
||||
}
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
|
||||
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
|
||||
// The item's name now, and whether another item of its kind has the new one.
|
||||
let mut same = false;
|
||||
let mut taken = false;
|
||||
for (_, key, shown) in lib.items().filter(|(k, ..)| *k == r.kind) {
|
||||
if key == r.item {
|
||||
same = shown == name;
|
||||
} else if shown == name {
|
||||
taken = true;
|
||||
}
|
||||
}
|
||||
let key = if r.kind == ItemKind::Graphic { r.item.clone() } else { name.clone() };
|
||||
let out = json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "name": name });
|
||||
if same {
|
||||
return Ok(out);
|
||||
}
|
||||
if taken {
|
||||
return Err(bad(C, format!("the library has another item of that kind named `{name}`")));
|
||||
}
|
||||
s.libraries.change(&id, |lib| {
|
||||
lib.rename_item(&r, &name);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn add_to_char_styles(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.addToCharacterStyles";
|
||||
// A replay (the journal) carries what the style was, so it doesn't depend on the library.
|
||||
let data = match p.get("recorded").cloned() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
let id = library_id(s, p, C)?;
|
||||
let item = str_param(p, "item").ok_or_else(|| bad(C, "missing `item`"))?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
|
||||
let t = lib.char_styles.iter().find(|t| t.name == item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
|
||||
let data = json!({ "name": t.name, "attrs": t.attrs });
|
||||
s.note_journal("recorded", data.clone());
|
||||
data
|
||||
}
|
||||
};
|
||||
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
|
||||
let mut attrs = data.get("attrs").and_then(Value::as_object).cloned().ok_or_else(|| bad(C, "the library item is damaged"))?;
|
||||
let doc = &s.doc()?.doc;
|
||||
unlink_missing_swatches(doc, &mut attrs);
|
||||
// Compared in the form the document stores them in.
|
||||
let attrs = super::textstyles::stored_style_attrs(doc, false, &attrs).unwrap_or(attrs);
|
||||
// The document's style of that name when it is the same; else a new one, which isn't applied.
|
||||
if super::textstyles::style_attrs(doc, false, &name).is_some_and(|a| a == attrs) {
|
||||
return Ok(json!({ "style": name, "existing": true }));
|
||||
}
|
||||
let style = unique(&name, |n| super::textstyles::style_attrs(doc, false, n).is_some());
|
||||
s.execute("charStyle.new", &json!({ "name": style, "attrs": attrs }))?;
|
||||
Ok(json!({ "style": style, "existing": false }))
|
||||
}
|
||||
|
||||
/// Unlink a library character style's `fill` and `stroke` colors (solid, and each gradient stop)
|
||||
/// from swatches that are not global or spot swatches of `doc`: an unlinked color keeps the color
|
||||
/// it shows at full tint, as [`clean_gradient`] keeps a library gradient's stops. A link to a
|
||||
/// global or spot swatch `doc` has stays, and takes that swatch's color.
|
||||
fn unlink_missing_swatches(doc: &vectorcraft_doc::Document, attrs: &mut Map<String, Value>) {
|
||||
let keep = |name: &str| doc.global_color(name).is_some();
|
||||
for key in ["fill", "stroke"] {
|
||||
let Some(mut paint) = attrs.get(key).and_then(|v| serde_json::from_value::<Paint>(v.clone()).ok()) else { continue };
|
||||
let mut changed = false;
|
||||
let mut unlink = |swatch: &mut Option<String>, tint: &mut f32| {
|
||||
if swatch.as_deref().is_some_and(|n| !keep(n)) {
|
||||
*swatch = None;
|
||||
*tint = 1.0;
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
match &mut paint {
|
||||
Paint::Solid { swatch, tint, .. } => unlink(swatch, tint),
|
||||
Paint::Gradient(g) => {
|
||||
for st in &mut g.gradient.stops {
|
||||
unlink(&mut st.swatch, &mut st.tint);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if changed && let Ok(v) = serde_json::to_value(&paint) {
|
||||
attrs.insert(key.to_string(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `p`'s `name`, trimmed and clipped, else `default`.
|
||||
fn name_param(p: &Value, default: &str) -> String {
|
||||
let mut name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(default).to_string();
|
||||
|
||||
@@ -506,6 +506,13 @@ pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<St
|
||||
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
|
||||
}
|
||||
|
||||
/// `attrs` in the form `charStyle.new` (`para`: `paraStyle.new`) stores them in `d`, or `None`
|
||||
/// when they don't pass its checks.
|
||||
pub(crate) fn stored_style_attrs(d: &Document, para: bool, attrs: &Map<String, Value>) -> Option<Map<String, Value>> {
|
||||
let kind = if para { Kind::Para } else { Kind::Char };
|
||||
attrs_param(d, &json!({ "attrs": attrs }), kind, "new", None).ok().flatten()
|
||||
}
|
||||
|
||||
/// The name `charStyle.new` (`para`: `paraStyle.new`) gives a style when it is given none: the
|
||||
/// first "Character Style N" ("Paragraph Style N") that no character style (paragraph style) of the
|
||||
/// document has.
|
||||
|
||||
@@ -1367,6 +1367,28 @@ fn smooth_tool_reduces_jagged_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
|
||||
}
|
||||
|
||||
/// Smoothing a path with direct-selected anchors leaves the path selected whole: the selection no
|
||||
/// longer names anchors by indices into the anchors the smooth replaced. A selected path the drag
|
||||
/// does not reach keeps its anchors selected, and undo brings the old selection back.
|
||||
#[test]
|
||||
fn smoothing_a_path_drops_its_direct_selected_anchors() {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> = (0..=40).map(|i| json!({"x": 100.0 + i as f64 * 5.0, "y": 200.0 + if i % 2 == 0 { 0.0 } else { 1.5 }})).collect();
|
||||
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let other = line(&mut s, 100.0, 500.0, 300.0, 500.0);
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 38]], "mode": "set"})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 1]], "mode": "add"})).unwrap();
|
||||
drag(&mut s, "smooth", &[(90.0, 200.0), (200.0, 200.0), (310.0, 200.0)], Mods::default());
|
||||
assert!(path(&s, id).anchor_count() < 20, "the path is smoothed");
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert!(sel.contains(id) && sel.contains(other), "{sel:?}");
|
||||
assert!(!sel.anchors.contains_key(&id) && !sel.segments.contains_key(&id), "{sel:?}");
|
||||
assert_eq!(sel.anchors.get(&other), Some(&[(0, 1)].into()), "the path out of reach keeps its anchor");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&[(0, 38)].into()));
|
||||
}
|
||||
|
||||
/// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight
|
||||
/// sides next to the bump stay straight lines from their far ends, which keep their place.
|
||||
#[test]
|
||||
|
||||
@@ -635,6 +635,210 @@ fn a_library_style_named_with_spaces_comes_in_trimmed() {
|
||||
assert_eq!((text.runs[0].style.size, text.runs[0].style.style_name.as_deref()), (31.0, Some("Body")));
|
||||
}
|
||||
|
||||
/// The size of the first run of text object `id`.
|
||||
fn run_size(s: &Session, id: &Value) -> f64 {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.runs[0].style.size,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add to Character Styles puts a library character style into the document's character styles
|
||||
/// without applying it, with or without type selected, in one undo step; the same style there is
|
||||
/// reused without a step, and a name taken by other attributes gets a number. A bad call changes
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn a_library_character_style_is_added_to_the_document_without_applying_it() {
|
||||
let mut s = session();
|
||||
let data = json!({"name": "Kept", "charStyles": [{"name": "Headline", "attrs": {"size": 31.0}}, {"name": "Broken", "attrs": {"size": "big"}}]});
|
||||
let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string();
|
||||
let styles = |s: &Session| s.doc().unwrap().doc.char_styles.iter().map(|c| c.name.clone()).collect::<Vec<_>>();
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
// Nothing selected.
|
||||
let before = undo(&s);
|
||||
let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Headline"}));
|
||||
assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Headline"), Some(false)));
|
||||
assert_eq!(styles(&s), ["Headline"]);
|
||||
assert_eq!(undo(&s), before + 1, "one undo step");
|
||||
let attrs = &s.doc().unwrap().doc.char_styles[0].attrs;
|
||||
assert_eq!(attrs.get("size"), Some(&json!(31.0)));
|
||||
// Selected type keeps its look.
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let before = undo(&s);
|
||||
let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Headline"}));
|
||||
assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Headline"), Some(true)));
|
||||
assert_eq!((undo(&s), run_size(&s, &t), styles(&s).len()), (before, 9.0, 1), "reused: no step, the type unchanged");
|
||||
// Another style of that name: numbered, still not applied.
|
||||
run(&mut s, "charStyle.setAttrs", json!({"name": "Headline", "attrs": {"size": 5}}));
|
||||
let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Headline"}));
|
||||
assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Headline 2"), Some(false)));
|
||||
assert_eq!(run_size(&s, &t), 9.0);
|
||||
let vectorcraft_doc::NodeKind::Text(text) = &s.doc().unwrap().doc.node(NodeId(t.as_u64().unwrap())).unwrap().kind else { panic!("not text") };
|
||||
assert_eq!(text.runs[0].style.style_name, None, "no style applied");
|
||||
// Undo takes the added style back alone.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(styles(&s), ["Headline"]);
|
||||
// Bad calls: nothing changes.
|
||||
let before = (undo(&s), styles(&s));
|
||||
for bad in [
|
||||
json!({"library": lib}),
|
||||
json!({"library": lib, "item": 3}),
|
||||
json!({"library": lib, "item": "Nope"}),
|
||||
json!({"library": "Nope", "item": "Headline"}),
|
||||
json!({"library": lib, "item": "Broken"}),
|
||||
json!({"recorded": {"name": "X", "attrs": "damaged"}}),
|
||||
] {
|
||||
assert!(s.execute("library.addToCharacterStyles", &bad).is_err(), "{bad}");
|
||||
assert_eq!((undo(&s), styles(&s)), before, "{bad}");
|
||||
}
|
||||
// Without a document it isn't available.
|
||||
run(&mut s, "file.close", json!({}));
|
||||
assert!(s.active().is_none());
|
||||
assert!(s.execute("library.addToCharacterStyles", &json!({"library": lib, "item": "Headline"})).is_err());
|
||||
}
|
||||
|
||||
/// A library character style whose fill links to a swatch the document lacks is added and applied
|
||||
/// with the color it shows, unlinked at full tint; a global swatch of that name in the document
|
||||
/// keeps the link and gives its color.
|
||||
#[test]
|
||||
fn a_library_character_style_linked_to_a_missing_swatch_is_added_unlinked() {
|
||||
let base = vectorcraft_color::Color::rgb(0.2, 0.4, 0.6);
|
||||
let shown = base.tinted(0.5);
|
||||
let linked = vectorcraft_color::Paint::Solid { color: shown, swatch: Some("Brand".into()), tint: 0.5 };
|
||||
let data = json!({"name": "Kept", "charStyles": [{"name": "Accent", "attrs": {"size": 20.0, "fill": linked}}]});
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string();
|
||||
let fill = |s: &Session, name: &str| {
|
||||
let st = s.doc().unwrap().doc.char_styles.iter().find(|c| c.name == name).map(|c| c.attrs.clone()).unwrap();
|
||||
serde_json::from_value::<vectorcraft_color::Paint>(st["fill"].clone()).unwrap()
|
||||
};
|
||||
run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Accent"}));
|
||||
assert_eq!(fill(&s, "Accent"), vectorcraft_color::Paint::Solid { color: shown, swatch: None, tint: 1.0 });
|
||||
// Apply as well, on selected type.
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": "Accent"}));
|
||||
assert_eq!(r["style"], "Accent", "the added style is reused");
|
||||
assert_eq!(run_size(&s, &t), 20.0);
|
||||
// A document with a global swatch of that name keeps the link.
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let other = vectorcraft_color::Color::rgb(0.8, 0.1, 0.1);
|
||||
run(&mut s, "swatch.new", json!({"name": "Brand", "color": other, "global": true}));
|
||||
run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Accent"}));
|
||||
let vectorcraft_color::Paint::Solid { swatch, tint, .. } = fill(&s, "Accent") else { panic!("not a solid fill") };
|
||||
assert_eq!((swatch.as_deref(), tint), (Some("Brand"), 0.5));
|
||||
// The style with the document's swatch color is the same style: added again, it is reused.
|
||||
let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Accent"}));
|
||||
assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Accent"), Some(true)));
|
||||
}
|
||||
|
||||
/// A library style is compared with the document's in the form the document stores it in, so a
|
||||
/// style whose file writes a size as a whole number is reused, by Add to Character Styles and by a
|
||||
/// click alike.
|
||||
#[test]
|
||||
fn a_library_style_is_reused_whatever_form_its_file_writes_it_in() {
|
||||
let data = json!({"name": "Kept", "charStyles": [{"name": "Whole", "attrs": {"size": 31}}], "paraStyles": [{"name": "Para", "attrs": {"left_indent": 4}}]});
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string();
|
||||
run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Whole"}));
|
||||
let r = run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Whole"}));
|
||||
assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Whole"), Some(true)));
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
for (kind, item) in [("charStyle", "Whole"), ("paraStyle", "Para"), ("paraStyle", "Para")] {
|
||||
assert_eq!(run(&mut s, "library.use", json!({"library": lib, "kind": kind, "item": item}))["style"], item);
|
||||
}
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((d.char_styles.len(), d.para_styles.iter().filter(|p| p.name.starts_with("Para")).count()), (1, 1));
|
||||
}
|
||||
|
||||
/// Rename gives a library item a new name, trimmed, in its place and its group; a character style
|
||||
/// keeps its attributes, a graphic its id. Another item of its kind with that name, an empty or
|
||||
/// too long name, a name that isn't text and an unknown item or library are errors that change
|
||||
/// nothing; a name with path characters is a name like any other; the item's own name writes
|
||||
/// nothing; and the document's history is not touched.
|
||||
#[test]
|
||||
fn library_items_are_renamed_in_place() {
|
||||
let (mut s, g, c, st) = brand();
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 120, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let other = run(&mut s, "library.add", json!({"kind": "charStyle", "name": "Body"}))["name"].as_str().unwrap().to_string();
|
||||
run(&mut s, "library.createGroup", json!({"name": "Brand", "items": [{"kind": "charStyle", "item": st}, {"kind": "fillColor", "item": c}]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let attrs = run(&mut s, "library.get", json!({}))["charStyles"][0]["attrs"].clone();
|
||||
let r = run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": st, "name": " Title "}));
|
||||
assert_eq!((r["item"].as_str(), r["name"].as_str()), (Some("Title"), Some("Title")));
|
||||
let got = run(&mut s, "library.get", json!({}));
|
||||
assert_eq!(got["charStyles"][0], json!({"name": "Title", "attrs": attrs}), "first still, the attributes kept");
|
||||
assert_eq!(got["charStyles"][1]["name"], other.as_str());
|
||||
assert_eq!(groups(&mut s), [("Brand".into(), refs(&[("charStyle", "Title"), ("color", &c)]))]);
|
||||
// A colour keeps its group under its new name; a graphic keeps its id.
|
||||
run(&mut s, "library.renameItem", json!({"kind": "strokeColor", "item": c, "name": "Navy"}));
|
||||
assert_eq!(groups(&mut s), [("Brand".into(), refs(&[("charStyle", "Title"), ("color", "Navy")]))]);
|
||||
let r = run(&mut s, "library.renameItem", json!({"kind": "graphic", "item": "Logo", "name": "Mark"}));
|
||||
assert_eq!(r["item"], g.as_str());
|
||||
let graphic = run(&mut s, "library.get", json!({}))["graphics"][0].clone();
|
||||
assert_eq!((graphic["id"].as_str(), graphic["name"].as_str()), (Some(g.as_str()), Some("Mark")));
|
||||
// Path characters are kept.
|
||||
run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Title", "name": "a/b\\c:d"}));
|
||||
assert_eq!(style_order_of(&mut s)[0], "a/b\\c:d");
|
||||
// Errors change nothing.
|
||||
let before = run(&mut s, "library.get", json!({}));
|
||||
let long = "x".repeat(257);
|
||||
for bad in [
|
||||
json!({"kind": "charStyle", "item": "a/b\\c:d", "name": other}),
|
||||
json!({"kind": "charStyle", "item": "a/b\\c:d", "name": " "}),
|
||||
json!({"kind": "charStyle", "item": "a/b\\c:d", "name": long}),
|
||||
json!({"kind": "charStyle", "item": "a/b\\c:d", "name": 5}),
|
||||
json!({"kind": "charStyle", "item": "a/b\\c:d"}),
|
||||
json!({"kind": "charStyle", "item": "Nope", "name": "X"}),
|
||||
json!({"kind": "paraStyle", "item": "a/b\\c:d", "name": "X"}),
|
||||
json!({"kind": "brush", "item": "a/b\\c:d", "name": "X"}),
|
||||
json!({"library": "Nope", "kind": "charStyle", "item": "a/b\\c:d", "name": "X"}),
|
||||
] {
|
||||
assert!(s.execute("library.renameItem", &bad).is_err(), "{bad}");
|
||||
assert_eq!(run(&mut s, "library.get", json!({})), before, "{bad}");
|
||||
}
|
||||
// A style of another kind may have the name, and 256 characters are allowed.
|
||||
run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "a/b\\c:d", "name": "Navy"}));
|
||||
run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Navy", "name": "y".repeat(256)}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no document undo step");
|
||||
}
|
||||
|
||||
/// The current library's character style names, in order.
|
||||
fn style_order_of(s: &mut Session) -> Vec<String> {
|
||||
order(s, "charStyles", "name")
|
||||
}
|
||||
|
||||
/// A rename is kept in the library's file; renaming an item to its own name writes nothing; and a
|
||||
/// rename whose file can't be written is taken back.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_library_item_rename_is_saved_or_taken_back() {
|
||||
let dir = vectorcraft_testkit::temp_dir("library-rename-item");
|
||||
let folder = dir.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
s.libraries.set_dir(Some(folder.clone()));
|
||||
let lib = run(&mut s, "library.create", json!({"name": "Kept"}))["id"].as_str().unwrap().to_string();
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 12}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
run(&mut s, "library.add", json!({"kind": "charStyle", "name": "Body"}));
|
||||
run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Body", "name": "Text"}));
|
||||
let mut t2 = session();
|
||||
t2.libraries.set_dir(Some(folder.clone()));
|
||||
assert_eq!(style_order_of(&mut t2), ["Text"], "kept in the file");
|
||||
// The file can't be written (a folder stands where it goes): its own name still succeeds, as
|
||||
// it writes nothing; a new name fails and is taken back.
|
||||
let file = dir.join(format!("{lib}.vclibrary"));
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
std::fs::create_dir(&file).unwrap();
|
||||
run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Text", "name": " Text "}));
|
||||
assert!(s.execute("library.renameItem", &json!({"kind": "charStyle", "item": "Text", "name": "Copy"})).is_err());
|
||||
assert_eq!(style_order_of(&mut s), ["Text"], "taken back");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn libraries_are_kept_in_files_across_sessions() {
|
||||
|
||||
Reference in New Issue
Block a user