Libraries panel: art and colours dragged in are added where they are dropped

Art dragged off the canvas and colours dragged in from elsewhere (swatches, another library's)
used to be added at the end of the ungrouped items, wherever they were dropped. They now land
as items dragged within the list do: on another item of their kind, just before or after it by
the pointer's half and in its group; on a group, into it; away from the items, ungrouped at the
end as before.

library.add takes group and before/after, placed as library.moveItem places an item (the
placement is shared). A library change that fails partway now leaves the library as it was.
This commit is contained in:
Dan Green
2026-10-30 20:20:17 +07:00
committed by bflatastic
parent d38994c77d
commit 7688b41a40
7 changed files with 267 additions and 81 deletions
+75 -35
View File
@@ -248,22 +248,24 @@ impl Libraries {
})
}
/// Change library `id` with `f` and save it; on a failed save the change is undone.
/// Change library `id` with `f` and save it; when `f` fails, or the save does, the change is
/// undone.
fn change<T>(&mut self, id: &str, f: impl FnOnce(&mut Library) -> Result<T>) -> Result<T> {
let i = self.libs.iter().position(|(i, _)| i == id).ok_or_else(|| EngineError::Other(format!("no library `{id}`")))?;
let before = self.libs.get(i).map(|(_, l)| l.clone()).unwrap_or_default();
let r = match self.libs.get_mut(i) {
Some((_, lib)) => f(lib)?,
Some((_, lib)) => f(lib),
None => return Err(EngineError::Other(format!("no library `{id}`"))),
};
if let Err(e) = self.save(id) {
let r = r.and_then(|r| self.save(id).map(|()| r));
if let Err(e) = r {
if let Some((_, lib)) = self.libs.get_mut(i) {
*lib = before;
}
return Err(e);
}
self.sort();
Ok(r)
r
}
fn delete(&mut self, id: &str) -> Result<()> {
@@ -341,6 +343,23 @@ impl Library {
}
}
/// Put item `r` in group `at` (an index; None: in none), out of the one it was in, and with
/// `next_to` just before or after that item of its kind in its kind's list: in the group too,
/// next to that item when it is there, else last. → the group's name.
fn put(&mut self, r: ItemRef, at: Option<usize>, next_to: Option<&(ItemRef, bool)>) -> Option<String> {
if let Some((other, after)) = next_to
&& *other != r
{
self.place_next_to(&r, other, *after);
}
self.ungroup(&r);
at.and_then(|i| self.groups.get_mut(i)).map(|g| {
let near = next_to.and_then(|(other, after)| g.items.iter().position(|x| x == other).map(|j| j + usize::from(*after)));
g.items.insert(near.unwrap_or(g.items.len()).min(g.items.len()), r);
g.name.clone()
})
}
/// Make a library read from a file (hand-edited, imported) safe and consistent: at most
/// [`MAX_ITEMS`] items of each kind and [`MAX_GROUPS`] groups, names clipped to [`MAX_NAME`]
/// characters and unique within their kind (a repeated one gets a number), sizes finite, a
@@ -617,7 +636,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Library",
[],
None,
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|fillGradient|strokeGradient|fillPattern|strokePattern|charStyle|paraStyle, ids?: objects (default: the selection), name?, color?: colour (a colour to add, instead of the selection's), swatch?: a gradient or pattern swatch of the document (fillGradient…strokePattern: added instead of the selection's paint)} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); fillGradient/strokeGradient: the first selected object's fill/stroke gradient (else the default's), without its placement or swatch links, named after its swatch, else \"Gradient\"; fillPattern/strokePattern: the first selected object's fill/stroke pattern (else the default's) with the resources its art uses, named after it; charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}",
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|fillGradient|strokeGradient|fillPattern|strokePattern|charStyle|paraStyle, ids?: objects (default: the selection), name?, color?: colour (a colour to add, instead of the selection's), swatch?: a gradient or pattern swatch of the document (fillGradient…strokePattern: added instead of the selection's paint), group?: name, before?: item | after?: item (an item of the same kind)} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); fillGradient/strokeGradient: the first selected object's fill/stroke gradient (else the default's), without its placement or swatch links, named after its swatch, else \"Gradient\"; fillPattern/strokePattern: the first selected object's fill/stroke pattern (else the default's) with the resources its art uses, named after it; charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). A new item goes into `group` (default: none) and, with `before` or `after`, just before or after that item in the order its kind is shown in, as dropping it among the Libraries panel's items does; an item the library already has (same kind and contents) isn't added again, nor moved → {library, kind, name, id?: graphic id, existing: bool, group?: name or null (a new item's)}",
has_doc,
add
),
@@ -818,8 +837,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
Item::Style(para, attrs)
}
};
let place = Placement::of(p, C)?;
let id = target_library(s, p, C)?;
let kind_name = str_param(p, "kind").unwrap_or_default().to_string();
let item_kind = kind.item_kind();
let r = s.libraries.change(&id, |lib| {
let full = |n: usize| if n >= MAX_ITEMS { Err(bad(C, format!("the library holds {MAX_ITEMS} items of this kind already"))) } else { Ok(()) };
Ok(match item {
@@ -833,8 +854,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
// Ids are `graphic`, `graphic-2`…: taken ones are looked for as they are kept.
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n.replace(' ', "-"))).replace(' ', "-");
let v = json!({ "name": g.name, "id": g.id, "existing": false });
let key = g.id.clone();
lib.graphics.push(g);
v
placed(lib, &place, ItemRef { kind: item_kind, item: key }, v)?
}
Item::Color((name, color)) => {
full(lib.colors.len())?;
@@ -843,7 +865,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
let name = unique(&given.clone().unwrap_or(name), |n| lib.colors.iter().any(|o| o.name == n));
lib.colors.push(LibraryColor { name: name.clone(), color });
json!({ "name": name, "existing": false })
placed(lib, &place, ItemRef { kind: item_kind, item: name.clone() }, json!({ "name": name, "existing": false }))?
}
Item::Gradient(mut g) => {
full(lib.gradients.len())?;
@@ -852,8 +874,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
g.name = unique(&given.clone().unwrap_or(g.name), |n| lib.gradients.iter().any(|o| o.name == n));
let v = json!({ "name": g.name, "existing": false });
let key = g.name.clone();
lib.gradients.push(g);
v
placed(lib, &place, ItemRef { kind: item_kind, item: key }, v)?
}
Item::Pattern(mut pat) => {
full(lib.patterns.len())?;
@@ -862,8 +885,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
pat.name = unique(&given.clone().unwrap_or(pat.name), |n| lib.patterns.iter().any(|o| o.name == n));
let v = json!({ "name": pat.name, "existing": false });
let key = pat.name.clone();
lib.patterns.push(pat);
v
placed(lib, &place, ItemRef { kind: item_kind, item: key }, v)?
}
Item::Style(para, attrs) => {
let list = if para { &mut lib.para_styles } else { &mut lib.char_styles };
@@ -874,7 +898,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let base = given.clone().unwrap_or_else(|| style_name(&attrs, para));
let name = unique(&base, |n| list.iter().any(|o| o.name == n));
list.push(TextStyleDef { name: name.clone(), attrs });
json!({ "name": name, "existing": false })
placed(lib, &place, ItemRef { kind: item_kind, item: name.clone() }, json!({ "name": name, "existing": false }))?
}
})
})?;
@@ -884,6 +908,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// New item `r` of `lib` put where `place` says, its group added to `v` (what `library.add`
/// reports).
fn placed(lib: &mut Library, place: &Placement, r: ItemRef, mut v: Value) -> Result<Value> {
v["group"] = json!(place.apply(lib, r, "library.add")?);
Ok(v)
}
enum Item {
Graphic(LibraryGraphic),
Color((String, Color)),
@@ -1320,38 +1351,47 @@ fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveItem";
let (id, kind, item) = item_param(s, p, C)?;
let group = str_param(p, "group");
let next_to = match (str_param(p, "before"), str_param(p, "after")) {
(Some(_), Some(_)) => return Err(bad(C, "give `before` or `after`, not both")),
(Some(b), None) => Some((b, false)),
(None, Some(a)) => Some((a, true)),
(None, None) => None,
};
let place = Placement::of(p, C)?;
let (key, to) = s.libraries.change(&id, |lib| {
let missing = |item: &str| bad(C, format!("the library has no item `{item}`"));
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| missing(item))?;
let at = group.map(|g| group_at(lib, g, C)).transpose()?;
let next_to = next_to
.map(|(other, after)| Ok::<_, EngineError>((lib.item_ref(kind.item_kind(), other).ok_or_else(|| missing(other))?, after)))
.transpose()?;
if let Some((other, after)) = &next_to
&& *other != r
{
lib.place_next_to(&r, other, *after);
}
lib.ungroup(&r);
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
let key = r.item.clone();
let to = at.and_then(|i| lib.groups.get_mut(i)).map(|g| {
// Next to the other item when that is in the group too, else last.
let near = next_to.as_ref().and_then(|(other, after)| g.items.iter().position(|x| x == other).map(|j| j + usize::from(*after)));
g.items.insert(near.unwrap_or(g.items.len()).min(g.items.len()), r);
g.name.clone()
});
let to = place.apply(lib, r, C)?;
Ok((key, to))
})?;
Ok(json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "group": to }))
}
/// Where `library.add` and `library.moveItem` put an item: its `group` (none: out of any), and
/// the item of its kind it goes `before` or `after`.
struct Placement<'a> {
group: Option<&'a str>,
next_to: Option<(&'a str, bool)>,
}
impl<'a> Placement<'a> {
fn of(p: &'a Value, cmd: &str) -> Result<Self> {
let next_to = match (str_param(p, "before"), str_param(p, "after")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `before` or `after`, not both")),
(Some(b), None) => Some((b, false)),
(None, Some(a)) => Some((a, true)),
(None, None) => None,
};
Ok(Self { group: str_param(p, "group"), next_to })
}
/// Put item `r` of `lib` where this says ([`Library::put`]) → its group's name.
fn apply(&self, lib: &mut Library, r: ItemRef, cmd: &str) -> Result<Option<String>> {
let at = self.group.map(|g| group_at(lib, g, cmd)).transpose()?;
let next_to = self
.next_to
.map(|(other, after)| {
lib.item_ref(r.kind, other).map(|o| (o, after)).ok_or_else(|| bad(cmd, format!("the library has no item `{other}`")))
})
.transpose()?;
Ok(lib.put(r, at, next_to.as_ref()))
}
}
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveGroup";
let id = library_id(s, p, C)?;
+43
View File
@@ -681,6 +681,49 @@ fn items_and_groups_are_reordered() {
assert_eq!(groups(&mut s)[0].1, refs(&[("color", "Green"), ("color", "Gray")]), "a group keeps its items when it moves");
}
/// Dropping art or a colour among the Libraries panel's items: the new item goes just before or
/// after one of its kind and into a group, or into a group alone, and a bad place adds nothing.
#[test]
fn a_new_item_is_added_where_it_is_dropped() {
let mut s = session();
run(&mut s, "library.create", json!({"name": "Brand"}));
for (name, color) in [("Red", "#ff0000"), ("Green", "#00ff00")] {
run(&mut s, "library.add", json!({"kind": "fillColor", "color": color, "name": name}));
}
run(&mut s, "library.createGroup", json!({"name": "Cool", "items": [{"kind": "fillColor", "item": "Green"}]}));
// Before an ungrouped item: ungrouped too.
let r = run(&mut s, "library.add", json!({"kind": "fillColor", "color": "#ffff00", "name": "Yellow", "before": "Red"}));
assert!(r["group"].is_null());
assert_eq!(order(&mut s, "colors", "name"), ["Yellow", "Red", "Green"]);
// After an item in a group, into that group next to it.
let r = run(&mut s, "library.add", json!({"kind": "fillColor", "color": "#0000ff", "name": "Blue", "group": "cool", "after": "Green"}));
assert_eq!(r["group"], "Cool");
assert_eq!(order(&mut s, "colors", "name"), ["Yellow", "Red", "Green", "Blue"]);
assert_eq!(groups(&mut s), [("Cool".into(), refs(&[("color", "Green"), ("color", "Blue")]))]);
// Into a group alone: last in it, and last of its kind.
let shape = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
let g = run(&mut s, "library.add", json!({"kind": "graphic", "ids": [shape], "group": "Cool"}));
assert_eq!(g["group"], "Cool");
let g = g["id"].as_str().unwrap().to_string();
assert_eq!(groups(&mut s)[0].1, refs(&[("color", "Green"), ("color", "Blue"), ("graphic", &g)]));
// One the library has stays where it is.
let r = run(&mut s, "library.add", json!({"kind": "fillColor", "color": "#ff0000", "before": "Yellow"}));
assert_eq!((r["name"].as_str(), r["existing"].as_bool()), (Some("Red"), Some(true)));
assert_eq!(order(&mut s, "colors", "name"), ["Yellow", "Red", "Green", "Blue"]);
// Bad places add nothing.
let other = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 20, "height": 10}))["id"].clone();
for bad in [
json!({"kind": "fillColor", "color": "#808080", "group": "Nope"}),
json!({"kind": "fillColor", "color": "#808080", "before": "Nope"}),
json!({"kind": "fillColor", "color": "#808080", "before": "Red", "after": "Blue"}),
json!({"kind": "graphic", "ids": [other], "before": "Red"}),
] {
assert!(s.execute("library.add", &bad).is_err(), "{bad}");
}
assert_eq!(order(&mut s, "colors", "name"), ["Yellow", "Red", "Green", "Blue"]);
assert_eq!(order(&mut s, "graphics", "id"), [g]);
}
/// A library goes out to a file with its art and groups and comes back as a new library, numbered
/// when its name is taken; the graphic it brings places in a document (#926).
#[test]
+8 -2
View File
@@ -1147,8 +1147,9 @@ fn styles(what: &str, data: &str) -> Result<(), TestCaseError> {
}
/// Libraries panel library file `data` imported, then each kind's first item used on the rich
/// document, moved into its first group and next to its kind's last item, the group moved and
/// deleted and the library exported (#926).
/// document, moved into its first group and next to its kind's last item, a colour and the
/// selection added into that group next to the first ones, the group moved and deleted and the
/// library exported (#926).
fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "library.import", data, |s, r| {
let lib = r["id"].clone();
@@ -1173,6 +1174,11 @@ fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
}
}
}
// Added where the panel drops it: into the first group, next to the first colour and graphic.
let group = &got["groups"][0]["name"];
let to = json!({"library": lib, "kind": "fillColor", "color": "#123456", "group": group, "after": got["colors"][0]["name"]});
let _ = no_panic(s.execute("library.add", &to));
let _ = no_panic(s.execute("library.add", &json!({"library": lib, "kind": "graphic", "group": group, "before": got["graphics"][0]["id"]})));
if let Some(g) = got["groups"].get(0) {
for to in [0, 1, 1_000_000] {
let _ = no_panic(s.execute("library.moveGroup", &json!({"library": lib, "group": g["name"], "to": to})));
+66 -38
View File
@@ -15,7 +15,10 @@
//! bottom edge). An item dragged within it lands before or after another of its kind, in that one's
//! group (`library.moveItem` with `before`/`after`), or, dropped elsewhere on a group or on the
//! ungrouped items, goes into that group or out of its own; a group dragged by its header goes
//! before or after another group (`library.moveGroup`).
//! before or after another group (`library.moveGroup`). Art from the canvas and swatches from
//! elsewhere are added where they are dropped in the same way (`library.add` with `group` and
//! `before`/`after`): next to an item of their kind and in its group, or into a group; dropped
//! away from the items, ungrouped.
//!
//! The buttons beside the library picker show the items as icons (colour chips and graphic tiles,
//! the default) or in a list, one row each with its name (a colour's hex value too).
@@ -99,34 +102,44 @@ enum Act {
RenameGroup(String),
}
/// What a drag in the panel holds of the library shown: one of its items, or one of its groups
/// (by the group's header).
/// What a drag in the panel holds for the library shown: one of its items, one of its groups (by
/// the group's header), or something to add to it.
#[derive(Clone, Debug, PartialEq)]
enum Grab {
Item(ItemKind, String),
Group(String),
/// Art dragged off the canvas, or a swatch from elsewhere (a colour, also from another library,
/// or a gradient or pattern swatch): added where it is dropped, with these `library.add` params.
New(ItemKind, Value),
}
/// The item or group of library `library` that `drag` holds, if it holds one.
/// The item or group of library `library` that `drag` holds, or what it would add, if anything.
fn grabbed(drag: &PanelDrag, library: &str) -> Option<Grab> {
match drag {
PanelDrag::Paint { library: Some((l, item)), .. } if l == library => Some(Grab::Item(ItemKind::Color, item.clone())),
PanelDrag::LibraryGraphic { library: l, item } if l == library => Some(Grab::Item(ItemKind::Graphic, item.clone())),
PanelDrag::LibraryItem { library: l, kind, item } if l == library => Some(Grab::Item(*kind, item.clone())),
PanelDrag::LibraryGroup { library: l, group } if l == library => Some(Grab::Group(group.clone())),
PanelDrag::Art(ids) => {
let ids: Vec<u64> = ids.iter().map(|id| id.0).collect();
Some(Grab::New(ItemKind::Graphic, json!({ "kind": "graphic", "ids": ids })))
}
PanelDrag::Paint { .. } => swatch_add(drag, library).map(|(kind, add)| Grab::New(kind, add)),
_ => None,
}
}
/// The `library.add` params that add what `drag` holds to library `library`: a color (not one of
/// that library's own), or a gradient or pattern swatch, by its name.
fn swatch_add(drag: &PanelDrag, library: &str) -> Option<Value> {
/// The kind and `library.add` params of what `drag` holds, to add to library `library`: a color
/// (not one of that library's own), or a gradient or pattern swatch, by its name.
fn swatch_add(drag: &PanelDrag, library: &str) -> Option<(ItemKind, Value)> {
let PanelDrag::Paint { paint, params, library: from, .. } = drag else { return None };
let swatch = || params.get("swatch").and_then(Value::as_str);
match paint {
Paint::Solid { color, .. } if from.as_ref().is_none_or(|(l, _)| l != library) => Some(json!({ "kind": "fillColor", "color": color })),
Paint::Gradient(_) => swatch().map(|s| json!({ "kind": "fillGradient", "swatch": s })),
Paint::Pattern { .. } => swatch().map(|s| json!({ "kind": "fillPattern", "swatch": s })),
Paint::Solid { color, .. } if from.as_ref().is_none_or(|(l, _)| l != library) => {
Some((ItemKind::Color, json!({ "kind": "fillColor", "color": color })))
}
Paint::Gradient(_) => swatch().map(|s| (ItemKind::Gradient, json!({ "kind": "fillGradient", "swatch": s }))),
Paint::Pattern { .. } => swatch().map(|s| (ItemKind::Pattern, json!({ "kind": "fillPattern", "swatch": s }))),
_ => None,
}
}
@@ -294,23 +307,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
out.inner_rect
});
if let Some(grab) = &items.grab
&& !over_item
{
section_drop(ui, &items, grab, &sections, list_rect, &mut act);
}
// Art dragged off the canvas, or a swatch (not one of this library's colours), dropped on the
// list is added to the library.
let placed = over_item || items.grab.as_ref().is_some_and(|grab| section_drop(ui, &items, grab, &sections, list_rect, &mut act));
// Art dragged off the canvas, or a swatch from elsewhere, dropped on the list away from its
// items and sections (an empty library, the space below them) is added to it, ungrouped. The
// zone is there in every frame: egui finds the pointer's widget among the last frame's.
let zone = ui.interact(list_rect, ui.scope_id().with("libraries-drop"), Sense::hover());
if let Some(ids) = widgets::art_drop(ui, &zone) {
act = Some(Act::Run("library.add", json!({ "kind": "graphic", "ids": ids })));
}
if let Some(drag) = zone.dnd_hover_payload::<PanelDrag>()
&& let Some(add) = swatch_add(&drag, &lib_id)
if !placed
&& let Some(Grab::New(_, add)) = &items.grab
&& zone.dnd_hover_payload::<PanelDrag>().is_some()
{
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
if zone.dnd_release_payload::<PanelDrag>().is_some() {
act = Some(Act::Run("library.add", add));
act = Some(Act::Run("library.add", add.clone()));
}
}
let bar_top = ui.cursor().top();
@@ -534,17 +542,22 @@ impl Items<'_> {
resp.context_menu(|ui| self.item_menu(ui, act, kind, name));
}
/// An item of `kind` dragged over item `key`'s cell `resp` lands just before or after it, by
/// the pointer's half (`row`: rows stack downwards; else chips and tiles run across), in its
/// group: an accent line shows where, and `over` is set. Dropped on itself it stays.
/// An item of `kind` dragged over item `key`'s cell `resp`, or a new one of that kind, lands
/// just before or after it, by the pointer's half (`row`: rows stack downwards; else chips and
/// tiles run across), in its group: an accent line shows where, and `over` is set. Dropped on
/// itself an item stays.
#[allow(clippy::too_many_arguments)]
fn drop_next_to(&self, ui: &Ui, resp: &egui::Response, kind: ItemKind, key: &str, row: bool, act: &mut Option<Act>, over: &mut bool) {
let Some(Grab::Item(grabbed_kind, moving)) = &self.grab else { return };
let (grabbed_kind, moving) = match &self.grab {
Some(Grab::Item(k, item)) => (k, Some(item)),
Some(Grab::New(k, _)) => (k, None),
_ => return,
};
if *grabbed_kind != kind || resp.dnd_hover_payload::<PanelDrag>().is_none() {
return;
}
*over = true;
if moving == key {
if moving.is_some_and(|m| m == key) {
resp.dnd_release_payload::<PanelDrag>();
return;
}
@@ -560,12 +573,15 @@ impl Items<'_> {
ui.painter().line_segment([egui::pos2(x, r.top()), egui::pos2(x, r.bottom())], accent);
}
if resp.dnd_release_payload::<PanelDrag>().is_some() {
let mut p = json!({ "kind": kind_param(kind), "item": moving });
let (cmd, mut p) = match (&self.grab, moving) {
(Some(Grab::New(_, add)), _) => ("library.add", add.clone()),
(_, item) => ("library.moveItem", json!({ "kind": kind_param(kind), "item": item })),
};
p[if after { "after" } else { "before" }] = json!(key);
if let Some(g) = self.group_of(kind, key).and_then(|i| self.lib.groups.get(i)) {
p["group"] = json!(g.name);
}
*act = Some(Act::Run("library.moveItem", p));
*act = Some(Act::Run(cmd, p));
}
}
@@ -649,21 +665,32 @@ fn scroll_at_edges(ui: &mut Ui) {
/// The drag in progress held over a section of the list (`sections`, seen through `clip`), away
/// from the items that take one: an item goes into that section's group (the ungrouped section:
/// out of its group), the section outlined; a group goes before or after that group, by the
/// pointer's half, an accent line showing where.
fn section_drop(ui: &Ui, items: &Items, grab: &Grab, sections: &[(Option<usize>, Rect)], clip: Rect, act: &mut Option<Act>) {
/// out of its group), and something new is added there, the section outlined; a group goes
/// before or after that group, by the pointer's half, an accent line showing where. → whether
/// the pointer is over a section.
fn section_drop(ui: &Ui, items: &Items, grab: &Grab, sections: &[(Option<usize>, Rect)], clip: Rect, act: &mut Option<Act>) -> bool {
// By the pointer, not by hover zones: the headers and items over the sections take the hover.
let Some(pos) = ui.ctx().pointer_latest_pos() else { return };
let Some(pos) = ui.ctx().pointer_latest_pos() else { return false };
let released = ui.input(|i| i.pointer.any_released());
let take = || egui::DragAndDrop::take_payload::<PanelDrag>(ui.ctx()).is_some();
let t = Tokens::get(ui.ctx());
let Some((group, rect)) = sections.iter().find(|(_, rect)| rect.intersect(clip).contains(pos)) else { return };
let Some((group, rect)) = sections.iter().find(|(_, rect)| rect.intersect(clip).contains(pos)) else { return false };
let r = rect.intersect(clip);
let name = |i: &usize| items.lib.groups.get(*i).map(|g| g.name.clone());
match grab {
Grab::New(_, add) => {
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
if released && take() {
let mut p = add.clone();
if let Some(g) = group.as_ref().and_then(name) {
p["group"] = json!(g);
}
*act = Some(Act::Run("library.add", p));
}
}
Grab::Item(kind, item) => {
if items.group_of(*kind, item) == *group {
return;
return true;
}
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
if released && take() {
@@ -676,9 +703,9 @@ fn section_drop(ui: &Ui, items: &Items, grab: &Grab, sections: &[(Option<usize>,
}
Grab::Group(moving) => {
// Groups only go among the groups; dropped on itself, one stays.
let (Some(to), Some(from)) = (*group, items.lib.groups.iter().position(|g| g.name == *moving)) else { return };
let (Some(to), Some(from)) = (*group, items.lib.groups.iter().position(|g| g.name == *moving)) else { return true };
if from == to {
return;
return true;
}
let after = pos.y > rect.center().y;
let y = if after { rect.bottom() } else { rect.top() };
@@ -690,6 +717,7 @@ fn section_drop(ui: &Ui, items: &Items, grab: &Grab, sections: &[(Option<usize>,
}
}
}
true
}
/// An item's row `r` in the list (`resp`: its response): highlighted while hovered, `name` after
@@ -235,6 +235,71 @@ fn the_list_view_shows_names_and_takes_drops_by_row() {
assert!(!shown(&texts, "Ocean Blue"), "{texts:?}");
}
/// Art dragged off the canvas, and a colour from elsewhere, are added where they are dropped: on
/// another graphic's top or bottom half, just before or after it and in its group; on a group's
/// header, into that group; away from the items, ungrouped at the end, as into an empty library.
#[test]
fn art_and_colours_dropped_from_elsewhere_go_where_they_are_dropped() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("library.create", json!({"name": "Brand"})).unwrap();
let art = |app: &mut VectorcraftApp, x: f64, name: Option<&str>| {
let id = app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 10.0 + x, "height": 20})).unwrap()["id"].as_u64().unwrap();
if let Some(name) = name {
app.run("library.add", json!({"kind": "graphic", "ids": [id], "name": name})).unwrap();
}
PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])
};
// An empty library takes a drop anywhere on the list.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame_at(&mut app, &ctx, 600.0, vec![]);
let drag = art(&mut app, 0.0, None);
drop_at(&mut app, &ctx, drag, egui::pos2(130.0, 200.0));
let graphics = |app: &mut VectorcraftApp| {
let got = app.run("library.get", json!({})).unwrap();
got["graphics"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap().to_string()).collect::<Vec<_>>()
};
assert_eq!(graphics(&mut app).len(), 1);
app.run("library.removeItem", json!({"kind": "graphic", "item": "graphic"})).unwrap();
art(&mut app, 10.0, Some("Badge"));
art(&mut app, 20.0, Some("Seal"));
app.run("library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": "Seal"}]})).unwrap();
crate::panels::set_pstate(&ctx, LIST_VIEW, true);
// Onto Badge's top half: before it, ungrouped.
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
let badge = at(&texts, "Badge");
let drag = art(&mut app, 30.0, None);
drop_at(&mut app, &ctx, drag, egui::pos2(badge.center().x, badge.top() - 2.0));
let first = graphics(&mut app).first().cloned().unwrap();
assert_eq!(graphics(&mut app)[1..], ["Badge", "Seal"]);
assert_eq!(group_names(&mut app)[0].1.len(), 1, "only Seal is in Logos");
// Onto Seal's bottom half: after it, in Logos.
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
let seal = at(&texts, "Seal");
let drag = art(&mut app, 40.0, None);
drop_at(&mut app, &ctx, drag, egui::pos2(seal.center().x, seal.bottom() + 2.0));
let names = graphics(&mut app);
assert_eq!(names[..3], [first.as_str(), "Badge", "Seal"]);
assert_eq!(group_names(&mut app)[0].1.len(), 2, "the new graphic is in Logos: {:?}", group_names(&mut app));
// A swatch onto Logos's header: a colour in Logos.
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
drop_at(&mut app, &ctx, PanelDrag::paint(Paint::solid(vectorcraft_color::Color::from_hex("#0044aa").unwrap())), at(&texts, "Logos").center());
let got = app.run("library.get", json!({})).unwrap();
let color = got["colors"][0]["name"].as_str().unwrap().to_string();
assert!(group_names(&mut app)[0].1.contains(&color), "{:?}", group_names(&mut app));
// Below the items: ungrouped, at the end.
let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]);
// The last item is the graphic added after Seal, in the list's last section.
let below = at(&texts, "Seal").bottom() + 80.0;
let drag = art(&mut app, 50.0, None);
drop_at(&mut app, &ctx, drag, egui::pos2(130.0, below));
let got = app.run("library.get", json!({})).unwrap();
let last = got["graphics"].as_array().unwrap().last().unwrap()["id"].as_str().unwrap().to_string();
assert_eq!(graphics(&mut app).len(), 5);
assert!(!group_names(&mut app)[0].1.contains(&last), "{:?}", group_names(&mut app));
}
/// A long list scrolls inside the panel (its bottom bar stays in the window), and a drag held
/// near the list's bottom edge scrolls it.
#[test]
+7 -4
View File
@@ -2053,10 +2053,13 @@ size and a PNG `thumbnail` in base64; colours with `name`, `hex` and `color`; st
`library.setCurrent {library}` manage them; `library` is an id or a name, and without it the current library (the one
the panel shows) is meant.
`library.add {library?, kind, ids?, name?, color?, swatch?}` adds from the selection: `graphic` (a copy of the objects with the
images, symbols, patterns, swatches, styles and brushes they use, as Copy takes them), `fillColor` / `strokeColor` (the
first selected object's solid colour, else the default one, or `color`), `charStyle` / `paraStyle` (the selected
text's attributes). An item the library already has is reported as `existing`. `library.use {library?, kind, item,
`library.add {library?, kind, ids?, name?, color?, swatch?, group?, before? | after?}` adds from the selection:
`graphic` (a copy of the objects with the images, symbols, patterns, swatches, styles and brushes they use, as Copy
takes them), `fillColor` / `strokeColor` (the first selected object's solid colour, else the default one, or `color`),
`charStyle` / `paraStyle` (the selected text's attributes). The new item goes into `group` (by default into none) and,
with `before` or `after` (another item of its kind), just before or after that item, as `library.moveItem` places one;
art and swatches dropped among the panel's items are added this way. An item the library already has is reported as
`existing`, and stays where it is. `library.use {library?, kind, item,
center?, to?}` uses one: a graphic is placed centred on `center` (pasted with its resources; a swatch name the document
gives another colour merges into the document's), a colour paints the selection's fill or stroke, and a style is added
to the document under its name without the white space around it (numbered when that name is taken by other
+3 -2
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Gradient panel presets done; Gradient stop popover: an eyedropper samples the stop's colour from the art (#4681); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Libraries panel: art dragged off the canvas and swatches dragged in are added where they are dropped; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -404,7 +404,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes, the canvas context menu and docking panels into any dock group by dragging (as tabs, a top/bottom split or icons) are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window, context menus beyond the canvas, about 13 Preferences options stored but not yet acted on (#394: Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes, the canvas context menu and docking panels into any dock group by dragging (as tabs, a top/bottom split or icons) are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window, context menus beyond the canvas, about 14 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 |
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745), its search, groups and library export/import as `.vclibrary` files (#926), a list that scrolls, with items and groups reordered by dragging and items dragged into and out of groups, and items shown as icons or in a list with their names, are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745), its search, groups and library export/import as `.vclibrary` files (#926), a list that scrolls, with items and groups reordered by dragging and items dragged into and out of groups, art and swatches dragged in added where they are dropped, and items shown as icons or in a list with their names, are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | Bounded control socket waits (#1726); PngSuite pinned-file integrity and download budgets verified (#1729); RPM runtime capabilities and glibc floor checked (#1275); titled PDF exports ask viewers to display the document title (#3646); real-distro installation/start-up QA pending | 50–80 |
@@ -1283,6 +1283,7 @@ spot colours, carry a colour adjustment, or are RGB, CMYK or grayscale images
| 2026-10-30 | minor | Selection, transform & align tools: Align panel menu › Align to Glyph Bounds › Point Text and Area Text align and distribute type by the ink of its glyphs (#4231); evidence added; score and hours unchanged (estimated). |
| 2026-10-30 | minor | Blob Brush and Eraser Tool Options: Size follows the pen pressure, Fixed or Pressure with a Variation (#4590, #3961); scores and estimates unchanged |
| 2026-10-30 | minor | Blob Brush Tool Options: Size follows the pen pressure, Fixed or Pressure with a Variation (#4590); scores and estimates unchanged |
| 2026-10-29 | minor | Libraries panel: art dragged off the canvas and swatches dragged in (colours, also another library's, gradients and patterns) are added where they are dropped, as items dragged within the list move: on another item of their kind, just before or after it by the pointer's half and in its group; on a group, into it; away from the items, ungrouped at the end as before (`library.add` takes `group` and `before`/`after`). Tests: `tests::library::a_new_item_is_added_where_it_is_dropped`, `panels::libraries::tests::art_and_colours_dropped_from_elsewhere_go_where_they_are_dropped`; the library fuzz test adds with them. Scores and hours unchanged. |
| 2026-10-29 | minor | Path operations: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; evidence added; score and hours unchanged (estimated). |
| 2026-10-29 | minor | Object › Path › Smooth… with its slider and Auto-Smooth (#4115) added to the evidence; score and hours unchanged. |
| 2026-10-26 | minor | File formats: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; evidence added ([file-format-parity.md](file-format-parity.md#eps-save-and-source-protection)); writing Illustrator's editing data (#868) and the `.ai` structure issues stay in the missing items; score and hours unchanged (estimated) |