mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
Appearance: a mixed selection lists the attributes its objects share (#4174)
With several objects selected whose appearances differ in anything (two
circles whose fills differ but whose stroke and effect are the same),
the Appearance panel showed only "Mixed Appearances": no stroke, effect
or opacity row, so attributes the objects share couldn't be edited
together. It now lists what they share, as the issue describes:
- `vectorcraft_doc::appearance::SharedStack` (with `align`, a stacking-
order longest-common-subsequence match) gives the fills, strokes and
object-level effects every object has an equal copy of, and where each
is in every object's own stack.
- The panel's object row names the objects' common kind ("Path",
"Ellipse"; "Mixed Objects" only when kinds differ, as the Control bar
does), with an italic "Mixed Appearances" row under it when they
differ, then only the shared rows with their usual controls. The
Opacity row reads "Mixed" when opacity or blend mode differ (an edit
sets them all). The object row shows no fill chip when the fills
differ. Rows aren't dragged in a mixed selection: where a row would go
in each of several different stacks isn't one place.
- The engine maps a shared row to each object's own item: the active
item (a row of the first selected object's stack) always does, so the
Color, Stroke and Transparency panels edit every object's matching
item, and the item and effect commands do with `aligned: true`
(appearance.setItem / removeItem / duplicateItem, effect.remove /
duplicate / setParams / lightingPreset, transparency.set), each as
one undo step. moveItem and effect.move refuse aligned rows of
differing stacks. Without `aligned`, explicit indices keep their
meaning for single objects and scripts.
- "Mixed" is added to every catalog.
Tests: the shared stack of the issue's case, of a stroke at another
index, of nothing alike and of equal stacks, and that matches never
cross; editing the shared stroke edits both strokes (at index 1 and 2)
in one undo step that restores both, also through stroke.set on the
active row; removing a shared effect and adding a fill reach both, and
reordering differing stacks is refused; the panel lists kind, note,
Stroke, Feather and Opacity, not Fill, the full stack for alike objects
and "Mixed Objects" for a path and type. The existing object-row test
now expects the kind for two rectangles and the shared rows when their
opacity differs.
This commit is contained in:
@@ -506,5 +506,105 @@ impl Appearance {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most fills, strokes or effects a stack may have to be matched against another's
|
||||
/// ([`align`]); a larger stack shares nothing with the others.
|
||||
const MAX_ALIGNED: usize = 1024;
|
||||
|
||||
/// Where each entry of `first` is in `other`: the entries equal in both (`same`), matched in their
|
||||
/// stacking order (a longest common subsequence, so the matches never cross), by index into
|
||||
/// `other`; `None` where `other` has no equal entry left for it.
|
||||
pub fn align<T>(first: &[T], other: &[T], same: impl Fn(&T, &T) -> bool) -> Vec<Option<usize>> {
|
||||
let (n, m) = (first.len(), other.len());
|
||||
let mut out = vec![None; n];
|
||||
if n == 0 || m == 0 || n > MAX_ALIGNED || m > MAX_ALIGNED {
|
||||
return out;
|
||||
}
|
||||
// `len[i][j]`: the longest match of `first[i..]` and `other[j..]`.
|
||||
let w = m + 1;
|
||||
let mut len = vec![0u16; (n + 1) * w];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
let here = match (first.get(i), other.get(j)) {
|
||||
(Some(a), Some(b)) if same(a, b) => len.get((i + 1) * w + j + 1).copied().unwrap_or(0).saturating_add(1),
|
||||
_ => len.get((i + 1) * w + j).copied().unwrap_or(0).max(len.get(i * w + j + 1).copied().unwrap_or(0)),
|
||||
};
|
||||
if let Some(c) = len.get_mut(i * w + j) {
|
||||
*c = here;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < n && j < m {
|
||||
let at = |a: usize, b: usize| len.get(a * w + b).copied().unwrap_or(0);
|
||||
match (first.get(i), other.get(j)) {
|
||||
(Some(a), Some(b)) if same(a, b) && at(i, j) == at(i + 1, j + 1).saturating_add(1) => {
|
||||
if let Some(o) = out.get_mut(i) {
|
||||
*o = Some(j);
|
||||
}
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
_ if at(i + 1, j) >= at(i, j + 1) => i += 1,
|
||||
_ => j += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What several objects' appearances share, as the Appearance panel lists a mixed selection: the
|
||||
/// fills and strokes, and the object-level effects, that every one of them has an equal copy of, in
|
||||
/// stacking order ([`align`]), each with where it is in every object's own stack.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SharedStack {
|
||||
/// Each shared fill or stroke, in the first object's paint order: its index in each object's
|
||||
/// `items` (the first object's first).
|
||||
pub items: Vec<Vec<usize>>,
|
||||
/// Each shared object-level effect: its index in each object's `effects`.
|
||||
pub effects: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl SharedStack {
|
||||
/// The rows `appearances` share (see [`SharedStack`]). One appearance shares all of itself.
|
||||
pub fn of(appearances: &[&Appearance]) -> Self {
|
||||
let Some((first, rest)) = appearances.split_first() else { return Self::default() };
|
||||
fn shared<T>(first: &[T], rest: Vec<&[T]>, same: impl Fn(&T, &T) -> bool + Copy) -> Vec<Vec<usize>> {
|
||||
let maps: Vec<Vec<Option<usize>>> = rest.iter().map(|o| align(first, o, same)).collect();
|
||||
(0..first.len())
|
||||
.filter_map(|i| {
|
||||
let mut at = vec![i];
|
||||
for m in &maps {
|
||||
at.push((*m.get(i)?)?);
|
||||
}
|
||||
Some(at)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Self {
|
||||
items: shared(&first.items, rest.iter().map(|a| a.items.as_slice()).collect(), |a, b| a == b),
|
||||
effects: shared(&first.effects, rest.iter().map(|a| a.effects.as_slice()).collect(), |a, b| a == b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the first object's fill or stroke `first` is in object `k`'s stack, when it is shared.
|
||||
pub fn item_in(&self, first: usize, k: usize) -> Option<usize> {
|
||||
self.items.iter().find(|at| at.first() == Some(&first))?.get(k).copied()
|
||||
}
|
||||
|
||||
/// Where the first object's object-level effect `first` is in object `k`'s effects, when shared.
|
||||
pub fn effect_in(&self, first: usize, k: usize) -> Option<usize> {
|
||||
self.effects.iter().find(|at| at.first() == Some(&first))?.get(k).copied()
|
||||
}
|
||||
|
||||
/// Is the first object's fill or stroke `i` shared?
|
||||
pub fn has_item(&self, i: usize) -> bool {
|
||||
self.items.iter().any(|at| at.first() == Some(&i))
|
||||
}
|
||||
|
||||
/// Is the first object's object-level effect `k` shared?
|
||||
pub fn has_effect(&self, k: usize) -> bool {
|
||||
self.effects.iter().any(|at| at.first() == Some(&k))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -311,3 +311,42 @@ fn contents_slot_follows_item_edits_and_round_trips() {
|
||||
assert_eq!(old.contents_index, None);
|
||||
assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index"));
|
||||
}
|
||||
|
||||
/// The issue's case (#4174): two circles with different fills, the same stroke and the same
|
||||
/// effect share the stroke and the effect, not the fills; a shared stroke at another place in one
|
||||
/// stack maps to its own index there; nothing shared shares nothing; equal stacks share all.
|
||||
#[test]
|
||||
fn mixed_appearances_share_their_equal_rows() {
|
||||
let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 11.0));
|
||||
let fill = |c: Color| AppearanceItem::Fill(FillLayer::new(Paint::solid(c)));
|
||||
let feather = Effect { id: "stylize.feather".into(), params: serde_json::json!({"radius": 5}), visible: true };
|
||||
let a = Appearance { items: vec![fill(Color::rgb(1.0, 1.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() };
|
||||
let b = Appearance { items: vec![fill(Color::rgb(1.0, 0.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() };
|
||||
let shared = SharedStack::of(&[&a, &b]);
|
||||
assert_eq!(shared.items, vec![vec![1, 1]], "the stroke, not the fills");
|
||||
assert_eq!(shared.effects, vec![vec![0, 0]]);
|
||||
// The stroke under an extra fill in the second object.
|
||||
let c = Appearance {
|
||||
items: vec![fill(Color::BLACK), fill(Color::WHITE), stroke.clone()],
|
||||
effects: vec![feather.clone()],
|
||||
..Appearance::default()
|
||||
};
|
||||
let shared = SharedStack::of(&[&a, &c]);
|
||||
assert_eq!(shared.items, vec![vec![1, 2]]);
|
||||
assert_eq!(shared.item_in(1, 1), Some(2));
|
||||
assert_eq!(shared.item_in(0, 1), None);
|
||||
// Nothing alike.
|
||||
let d = Appearance { items: vec![fill(Color::WHITE)], ..Appearance::default() };
|
||||
let none = SharedStack::of(&[&a, &d]);
|
||||
assert!(none.items.is_empty() && none.effects.is_empty());
|
||||
// Equal stacks share everything, each row where it is.
|
||||
assert_eq!(SharedStack::of(&[&a, &a.clone(), &a.clone()]).items, vec![vec![0, 0, 0], vec![1, 1, 1]]);
|
||||
}
|
||||
|
||||
/// Matches keep their stacking order: two equal rows in the opposite order match once.
|
||||
#[test]
|
||||
fn aligned_rows_never_cross() {
|
||||
assert_eq!(align(&[1, 2], &[2, 1], |a, b| a == b).iter().filter(|m| m.is_some()).count(), 1);
|
||||
assert_eq!(align(&[1, 2, 3], &[9, 1, 3], |a, b| a == b), vec![Some(1), None, Some(2)]);
|
||||
assert_eq!(align::<i32>(&[], &[1], |a, b| a == b), Vec::<Option<usize>>::new());
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::appearance::SharedStack;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
@@ -74,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} edit one fill/stroke of each target object's own appearance stack",
|
||||
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack",
|
||||
has_doc,
|
||||
set_item
|
||||
),
|
||||
@@ -83,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Item",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?} remove those fills/strokes from each target object's own stack",
|
||||
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack",
|
||||
has_doc,
|
||||
remove_item
|
||||
),
|
||||
@@ -101,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Item",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?|id?, target?} copy fills/strokes with their effects",
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it; not with aligned rows of a mixed selection), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects",
|
||||
has_doc,
|
||||
duplicate_item
|
||||
),
|
||||
@@ -110,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reorder Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{from: paint-order item index | \"contents\", to, contents?: count, ids?|id?, target?} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
|
||||
"{from: paint-order item index | \"contents\", to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
|
||||
has_doc,
|
||||
move_item
|
||||
),
|
||||
@@ -203,8 +204,11 @@ pub(crate) enum ItemTarget {
|
||||
/// The topmost fill or stroke (for effects: the object-level effects).
|
||||
Top,
|
||||
/// Item `index`. `explicit`: named by the `item` param, so it must exist and be of the edited
|
||||
/// kind; otherwise it is the panel's active item, which applies only where it fits.
|
||||
Item { index: usize, explicit: bool },
|
||||
/// kind; otherwise it is the panel's active item, which applies only where it fits. `aligned`:
|
||||
/// `index` is a row of the first target's stack, and each other target edits the item equal to
|
||||
/// it there ([`ItemTarget::per_target`]): the active item always is (it is a row of the first
|
||||
/// selected object's stack), an explicit one with `aligned: true`.
|
||||
Item { index: usize, explicit: bool, aligned: bool },
|
||||
}
|
||||
|
||||
/// Parse the `item` param of `cmd` (see the module docs).
|
||||
@@ -218,11 +222,11 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re
|
||||
Some(Value::Null) => Ok(ItemTarget::Top),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.map(|i| ItemTarget::Item { index: index(i), explicit: true })
|
||||
.map(|i| ItemTarget::Item { index: index(i), explicit: true, aligned: aligned(p) })
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
|
||||
// The active item is a row of the first selected object's own stack.
|
||||
None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top),
|
||||
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })),
|
||||
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,17 +244,30 @@ impl ItemTarget {
|
||||
/// of its own kind, so a fill row leaves stroke edits on the topmost stroke of the painted
|
||||
/// leaves (as without an active item) and vice versa. Explicit items are kept (and checked).
|
||||
pub(crate) fn of_kind(self, s: &Session, fill: bool) -> Self {
|
||||
let ItemTarget::Item { index, explicit: false } = self else { return self };
|
||||
let ItemTarget::Item { index, explicit: false, .. } = self else { return self };
|
||||
let fits =
|
||||
s.active().and_then(|st| st.doc.node(*st.selection.subjects().first()?)?.appearance.items.get(index).map(|it| it.is_fill() == fill));
|
||||
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
|
||||
/// [`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>> {
|
||||
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()] };
|
||||
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()
|
||||
}
|
||||
|
||||
/// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one.
|
||||
pub(crate) fn resolve(self, ap: &Appearance, fill: bool, cmd: &str) -> Result<Option<usize>> {
|
||||
match self {
|
||||
ItemTarget::Top => Ok(None),
|
||||
ItemTarget::Item { index, explicit } => match ap.item_of_kind(Some(index), fill) {
|
||||
ItemTarget::Item { index, explicit, .. } => match ap.item_of_kind(Some(index), fill) {
|
||||
None if explicit => Err(bad(
|
||||
cmd,
|
||||
match ap.items.get(index) {
|
||||
@@ -268,12 +285,44 @@ impl ItemTarget {
|
||||
match self {
|
||||
ItemTarget::Top => Ok(None),
|
||||
ItemTarget::Item { index, .. } if index < ap.items.len() => Ok(Some(index)),
|
||||
ItemTarget::Item { index, explicit: true } => Err(bad(cmd, format!("no appearance item {index}"))),
|
||||
ItemTarget::Item { index, explicit: true, .. } => Err(bad(cmd, format!("no appearance item {index}"))),
|
||||
ItemTarget::Item { explicit: false, .. } => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `p` say its item and effect indices are rows of the first target's stack (`aligned`),
|
||||
/// which each other target edits where it has them (see [`ItemTarget::per_target`])?
|
||||
pub(crate) fn aligned(p: &Value) -> bool {
|
||||
p.get("aligned").and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// What the appearances of `ids` share, when there are several of them and they all exist.
|
||||
pub(crate) fn shared_stack(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Option<SharedStack> {
|
||||
if ids.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let aps: Vec<&Appearance> = ids.iter().map(|id| d.node(*id).map(|n| &*n.appearance)).collect::<Option<_>>()?;
|
||||
Some(SharedStack::of(&aps))
|
||||
}
|
||||
|
||||
/// Do the appearances of `ids` differ (several objects whose stacks aren't all equal)?
|
||||
pub(crate) fn appearances_differ(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> bool {
|
||||
let mut aps = ids.iter().filter_map(|id| d.node(*id)).map(|n| &n.appearance);
|
||||
let Some(first) = aps.next() else { return false };
|
||||
aps.any(|a| a != first)
|
||||
}
|
||||
|
||||
/// Indices `idx` of the first target's items (aligned with `aligned`) in each of `ids`: where
|
||||
/// each target has the item equal to the first's (see [`SharedStack`]), else the same indices.
|
||||
/// A target missing one of them gets only those it has.
|
||||
pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec<Vec<usize>> {
|
||||
match shared_stack(d, ids).filter(|sh| aligned && idx.iter().all(|i| sh.has_item(*i))) {
|
||||
Some(sh) => (0..ids.len()).map(|k| idx.iter().filter_map(|i| sh.item_in(*i, k)).collect()).collect(),
|
||||
None => vec![idx.to_vec(); ids.len()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the `target` param asks for the contents of groups and layers (see the module docs).
|
||||
fn targets_contents(p: &Value) -> Result<bool> {
|
||||
match p.get("target") {
|
||||
@@ -352,9 +401,11 @@ pub(crate) fn edit_items_then(
|
||||
return Ok(());
|
||||
}
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = item.per_target(&before, ids);
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut edited = Vec::new();
|
||||
for id in ids {
|
||||
for (id, item) in ids.iter().zip(&per) {
|
||||
let Some(item) = item else { continue };
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let index = item.resolve(&n.appearance, fill, cmd)?;
|
||||
f(n, index)?;
|
||||
@@ -562,8 +613,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// When every object comes out as it was (for example, when none has item `index`), the document
|
||||
// and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &[idx], aligned(p));
|
||||
super::overprint::edit_counted(s, "Appearance", |d| {
|
||||
for id in &ids {
|
||||
for (id, at) in ids.iter().zip(&per) {
|
||||
let Some(&idx) = at.first() else { continue };
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if p.get("weight").is_none()
|
||||
&& let Some(pa) = &paint
|
||||
@@ -625,9 +678,12 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// When no object has an item at any of the indices, the document and its undo history stay as
|
||||
// they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = items_per_target(&before, &ids, &idx, aligned(p));
|
||||
super::overprint::edit_counted(s, "Remove Item", |d| {
|
||||
for id in &ids {
|
||||
for (id, idx) in ids.iter().zip(&per) {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
let mut idx = idx.clone();
|
||||
idx.sort_unstable();
|
||||
// From the top down, so the lower indices stay valid.
|
||||
for &i in idx.iter().rev() {
|
||||
n.appearance.remove_item(i);
|
||||
@@ -650,11 +706,18 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = require_targets(s, p, C)?;
|
||||
// Where the first object's copy landed (the active item lives in the first object's stack).
|
||||
let mut first_at = None;
|
||||
// Aligned rows of a mixed selection are copied right above themselves in each object.
|
||||
if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
|
||||
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
|
||||
}
|
||||
let per = items_per_target(&s.doc()?.doc, &ids, &idx, aligned(p));
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
for id in &ids {
|
||||
for (id, idx) in ids.iter().zip(&per) {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let ap = &mut n.appearance;
|
||||
let mut idx = idx.clone();
|
||||
idx.sort_unstable();
|
||||
// From the top down, so the lower indices stay valid.
|
||||
for &i in idx.iter().rev() {
|
||||
if let Some(item) = ap.items.get(i).cloned() {
|
||||
@@ -697,6 +760,10 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return move_contents(s, &ids, to);
|
||||
}
|
||||
let from = index_param(p, "from", C)?;
|
||||
// Where an aligned row would go in each of several different stacks isn't one place.
|
||||
if aligned(p) && appearances_differ(&s.doc()?.doc, &ids) {
|
||||
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
|
||||
}
|
||||
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
|
||||
let mut landed = to;
|
||||
// When every object's stack comes out as it was, the document and its undo history stay as they
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at};
|
||||
use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_target, item_target_at, shared_stack};
|
||||
use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
use super::*;
|
||||
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Effect",
|
||||
[],
|
||||
None,
|
||||
"{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
|
||||
"{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
|
||||
has_doc,
|
||||
remove
|
||||
),
|
||||
@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
[],
|
||||
None,
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"3D Lighting Preset",
|
||||
[],
|
||||
None,
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}",
|
||||
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}",
|
||||
has_doc,
|
||||
lighting_preset
|
||||
),
|
||||
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate Effect",
|
||||
[],
|
||||
None,
|
||||
"{index: int, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} insert a copy of the effect right after it → {ids}",
|
||||
"{index: int, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} insert a copy of the effect right after it → {ids}",
|
||||
has_doc,
|
||||
duplicate
|
||||
),
|
||||
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Effect",
|
||||
[],
|
||||
None,
|
||||
"{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\"} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
|
||||
"{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
|
||||
has_doc,
|
||||
move_effect
|
||||
),
|
||||
@@ -101,6 +101,35 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
/// One undo step (`label`) running `f` on the effect list `item` addresses on each target (an
|
||||
/// appearance item's own effects, or the object's). Errors with `none` when `f` changed no object;
|
||||
/// returns the changed ids.
|
||||
/// Each target of an effect edit aimed at `item` and effect `index`: the object, the item whose
|
||||
/// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see
|
||||
/// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target
|
||||
/// edits its equal item, or its equal object-level effect; a target without them is left out.
|
||||
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 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() {
|
||||
let (Some(item), Some(n)) = (item, d.node(*id)) else { continue };
|
||||
let at = item.effects_item(&n.appearance, cmd)?;
|
||||
// An object-level effect the targets share is, in each, where it is there.
|
||||
let first = roots.first().and_then(|f| d.node(*f));
|
||||
let first_level = first.map(|f| item.effects_item(&f.appearance, cmd)).transpose()?.flatten();
|
||||
let index = match (&shared, at, first_level) {
|
||||
(Some(sh), None, None) if sh.has_effect(index) => match sh.effect_in(index, k) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
},
|
||||
_ => index,
|
||||
};
|
||||
out.push((*id, at, index));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn edit_effects(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
@@ -108,17 +137,17 @@ fn edit_effects(
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
none: &str,
|
||||
mut f: impl FnMut(&mut Vec<Effect>) -> bool,
|
||||
index: usize,
|
||||
mut f: impl FnMut(&mut Vec<Effect>, usize) -> bool,
|
||||
) -> Result<Vec<NodeId>> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
let targets = effect_targets(s, p, item, index, cmd)?;
|
||||
s.edit(label, |d, _| {
|
||||
let mut done = vec![];
|
||||
for id in &roots {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let index = item.effects_item(&n.appearance, cmd)?;
|
||||
if n.appearance.effects_mut(index).is_some_and(&mut f) {
|
||||
for &(id, at, index) in &targets {
|
||||
let Some(n) = d.node_mut(id) else { continue };
|
||||
if n.appearance.effects_mut(at).is_some_and(|fx| f(fx, index)) {
|
||||
check_revolve_count(n, cmd)?;
|
||||
done.push(*id);
|
||||
done.push(id);
|
||||
}
|
||||
}
|
||||
if done.is_empty() {
|
||||
@@ -185,7 +214,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let mut index = 0;
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| {
|
||||
fx.push(effect.clone());
|
||||
index = fx.len() - 1;
|
||||
true
|
||||
@@ -241,7 +270,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.remove";
|
||||
let index = index_param(p, "index", C)?;
|
||||
let item = item_target(s, p, C)?;
|
||||
let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), index, |fx, index| {
|
||||
(index < fx.len()).then(|| fx.remove(index)).is_some()
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
@@ -251,12 +280,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.duplicate";
|
||||
let index = index_param(p, "index", C)?;
|
||||
let item = item_target(s, p, C)?;
|
||||
let ids = edit_effects(s, p, item, C, "Duplicate Effect", &format!("{C}: no effect at index {index}"), |fx| match fx.get(index).cloned() {
|
||||
Some(e) => {
|
||||
fx.insert(index + 1, e);
|
||||
true
|
||||
let ids = edit_effects(s, p, item, C, "Duplicate Effect", &format!("{C}: no effect at index {index}"), index, |fx, index| {
|
||||
match fx.get(index).cloned() {
|
||||
Some(e) => {
|
||||
fx.insert(index + 1, e);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
None => false,
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
@@ -269,6 +300,10 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src };
|
||||
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
let roots = appearance_targets(s, p)?;
|
||||
// Where an aligned effect would go in each of several different stacks isn't one place.
|
||||
if aligned(p) && super::appearance::appearances_differ(&s.doc()?.doc, &roots) {
|
||||
return Err(bad(C, "effects of objects whose appearances differ can't be moved together"));
|
||||
}
|
||||
// Where the effect landed in the first object.
|
||||
let mut landed = None;
|
||||
let ids = s.edit(if copy { "Duplicate Effect" } else { "Move Effect" }, |d, _| {
|
||||
@@ -298,14 +333,12 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// (none when `p` doesn't say which: running it reports why).
|
||||
pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
|
||||
const C: &str = "effect.setParams";
|
||||
let (Ok(index), Ok(item), Ok(ids), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), appearance_targets(s, p), s.doc()) else {
|
||||
let (Ok(index), Ok(item), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), s.doc()) else {
|
||||
return vec![];
|
||||
};
|
||||
let effect = |id: &NodeId| {
|
||||
let ap = &st.doc.node(*id)?.appearance;
|
||||
Some(ap.effects_at(item.effects_item(ap, C).ok()?)?.get(index)?.id.clone())
|
||||
};
|
||||
ids.iter().filter_map(effect).collect()
|
||||
let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] };
|
||||
let effect = |&(id, at, index): &(NodeId, Option<usize>, usize)| Some(st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id.clone());
|
||||
targets.iter().filter_map(effect).collect()
|
||||
}
|
||||
|
||||
fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -317,9 +350,8 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = item_target(s, p, C)?;
|
||||
// Validate the whole selection before changing any object. An unrelated effect or
|
||||
// a missing index must not leave just some objects with a new lighting rig.
|
||||
for id in appearance_targets(s, p)? {
|
||||
for (id, slot, index) in effect_targets(s, p, item, index, C)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let slot = item.effects_item(&n.appearance, C)?;
|
||||
let effect = n.appearance.effects_at(slot).and_then(|fx| fx.get(index));
|
||||
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
|
||||
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
|
||||
@@ -359,9 +391,8 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if edited.iter().any(|e| texture_load_effect(e)) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for id in appearance_targets(s, p)? {
|
||||
for (id, target, index) in effect_targets(s, p, item, index, C)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let target = item.effects_item(&n.appearance, C)?;
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
|
||||
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
|
||||
@@ -377,7 +408,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| {
|
||||
let Some(e) = fx.get_mut(index) else { return false };
|
||||
if let Some(id) = &warp_id {
|
||||
e.id.clone_from(id);
|
||||
|
||||
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transparency",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\"} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)",
|
||||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)",
|
||||
has_doc,
|
||||
transparency
|
||||
),
|
||||
@@ -742,7 +742,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q.remove("item");
|
||||
q.remove("id");
|
||||
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
|
||||
let ItemTarget::Item { index, explicit } = item else {
|
||||
let ItemTarget::Item { index, explicit, aligned } = item else {
|
||||
return s.execute("object.setProps", &Value::Object(q));
|
||||
};
|
||||
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level.
|
||||
@@ -766,7 +766,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if opacity.is_some() || blend.is_some() {
|
||||
let ids: Vec<u64> = item_ids.iter().map(|id| id.0).collect();
|
||||
s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend }))?;
|
||||
s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend, "aligned": aligned }))?;
|
||||
}
|
||||
if q.keys().all(|k| k == "ids") {
|
||||
return ok();
|
||||
|
||||
@@ -523,3 +523,65 @@ fn clear_and_reduce_keep_a_groups_stack_empty() {
|
||||
// The members keep their own appearance.
|
||||
assert_eq!(node(&s, a).appearance, members);
|
||||
}
|
||||
|
||||
/// #4174: two circles with different fills, the same 11 pt blue stroke and the same Feather,
|
||||
/// both selected. Their default stacks are `[Fill, Stroke]`; the second gets a second fill under
|
||||
/// its stroke when `extra_fill`, so the shared stroke sits at another index there.
|
||||
fn mixed_circles(s: &mut Session, extra_fill: bool) -> (NodeId, NodeId) {
|
||||
let mut circle = |x: f64, color: &str| {
|
||||
let id = NodeId(run(s, "shape.ellipse", json!({"x": x, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"ids": [id.0], "color": color}));
|
||||
run(s, "appearance.setItem", json!({"ids": [id.0], "index": 1, "color": "#0000ff", "weight": 11}));
|
||||
run(s, "effect.apply", json!({"ids": [id.0], "effect": "stylize.feather", "item": null}));
|
||||
id
|
||||
};
|
||||
let (a, b) = (circle(0.0, "#ffff00"), circle(150.0, "#ff0000"));
|
||||
if extra_fill {
|
||||
// Another fill on top of b's fill and moved under its stroke: [Fill, Fill, Stroke].
|
||||
run(s, "appearance.addFill", json!({"ids": [b.0]}));
|
||||
run(s, "appearance.moveItem", json!({"ids": [b.0], "from": 2, "to": 1}));
|
||||
run(s, "appearance.setItem", json!({"ids": [b.0], "index": 1, "color": "#00ff00"}));
|
||||
}
|
||||
run(s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// The shared stroke row edits the stroke of both circles, whatever its index in each, as one undo
|
||||
/// step, leaving their fills alone; undo restores both.
|
||||
#[test]
|
||||
fn a_shared_row_edits_every_selected_object() {
|
||||
for extra in [false, true] {
|
||||
let mut s = session();
|
||||
let (a, b) = mixed_circles(&mut s, extra);
|
||||
let b_stroke = if extra { 2 } else { 1 };
|
||||
let fills = (node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone());
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// The panel lists the first object's rows: the stroke is a's item 1.
|
||||
run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 4, "aligned": true}));
|
||||
assert_eq!(stroke(&node(&s, a), 1).width, 4.0);
|
||||
assert_eq!(stroke(&node(&s, b), b_stroke).width, 4.0, "extra fill: {extra}");
|
||||
assert_eq!(node(&s, a).appearance.items[0], fills.0[0], "a's fill untouched");
|
||||
assert_eq!(node(&s, b).appearance.items.len(), fills.1.len());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone()), fills, "undo restores both");
|
||||
// The active row (a's item 1) carries paint edits from other panels to both strokes too.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
|
||||
run(&mut s, "stroke.set", json!({"weight": 7}));
|
||||
assert_eq!(stroke(&node(&s, b), b_stroke).width, 7.0, "extra fill: {extra}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing the shared effect removes it from both; adding a fill adds one to both.
|
||||
#[test]
|
||||
fn shared_effects_and_new_fills_reach_every_selected_object() {
|
||||
let mut s = session();
|
||||
let (a, b) = mixed_circles(&mut s, true);
|
||||
run(&mut s, "effect.remove", json!({"index": 0, "item": null, "aligned": true}));
|
||||
assert!(node(&s, a).appearance.effects.is_empty() && node(&s, b).appearance.effects.is_empty());
|
||||
let (na, nb) = (node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len());
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (na + 1, nb + 1));
|
||||
// Rows of different stacks aren't reordered together.
|
||||
assert!(s.execute("appearance.moveItem", &json!({"from": 1, "to": 0, "aligned": true})).is_err());
|
||||
}
|
||||
|
||||
@@ -1813,6 +1813,7 @@ font-technology Variable Променливи
|
||||
Miter Limit: Ограничение на острите ъгли:
|
||||
Mixed Appearances Смесени изгледи
|
||||
Mixed Objects Смесени обекти
|
||||
Mixed Смесено
|
||||
Mode: Режим:
|
||||
Model Модел
|
||||
Modified Модифицирано
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1359,6 +1359,7 @@ lighting Light Světlo
|
||||
Hide Thumbnail Skrýt miniaturu
|
||||
Mixed Appearances Smíšené vzhledy
|
||||
Mixed Objects Smíšené objekty
|
||||
Mixed Smíšené
|
||||
No options Žádné možnosti
|
||||
No Selection Nic není vybráno
|
||||
Offset X Posun X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1276,6 +1276,7 @@ lighting Light Lys
|
||||
Hide Thumbnail Skjul miniatur
|
||||
Mixed Appearances Blandede udseender
|
||||
Mixed Objects Blandede objekter
|
||||
Mixed Blandet
|
||||
No options Ingen indstillinger
|
||||
No Selection Ingen markering
|
||||
Offset X Forskydning X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1865,6 +1865,7 @@ file-filter Templates Vorlagen
|
||||
Miter Limit: Gehrungsgrenze:
|
||||
Mixed Appearances Gemischtes Aussehen
|
||||
Mixed Objects Gemischte Objekte
|
||||
Mixed Gemischt
|
||||
Mode: Modus:
|
||||
Model Modell
|
||||
Modified Geändert
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2616,6 +2616,7 @@ theme Appearance Εμφάνιση
|
||||
Miter Limit: Όριο οξείας σύνδεσης:
|
||||
Mixed Appearances Μεικτές εμφανίσεις
|
||||
Mixed Objects Μεικτά αντικείμενα
|
||||
Mixed Μικτό
|
||||
Modified Τροποποιήθηκε
|
||||
Modified since it was read: {path} Τροποποιήθηκε μετά την ανάγνωση: {path}
|
||||
Modified: Τροποποιήθηκε:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2070 and column 19.
|
@@ -1863,6 +1863,7 @@ font-technology Variable Variables
|
||||
Miter Limit: Límite de inglete:
|
||||
Mixed Appearances Apariencias mixtas
|
||||
Mixed Objects Objetos mixtos
|
||||
Mixed Mixto
|
||||
Mode: Modo:
|
||||
Model Modelo
|
||||
Modified Modificado
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1333,6 +1333,7 @@ lighting Light Valo
|
||||
Hide Thumbnail Piilota pikkukuva
|
||||
Mixed Appearances Eri ulkoasuja
|
||||
Mixed Objects Eri objekteja
|
||||
Mixed Sekalainen
|
||||
No options Ei asetuksia
|
||||
No Selection Ei valintaa
|
||||
Offset X Siirtymä X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1862,6 +1862,7 @@ font-technology Variable Variables
|
||||
Miter Limit: Limite de pointe :
|
||||
Mixed Appearances Aspects mixtes
|
||||
Mixed Objects Objets mixtes
|
||||
Mixed Mixte
|
||||
Mode: Mode :
|
||||
Model Modèle
|
||||
Modified Modifié
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2365,6 +2365,7 @@ file-filter Templates Sablonok
|
||||
Miter Limit: Hegyesszög-határ:
|
||||
Mixed Appearances Vegyes megjelenések
|
||||
Mixed Objects Vegyes objektumok
|
||||
Mixed Vegyes
|
||||
Mobile Mobil
|
||||
Mode Mód
|
||||
Mode: Mód:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1276,6 +1276,7 @@ lighting Light Cahaya
|
||||
Hide Thumbnail Sembunyikan thumbnail
|
||||
Mixed Appearances Penampilan campuran
|
||||
Mixed Objects Objek campuran
|
||||
Mixed Campuran
|
||||
No options Tidak ada opsi
|
||||
No Selection Tidak ada seleksi
|
||||
Offset X Offset X
|
||||
|
||||
|
@@ -1890,6 +1890,7 @@ file-filter Templates Modelli
|
||||
Miter Limit: Limite angolo vivo:
|
||||
Mixed Appearances Aspetti misti
|
||||
Mixed Objects Oggetti misti
|
||||
Mixed Misto
|
||||
Mode: Modalità:
|
||||
Model Modello
|
||||
Modified Modificato
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3510,6 +3510,7 @@ file-filter Templates テンプレート
|
||||
Miter Limit 角の比率
|
||||
Miter Limit: 角の比率:
|
||||
Mixed Objects 混在したオブジェクト
|
||||
Mixed 混在
|
||||
Model モデル
|
||||
Move All Objects すべてのオブジェクトを移動
|
||||
N pt per N pt ごと
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1192,6 +1192,7 @@ lighting Light სინათლე
|
||||
Hide Thumbnail ესკიზის დამალვა
|
||||
Mixed Appearances შერეული იერსახეები
|
||||
Mixed Objects შერეული ობიექტები
|
||||
Mixed შერეული
|
||||
No options პარამეტრები არ არის
|
||||
No Selection მონიშნული არაფერია
|
||||
Offset X X-წანაცვლება
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3503,6 +3503,7 @@ file-filter Templates 템플릿
|
||||
Miter Limit 모퉁이 한계
|
||||
Miter Limit: 모퉁이 한계:
|
||||
Mixed Objects 혼합 개체
|
||||
Mixed 혼합
|
||||
Model 모델
|
||||
Move All Objects 모든 개체 이동
|
||||
N pt per 당 N pt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1290,6 +1290,7 @@ lighting Light Licht
|
||||
Hide Thumbnail Miniatuur verbergen
|
||||
Mixed Appearances Gemengde vormgevingen
|
||||
Mixed Objects Gemengde objecten
|
||||
Mixed Gemengd
|
||||
No options Geen opties
|
||||
No Selection Geen selectie
|
||||
Offset X Verschuiving X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1273,6 +1273,7 @@ lighting Light Światło
|
||||
Hide Thumbnail Ukryj miniaturę
|
||||
Mixed Appearances Różne wyglądy
|
||||
Mixed Objects Różne obiekty
|
||||
Mixed Mieszane
|
||||
No options Brak opcji
|
||||
No Selection Brak zaznaczenia
|
||||
Offset X Odsunięcie X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4455,6 +4455,7 @@ axis Both Ambos
|
||||
Miter Limit Limite da Ponta
|
||||
Miter Limit: Limite da Ponta:
|
||||
Mixed Objects Objetos Mistos
|
||||
Mixed Misto
|
||||
Mojikumi Mojikumi
|
||||
Move All Objects Mover Todos os Objetos
|
||||
N pt per N pt por
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1888,6 +1888,7 @@ file-filter Templates Шаблоны
|
||||
Miter Limit: Предел митры:
|
||||
Mixed Appearances Смешанное оформление
|
||||
Mixed Objects Смешанные объекты
|
||||
Mixed Смешанное
|
||||
Mode: Режим:
|
||||
Model Модель
|
||||
Modified Изменено
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1813,6 +1813,7 @@ font-technology Variable Variabla
|
||||
Miter Limit: Spetsgräns:
|
||||
Mixed Appearances Blandade utseenden
|
||||
Mixed Objects Blandade objekt
|
||||
Mixed Blandat
|
||||
Mode: Läge:
|
||||
Model Modell
|
||||
Modified Ändrad
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1710,6 +1710,7 @@ font-technology Variable Değişken
|
||||
Miter Limit: Gönye Sınırı:
|
||||
Mixed Appearances Karışık Görünümler
|
||||
Mixed Objects Karışık Nesneler
|
||||
Mixed Karışık
|
||||
Mode: Mod:
|
||||
Model Modeli
|
||||
Modified Değiştirildi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1884,6 +1884,7 @@ file-filter Templates Шаблони
|
||||
Miter Limit: Межа гострого з’єднання:
|
||||
Mixed Appearances Змішане оформлення
|
||||
Mixed Objects Змішані об’єкти
|
||||
Mixed Змішане
|
||||
Mode: Режим:
|
||||
Model Модель
|
||||
Modified Змінено
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 749 and column 40.
|
@@ -2177,6 +2177,7 @@ file-filter Templates Mẫu
|
||||
Miter Limit: Giới hạn góc nhọn:
|
||||
Mixed Appearances Diện mạo hỗn hợp
|
||||
Mixed Objects Đối tượng hỗn hợp
|
||||
Mixed Hỗn hợp
|
||||
Mode Chế độ
|
||||
Mode: Chế độ:
|
||||
Model Mô hình
|
||||
|
||||
|
@@ -1770,6 +1770,7 @@ file-filter Templates 模板
|
||||
Miter Limit: 斜接限制:
|
||||
Mixed Appearances 混合外观
|
||||
Mixed Objects 混杂对象
|
||||
Mixed 混合
|
||||
Mode: 模式:
|
||||
Model 模型
|
||||
Modified 已修改
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2648,6 +2648,7 @@ file-filter Templates 範本
|
||||
Miter Limit: 尖角限度:
|
||||
Mixed Appearances 混合外觀
|
||||
Mixed Objects 混雜物件
|
||||
Mixed 混合
|
||||
Mode: 模式:
|
||||
Model 模型
|
||||
Modified 已修改
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -255,8 +255,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
select_row(app, ui.ctx(), Sel::None);
|
||||
}
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
|
||||
let mixed = mixed_appearances(app);
|
||||
let label = if mixed { tl!("Mixed Appearances") } else { tl!(object_label(app)) };
|
||||
let mix = mixed(app);
|
||||
let mixed = mix.is_some();
|
||||
let label = tl!(object_label(app));
|
||||
// A linked object names its graphic style ("Rectangle: Sunshine"), and so does new art
|
||||
// with nothing selected.
|
||||
let style = match app.session.selection_graphic_style() {
|
||||
@@ -270,7 +271,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
if !hide_thumb {
|
||||
let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0));
|
||||
let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone());
|
||||
// Objects whose fills differ show no one of them.
|
||||
let fill = match &node {
|
||||
Some(_) if mix.as_ref().is_some_and(|m| m.fills_differ) => Paint::None,
|
||||
Some(n) => n.appearance.fill_paint(),
|
||||
None => app.session.paint.fill.clone(),
|
||||
};
|
||||
chip(ui, th, &fill);
|
||||
if let Some(n) = node.as_ref().filter(|_| !mixed) {
|
||||
thumbnail_drag(ui, th, n.id);
|
||||
@@ -278,9 +284,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), &label, true);
|
||||
match &node {
|
||||
// Objects that differ have no common stack to list.
|
||||
Some(_) if mixed => {}
|
||||
Some(n) => stack(app, ui, n, sel, r),
|
||||
// Objects that differ list what they share, under a note that they differ.
|
||||
Some(n) if let Some(m) = &mix => {
|
||||
let (mr, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::hover());
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let format = egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text_dim, italics: true, ..Default::default() };
|
||||
job.append(tl!("Mixed Appearances"), 0.0, format);
|
||||
let g = ui.painter().layout_job(job);
|
||||
ui.painter().galley(mr.left_center() + vec2(EYE_W + 24.0, -g.size().y / 2.0), g, t.text_dim);
|
||||
if !(m.shared.items.is_empty() && m.shared.effects.is_empty()) {
|
||||
stack(app, ui, n, sel, r, Some(m));
|
||||
}
|
||||
}
|
||||
Some(n) => stack(app, ui, n, sel, r, None),
|
||||
None => default_stack(app, ui),
|
||||
}
|
||||
// Spacer row like Illustrator's empty tail.
|
||||
@@ -306,23 +322,54 @@ fn first_subject(app: &VectorcraftApp) -> Option<Node> {
|
||||
st.selection.subjects().first().and_then(|id| st.doc.node(*id)).cloned()
|
||||
}
|
||||
|
||||
/// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects"
|
||||
/// or the kind ("Path", "Type", "Layer"…) of the object, or targeted layer, the panel lists.
|
||||
/// The selection as the Control bar and the object row name it: "No Selection", the kind ("Path",
|
||||
/// "Type", "Layer"…) of the object, or targeted layer, the panel lists, or of every selected
|
||||
/// object when they are all of one kind, else "Mixed Objects".
|
||||
pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str {
|
||||
let Some(st) = app.session.active() else { return "No Selection" };
|
||||
match st.selection.subjects() {
|
||||
[] => "No Selection",
|
||||
[id] => st.doc.node(*id).map_or("No Selection", Node::kind_label),
|
||||
_ => "Mixed Objects",
|
||||
}
|
||||
let mut kinds = st.selection.subjects().iter().map(|id| st.doc.node(*id).map_or("No Selection", Node::kind_label));
|
||||
let Some(first) = kinds.next() else { return "No Selection" };
|
||||
if kinds.all(|k| k == first) { first } else { "Mixed Objects" }
|
||||
}
|
||||
|
||||
/// Do the selected objects differ in appearance (fills, strokes, effects, opacity or blend mode)?
|
||||
fn mixed_appearances(app: &VectorcraftApp) -> bool {
|
||||
let Some(st) = app.session.active() else { return false };
|
||||
let mut nodes = st.selection.subjects().iter().filter_map(|id| st.doc.node(*id));
|
||||
let Some(first) = nodes.next() else { return false };
|
||||
nodes.any(|n| n.appearance != first.appearance || n.opacity != first.opacity || n.blend != first.blend)
|
||||
/// The selected objects the panel lists (several, at least one differing from the first in
|
||||
/// appearance, opacity or blend mode): what their stacks share, and whether their opacity and blend
|
||||
/// mode differ. `None` for one object, or several alike.
|
||||
fn mixed(app: &VectorcraftApp) -> Option<Mixed> {
|
||||
let st = app.session.active()?;
|
||||
let nodes: Vec<&Node> = st.selection.subjects().iter().filter_map(|id| st.doc.node(*id)).collect();
|
||||
let (first, rest) = nodes.split_first()?;
|
||||
let transparency = rest.iter().any(|n| n.opacity != first.opacity || n.blend != first.blend);
|
||||
if rest.is_empty() || (!transparency && rest.iter().all(|n| n.appearance == first.appearance)) {
|
||||
return None;
|
||||
}
|
||||
let aps: Vec<&vectorcraft_doc::Appearance> = nodes.iter().map(|n| &*n.appearance).collect();
|
||||
let fills_differ = rest.iter().any(|n| n.appearance.fill_paint() != first.appearance.fill_paint());
|
||||
Some(Mixed { shared: vectorcraft_doc::appearance::SharedStack::of(&aps), transparency, fills_differ })
|
||||
}
|
||||
|
||||
/// A mixed selection as the panel lists it ([`mixed`]).
|
||||
struct Mixed {
|
||||
/// The fills, strokes and effects the objects share: the rows listed.
|
||||
shared: vectorcraft_doc::appearance::SharedStack,
|
||||
/// The objects' opacity or blend mode differ: the Opacity row reads "Mixed".
|
||||
transparency: bool,
|
||||
/// Their topmost fills differ: the object row shows no fill chip.
|
||||
fills_differ: bool,
|
||||
}
|
||||
|
||||
/// `params` of an item or effect command the panel runs, with `aligned` when the selected objects'
|
||||
/// stacks differ: its indices are then rows of the first object's stack, which each other object
|
||||
/// edits where it has them (the panel lists only the rows they share).
|
||||
fn aligned(app: &VectorcraftApp, mut params: Value) -> Value {
|
||||
let differ = app.session.active().is_some_and(|st| {
|
||||
let mut aps = st.selection.subjects().iter().filter_map(|id| st.doc.node(*id)).map(|n| &n.appearance);
|
||||
aps.next().is_some_and(|first| aps.any(|a| a != first))
|
||||
});
|
||||
if differ && let Some(m) = params.as_object_mut() {
|
||||
m.insert("aligned".into(), json!(true));
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// What the next object drawn gets when nothing is selected (`Session::new_art`): its fills and
|
||||
@@ -393,23 +440,33 @@ enum Slot {
|
||||
Contents,
|
||||
}
|
||||
|
||||
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect) {
|
||||
/// `only`: a mixed selection (`n` its first object): only the rows its objects share are listed,
|
||||
/// and rows aren't dragged (where one would go in each of the different stacks isn't one place).
|
||||
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect, only: Option<&Mixed>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ctx = ui.ctx().clone();
|
||||
let dragging: Option<Dragged> = pstate(&ctx, "ap-drag");
|
||||
let dragging: Option<Dragged> = pstate::<Option<Dragged>>(&ctx, "ap-drag").filter(|_| only.is_none());
|
||||
let items = selected_items(&ctx, Some(n), sel);
|
||||
let mut rows = vec![(Slot::Object { top: true }, object_row)];
|
||||
let mut stopped = false;
|
||||
// Starts or ends a row drag.
|
||||
let frozen = only.is_some();
|
||||
let mut track = |resp: &egui::Response, what: Dragged| {
|
||||
if frozen {
|
||||
return;
|
||||
}
|
||||
if resp.drag_started() {
|
||||
set_pstate(&ctx, "ap-drag", Some(what));
|
||||
}
|
||||
stopped |= resp.drag_stopped();
|
||||
};
|
||||
let contents = n.contents_label();
|
||||
// A mixed selection's Contents rows differ object by object: none is listed.
|
||||
let contents = n.contents_label().filter(|_| only.is_none());
|
||||
let below = n.appearance.contents_at();
|
||||
for (i, it) in n.appearance.items.iter().enumerate().rev() {
|
||||
if only.is_some_and(|m| !m.shared.has_item(i)) {
|
||||
continue;
|
||||
}
|
||||
// The Contents row sits right above the items painted below the members.
|
||||
if let Some(label) = contents.filter(|_| i + 1 == below) {
|
||||
let (r, resp) = contents_row(app, ui, n, label, sel);
|
||||
@@ -422,7 +479,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row:
|
||||
let (visible, paint) = (it.visible(), it.paint());
|
||||
let is_stroke = !it.is_fill();
|
||||
if eye(ui, r, ("ap-eye", i), visible, true) {
|
||||
app.run("appearance.setItem", json!({"index": i, "visible": !visible})).ok();
|
||||
app.run("appearance.setItem", aligned(app, json!({"index": i, "visible": !visible}))).ok();
|
||||
}
|
||||
if chevron(ui, r, ("ap-chev", i), open) {
|
||||
set_pstate(ui.ctx(), &format!("ap-open-{i}"), !open);
|
||||
@@ -456,7 +513,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row:
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
if let Some(w) = widgets::num_field_named(&mut child, ("ap-w", i), Some(tl!("Weight:")), Some(st.width), app.session.stroke_unit(), 56.0)
|
||||
{
|
||||
app.run("appearance.setItem", json!({"index": i, "weight": w})).ok();
|
||||
app.run("appearance.setItem", aligned(app, json!({"index": i, "weight": w}))).ok();
|
||||
}
|
||||
stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st);
|
||||
}
|
||||
@@ -469,7 +526,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row:
|
||||
}
|
||||
track(&resp, Dragged::Item(i));
|
||||
if open {
|
||||
let r = opacity_row(app, ui, Some(i), it.opacity(), it.blend());
|
||||
let r = opacity_row(app, ui, Some(i), it.opacity(), it.blend(), false);
|
||||
rows.push((Slot::Item { index: i, sub: true }, r));
|
||||
for (k, e) in it.effects().iter().enumerate() {
|
||||
let (r, resp) = effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k));
|
||||
@@ -485,11 +542,14 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row:
|
||||
}
|
||||
// Object-level effects.
|
||||
for (k, e) in n.appearance.effects.iter().enumerate() {
|
||||
if only.is_some_and(|m| !m.shared.has_effect(k)) {
|
||||
continue;
|
||||
}
|
||||
let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k));
|
||||
rows.push((Slot::Effect(None, k), r));
|
||||
track(&resp, Dragged::Effect(None, k));
|
||||
}
|
||||
let r = opacity_row(app, ui, None, n.opacity, n.blend);
|
||||
let r = opacity_row(app, ui, None, n.opacity, n.blend, only.is_some_and(|m| m.transparency));
|
||||
rows.push((Slot::Object { top: false }, r));
|
||||
let Some(what) = dragging else { return };
|
||||
let pointer = ui.ctx().pointer_interact_pos();
|
||||
@@ -669,17 +729,24 @@ fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) {
|
||||
/// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it
|
||||
/// opens a popup with the shared opacity and blend controls (`transparency.set` on that item).
|
||||
/// Returns the row's rect.
|
||||
fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opacity: f32, blend: BlendMode) -> Rect {
|
||||
///
|
||||
/// `mixed`: the selected objects' opacity or blend mode differ: it reads "Mixed", and an edit
|
||||
/// sets them all.
|
||||
fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opacity: f32, blend: BlendMode, mixed: bool) -> Rect {
|
||||
let (r, resp) = row(ui, false);
|
||||
eye(ui, r, ("ap-op-eye", item), true, false);
|
||||
let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 };
|
||||
let clicked = link(ui, pos2(lx, r.center().y), ("ap-op-link", item), tl!("Opacity:")) || resp.clicked();
|
||||
text(ui, pos2(lx + 56.0, r.center().y), &opacity_text(opacity, blend), false);
|
||||
let shown = if mixed { tl!("Mixed").to_string() } else { opacity_text(opacity, blend) };
|
||||
text(ui, pos2(lx + 56.0, r.center().y), &shown, false);
|
||||
widgets::popover(&resp, clicked, |ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) {
|
||||
Some(TransparencyEdit::Blend(b)) => {
|
||||
app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok();
|
||||
app.run("transparency.set", aligned(app, json!({"item": item, "blend": b.label()}))).ok();
|
||||
}
|
||||
Some(TransparencyEdit::Opacity(o, phase)) => {
|
||||
let params = aligned(app, json!({"item": item, "opacity": o}));
|
||||
live_run(app, "Opacity", "transparency.set", params, phase)
|
||||
}
|
||||
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
|
||||
None => {}
|
||||
});
|
||||
r
|
||||
@@ -696,7 +763,7 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usi
|
||||
let (row_rect, resp) = row(ui, selected);
|
||||
let r = row_rect;
|
||||
if eye(ui, r, ("ap-fx-eye", item, k), e.visible, true) {
|
||||
app.run("effect.setParams", json!({"item": item, "index": k, "visible": !e.visible})).ok();
|
||||
app.run("effect.setParams", aligned(app, json!({"item": item, "index": k, "visible": !e.visible}))).ok();
|
||||
}
|
||||
let indent = if item.is_some() { 12.0 } else { 0.0 };
|
||||
let r = Rect::from_min_max(pos2(r.left() + indent, r.top()), r.max);
|
||||
@@ -846,7 +913,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
|
||||
}
|
||||
});
|
||||
if let Some((key, v)) = change {
|
||||
app.run("effect.setParams", json!({"item": item, "index": k, "params": {key: v}})).ok();
|
||||
app.run("effect.setParams", aligned(app, json!({"item": item, "index": k, "params": {key: v}}))).ok();
|
||||
}
|
||||
if edit {
|
||||
app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok();
|
||||
@@ -892,7 +959,7 @@ fn swatch_picker(app: &mut VectorcraftApp, ui: &mut Ui, index: usize) {
|
||||
});
|
||||
if let Some((name, none)) = chosen {
|
||||
let params = if none { json!({"index": index, "none": true}) } else { json!({"index": index, "swatch": name}) };
|
||||
app.run("appearance.setItem", params).ok();
|
||||
app.run("appearance.setItem", aligned(app, params)).ok();
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
@@ -929,16 +996,20 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel)
|
||||
/// Duplicate the selected fills/strokes (each copy right above its row) or the selected effect.
|
||||
fn duplicate_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) {
|
||||
match sel.effect() {
|
||||
Some(fx) => app.run("effect.duplicate", fx).ok(),
|
||||
None if sel.item().is_some() => app.run("appearance.duplicateItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).ok(),
|
||||
Some(fx) => app.run("effect.duplicate", aligned(app, fx)).ok(),
|
||||
None if sel.item().is_some() => {
|
||||
app.run("appearance.duplicateItem", aligned(app, json!({"indices": selected_items(ui.ctx(), node, sel)}))).ok()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
|
||||
fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) {
|
||||
let ok = match sel.effect() {
|
||||
Some(fx) => app.run("effect.remove", fx).is_ok(),
|
||||
None if sel.item().is_some() => app.run("appearance.removeItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).is_ok(),
|
||||
Some(fx) => app.run("effect.remove", aligned(app, fx)).is_ok(),
|
||||
None if sel.item().is_some() => {
|
||||
app.run("appearance.removeItem", aligned(app, json!({"indices": selected_items(ui.ctx(), node, sel)}))).is_ok()
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
// Removing the active item clears it in the engine; a removed item effect leaves its item
|
||||
|
||||
@@ -534,3 +534,37 @@ fn inline_stroke_weight_and_effect_fields_are_named() {
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Blur");
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Color");
|
||||
}
|
||||
|
||||
/// #4174: two circles with different fills and the same stroke, effect and opacity list the
|
||||
/// object row with their kind, the "Mixed Appearances" note and what they share: the stroke, the
|
||||
/// Feather and the opacity, not the fills. A path with type names them "Mixed Objects".
|
||||
#[test]
|
||||
fn a_mixed_selection_lists_what_its_objects_share() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
|
||||
run(&mut app, "file.new", json!({"width": 300, "height": 200}));
|
||||
let mut ids = vec![];
|
||||
for (x, color) in [(0, "#ffff00"), (150, "#ff0000")] {
|
||||
let id = run(&mut app, "shape.ellipse", json!({"x": x, "y": 50, "width": 100, "height": 100}))["id"].clone();
|
||||
run(&mut app, "paint.setFill", json!({"ids": [id], "color": color}));
|
||||
run(&mut app, "appearance.setItem", json!({"ids": [id], "index": 1, "color": "#0000ff", "weight": 11}));
|
||||
run(&mut app, "effect.apply", json!({"ids": [id], "effect": "stylize.feather", "item": null}));
|
||||
ids.push(id);
|
||||
}
|
||||
run(&mut app, "select.set", json!({"ids": ids}));
|
||||
let shown = frame(&mut app);
|
||||
let kind = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(ids[0].as_u64().unwrap())).unwrap().kind_label();
|
||||
assert_eq!(shown.first().map(String::as_str), Some(kind), "the object row names their kind: {shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Mixed Appearances"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Stroke:") && !shown.iter().any(|t| t == "Fill:"), "the stroke, not the fills: {shown:?}");
|
||||
assert!(shown.iter().any(|t| t.contains("Feather")), "the shared effect: {shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Opacity:"), "{shown:?}");
|
||||
// Alike objects list their whole stack, without the note.
|
||||
run(&mut app, "paint.setFill", json!({"ids": ids, "color": "#ffff00"}));
|
||||
let shown = frame(&mut app);
|
||||
assert!(!shown.iter().any(|t| t == "Mixed Appearances") && shown.iter().any(|t| t == "Fill:"), "{shown:?}");
|
||||
// A path and type.
|
||||
let t = run(&mut app, "text.create", json!({"x": 0, "y": 180, "text": "Hi"}))["id"].clone();
|
||||
run(&mut app, "select.set", json!({"ids": [ids[0].clone(), t]}));
|
||||
assert_eq!(frame(&mut app).first().map(String::as_str), Some("Mixed Objects"));
|
||||
}
|
||||
|
||||
@@ -324,16 +324,18 @@ fn object_row_names_the_selection_like_the_control_bar() {
|
||||
let ctx = egui::Context::default();
|
||||
assert!(frame_in(&ctx, &mut app, appearance::show).contains(&"Rectangle".to_string()));
|
||||
let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].clone();
|
||||
// Objects with one appearance list it under "Mixed Objects".
|
||||
// Objects of one kind with one appearance are named by their kind and list it (#4174).
|
||||
run(&mut app, "select.set", json!({"ids": [a, b]}));
|
||||
let texts = frame_in(&ctx, &mut app, appearance::show);
|
||||
assert!(texts.contains(&"Mixed Objects".to_string()) && texts.contains(&"Stroke:".to_string()), "{texts:?}");
|
||||
// Objects that differ list nothing.
|
||||
assert!(texts.contains(&"Rectangle".to_string()) && texts.contains(&"Stroke:".to_string()), "{texts:?}");
|
||||
assert!(!texts.contains(&"Mixed Appearances".to_string()), "{texts:?}");
|
||||
// Objects whose opacity differs list what they share, the opacity as mixed.
|
||||
run(&mut app, "select.set", json!({"ids": [b]}));
|
||||
run(&mut app, "transparency.set", json!({"opacity": 50}));
|
||||
run(&mut app, "select.set", json!({"ids": [a, b]}));
|
||||
let texts = frame_in(&ctx, &mut app, appearance::show);
|
||||
assert!(texts.contains(&"Mixed Appearances".to_string()) && !texts.contains(&"Stroke:".to_string()), "{texts:?}");
|
||||
assert!(texts.contains(&"Mixed Appearances".to_string()) && texts.contains(&"Stroke:".to_string()), "{texts:?}");
|
||||
assert!(texts.contains(&"Mixed".to_string()), "the opacity reads Mixed: {texts:?}");
|
||||
// Type objects are "Type" in both places.
|
||||
let t = run(&mut app, "text.create", json!({"x": 10, "y": 150, "text": "Hi"}))["id"].clone();
|
||||
run(&mut app, "select.set", json!({ "ids": [t] }));
|
||||
|
||||
Reference in New Issue
Block a user