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:
ham9zah
2026-10-27 14:36:13 +07:00
committed by bflatastic
parent 52c92cb419
commit 8305ccf998
33 changed files with 520 additions and 90 deletions
+100
View File
@@ -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;
+39
View File
@@ -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());
}
+82 -15
View File
@@ -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
+62 -31
View File
@@ -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);
+3 -3
View File
@@ -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();
+62
View File
@@ -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());
}
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1276
1277
1278
1279
1280
1281
1282
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
2177
2178
2179
2180
2181
2182
2183
+1
View File
@@ -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.
+1
View File
@@ -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.
+108 -37
View File
@@ -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] }));