Merge main into feat/revolve-graphics

This commit is contained in:
dotCooCoo
2026-11-13 07:49:32 -08:00
51 changed files with 1404 additions and 98 deletions
+5 -1
View File
@@ -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(())
})?;
+158 -2
View File
@@ -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();
+7
View File
@@ -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.
+22
View File
@@ -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]
+204
View File
@@ -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() {