Merge pull request #4301 from ham9zah/fix/appearance-mixed-selection

Appearance: a mixed selection lists the attributes its objects share (#4174)
This commit is contained in:
BFlat
2026-10-27 15:10:31 +07:00
committed by GitHub
35 changed files with 562 additions and 93 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;
+36
View File
@@ -311,3 +311,39 @@ 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());
}
+83 -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,45 @@ 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`: the first
/// target's own, and in each other one the items equal to them there (see [`SharedStack`]), each
/// index on its own: one the targets don't share is left out of the others (never taken as the
/// same index there). Not aligned, or one target: the same indices in each.
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(|_| aligned) {
Some(sh) => (0..ids.len()).map(|k| if k == 0 { idx.to_vec() } else { 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 +402,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 +614,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 +679,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 +707,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 +761,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();
+75
View File
@@ -523,3 +523,78 @@ 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());
}
/// Rows deleted together, one shared (the stroke) and one not (a's fill): each object loses its
/// own copy of the shared row, and the other object keeps the items at those indices of a's stack.
#[test]
fn removing_shared_and_unshared_rows_removes_only_the_shared_ones_elsewhere() {
let mut s = session();
let (a, b) = mixed_circles(&mut s, true);
// b is [Fill, Fill, Stroke]; a's rows 0 (its fill) and 1 (the shared stroke).
let fills_b: Vec<_> = node(&s, b).appearance.items.iter().take(2).cloned().collect();
run(&mut s, "appearance.removeItem", json!({"indices": [0, 1], "aligned": true}));
assert!(node(&s, a).appearance.items.is_empty(), "a loses both");
assert_eq!(node(&s, b).appearance.items, fills_b, "b loses only its stroke, keeping both fills");
}
+28
View File
@@ -74,6 +74,10 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
if let Some(item) = p.get("item") {
fields.insert("__item".into(), item.clone());
}
// A shared row of a mixed selection: OK edits each object's own copy of the effect.
if p.get("aligned").and_then(Value::as_bool) == Some(true) {
fields.insert("__aligned".into(), json!(true));
}
match p.get("index").and_then(Value::as_u64) {
Some(k) => {
let e = applied.get(k as usize).ok_or_else(|| format!("no effect at index {k}"))?;
@@ -737,6 +741,9 @@ fn command(d: &Dialog) -> (&'static str, Value) {
if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") {
p["warpStyle"] = json!(id.strip_prefix("warp."));
}
if d.bool("__aligned") {
p["aligned"] = json!(true);
}
return ("effect.setParams", p);
}
let mut p = json!({"effect": d.str("__effect"), "params": params});
@@ -766,6 +773,27 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
mod tests {
use super::*;
/// An applied effect's dialog opened on a shared row of a mixed selection (`aligned`) edits
/// each object's own copy on OK; opened otherwise it edits the index as given.
#[test]
fn a_shared_rows_dialog_edits_each_objects_copy() {
let dialog = |aligned: bool| {
let mut fields = Map::new();
fields.insert("__effect".into(), json!("stylize.feather"));
fields.insert("__index".into(), json!(0));
fields.insert("__item".into(), Value::Null);
fields.insert("radius".into(), json!(8));
if aligned {
fields.insert("__aligned".into(), json!(true));
}
Dialog { kind: "effect".into(), fields }
};
let (cmd, p) = command(&dialog(true));
assert_eq!((cmd, &p["aligned"], &p["params"]["radius"]), ("effect.setParams", &json!(true), &json!(8)));
assert!(p["params"].get("__aligned").is_none());
assert!(command(&dialog(false)).1.get("aligned").is_none());
}
#[test]
fn fields_that_pick_a_value_are_dropdowns_of_its_values() {
// The values come from the effect's parameter documentation (`joins: "miter"|"round"|…`).
+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.
+1 -1
View File
@@ -506,7 +506,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"effect.dialog",
"Effect…",
"",
"{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}",
"{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item), aligned?: bool (index and item are rows of the first selected object's stack, which OK edits in each object where it has them, as effect.setParams does)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}",
),
("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"),
(
+110 -39
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);
@@ -707,7 +774,7 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usi
if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &shown_effect_label(&e.id)) {
select_row(app, ui.ctx(), this);
if has_options(&e.id) {
app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok();
app.run("effect.dialog", aligned(app, json!({"effect": e.id, "index": k, "item": item}))).ok();
} else {
set_pstate(ui.ctx(), &open_key, !open);
}
@@ -846,10 +913,10 @@ 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();
app.run("effect.dialog", aligned(app, 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] }));