Appearance: paint, stroke and effect edits change the active row of the first selected object

The Appearance panel's active row is a row of the first selected object's
stack, but fill, stroke, gradient and effect edits matched it against the
object that paints first. When the selection was made top object first and
the objects' stacks differ, an edit changed another row: with the red fill
of [red, white, Stroke] active above a [white, Stroke] object, Fill set the
white row instead.

These edits now match the active row against the first selected object (or
the compound path holding it), as Add New Fill and Reduce to Basic
Appearance already do. Gradient edits also read whether the active row is a
fill or a stroke from that object.
This commit is contained in:
dotCooCoo
2026-10-31 23:29:35 +07:00
committed by bflatastic
parent 4c32032d34
commit a32d6364ec
3 changed files with 69 additions and 10 deletions
+28 -9
View File
@@ -27,7 +27,7 @@ use vectorcraft_doc::appearance::SharedStack;
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
use vectorcraft_geom::Rect;
use super::edit::{outermost, selected_roots};
use super::edit::{outermost, roots_of, selected_roots};
use super::opacitymask::percent;
use super::paint::paint_from;
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
@@ -255,17 +255,36 @@ impl ItemTarget {
if fits == Some(true) { self } else { ItemTarget::Top }
}
/// This target for each of `ids` in `d` (in their order): an aligned item (a row of the first
/// target's stack) is, in each other target, the item equal to it there (see
/// This target for each of `ids` (in their order): an aligned item (a row of the first
/// target's stack, or for the active item of the first selected object's, see
/// [`ItemTarget::anchor`]) is, in each other target, the item equal to it there (see
/// [`SharedStack`]); `None` for a target that has none, which the edit leaves alone. A row the
/// targets don't share, and any other target, stands for itself in every target.
pub(crate) fn per_target(self, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<Option<ItemTarget>> {
pub(crate) fn per_target(self, s: &Session, ids: &[NodeId]) -> Vec<Option<ItemTarget>> {
let ItemTarget::Item { index, explicit, aligned: true } = self else { return vec![Some(self); ids.len()] };
let Some(shared) = shared_stack(d, ids) else { return vec![Some(self); ids.len()] };
let Some(st) = s.active() else { return vec![Some(self); ids.len()] };
let k = self.anchor(s, ids);
let mut order = ids.to_vec();
order.swap(0, k);
let Some(shared) = shared_stack(&st.doc, &order) else { return vec![Some(self); ids.len()] };
if !shared.has_item(index) {
return vec![Some(self); ids.len()];
}
(0..ids.len()).map(|k| shared.item_in(index, k).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect()
let mut per: Vec<Option<ItemTarget>> =
(0..order.len()).map(|j| shared.item_in(index, j).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect();
per.swap(0, k);
per
}
/// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of
/// is: the first selected object (or the compound path holding it) for the active item, which
/// need not paint first; the first target otherwise, or when that object is not among them.
pub(crate) fn anchor(self, s: &Session, ids: &[NodeId]) -> usize {
let ItemTarget::Item { explicit: false, .. } = self else { return 0 };
let Some(st) = s.active() else { return 0 };
let Some(first) = st.selection.subjects().first() else { return 0 };
let root = roots_of(&st.doc, vec![*first]).first().copied().unwrap_or(*first);
ids.iter().position(|id| *id == root).unwrap_or(0)
}
/// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one.
@@ -407,7 +426,7 @@ pub(crate) fn edit_items_then(
return Ok(());
}
let before = s.doc()?.doc.clone();
let per = item.per_target(&before, ids);
let per = item.per_target(s, ids);
super::overprint::edit_counted(s, label, |d| {
let mut edited = Vec::new();
for (id, item) in ids.iter().zip(&per) {
@@ -429,7 +448,7 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
}
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or
/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or
/// null is an error of command `cmd`.
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
check_switches(p, &["stroke"], cmd)?;
@@ -439,7 +458,7 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
let ItemTarget::Item { index, .. } = item else { return Ok(default) };
let ids = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
}
/// The whole number of 0 or more that `key` holds, read with [`index`].
+1 -1
View File
@@ -108,7 +108,7 @@ fn ids_json(ids: &[NodeId]) -> Value {
pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result<Vec<(NodeId, Option<usize>, usize)>> {
let roots = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
let per = item.per_target(d, &roots);
let per = item.per_target(s, &roots);
let shared = if aligned(p) { shared_stack(d, &roots) } else { None };
let mut out = Vec::with_capacity(roots.len());
for (k, (id, item)) in roots.iter().zip(per).enumerate() {
+40
View File
@@ -463,6 +463,46 @@ fn reduce_to_basic_keeps_the_active_row_of_the_first_selected_object() {
assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#0000ff");
}
/// b `[Fill red, Fill white, Stroke]` above a `[Fill white, Stroke]`, selected b first: b's rows 1
/// and 2 equal a's rows 0 and 1.
fn reversed_pair(s: &mut Session) -> (NodeId, NodeId) {
let a = rect(s, 50.0);
let b = rect(s, 200.0);
run(s, "appearance.addFill", json!({}));
run(s, "appearance.moveItem", json!({"from": 2, "to": 0}));
run(s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"}));
run(s, "select.set", json!({"ids": [b.0, a.0]}));
(a, b)
}
/// The active row is a row of the first selected object also for paint, stroke and effect edits
/// when that object paints above the others.
#[test]
fn edits_change_the_active_row_of_the_first_selected_object() {
let mut s = session();
let (a, b) = reversed_pair(&mut s);
run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // b's red fill
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#00ff00");
assert_eq!(*fill_paint(&nb, 1), Paint::solid(Color::WHITE));
// a shares no red row, so its fill row 0 changes, as for an active row the objects don't share.
assert_eq!(fill_paint(&na, 0).color().unwrap().to_hex(), "#00ff00");
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
let nb = node(&s, b);
assert_eq!((nb.appearance.items[0].effects().len(), nb.appearance.items[1].effects().len()), (1, 0));
let mut s = session();
let (a, b) = reversed_pair(&mut s);
run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's white fill, a's row 0
run(&mut s, "paint.editGradient", json!({"kind": "radial"}));
let (na, nb) = (node(&s, a), node(&s, b));
assert!(matches!(fill_paint(&nb, 1), Paint::Gradient(_)), "b's white fill takes the gradient");
assert!(matches!(fill_paint(&na, 0), Paint::Gradient(_)), "so does a's equal fill");
assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#ff0000");
assert!(!matches!(stroke(&nb, 2).paint, Paint::Gradient(_)) && !matches!(stroke(&na, 1).paint, Paint::Gradient(_)));
}
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
fn history(s: &Session) -> (usize, usize, bool) {
let st = s.doc().unwrap();