diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 2dc6cbabb..4ac758ffa 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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(first: &[T], other: &[T], same: impl Fn(&T, &T) -> bool) -> Vec> { + 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>, + /// Each shared object-level effect: its index in each object's `effects`. + pub effects: Vec>, +} + +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(first: &[T], rest: Vec<&[T]>, same: impl Fn(&T, &T) -> bool + Copy) -> Vec> { + let maps: Vec>> = 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 { + 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 { + 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; diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 49d2f4e77..3e3d610f4 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -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::(&[], &[1], |a, b| a == b), Vec::>::new()); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6fdced122..3e1173d8b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -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 { "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 { "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 { "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 { "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> { + 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> { 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 { + if ids.len() < 2 { + return None; + } + let aps: Vec<&Appearance> = ids.iter().map(|id| d.node(*id).map(|n| &*n.appearance)).collect::>()?; + 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> { + 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 { 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 { // 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 { // 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 { 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 { 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 diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index de6776586..e91a621c4 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -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 { "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 { "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 { "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 { "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 { "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, 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) -> bool, + index: usize, + mut f: impl FnMut(&mut Vec, usize) -> bool, ) -> Result> { - 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 { } } 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 { 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 { 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 { 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 { /// (none when `p` doesn't say which: running it reports why). pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { 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)| 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 { @@ -317,9 +350,8 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index f6e555892..b22d77ac9 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "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 { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); - 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 { } if opacity.is_some() || blend.is_some() { let ids: Vec = 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(); diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 14d871b58..d6336b86d 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -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"); +} diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 4bc5bde5b..b668f0791 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -74,6 +74,10 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { 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 { 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"|…`). diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 230e2251c..1f5588aea 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1813,6 +1813,7 @@ font-technology Variable Променливи Miter Limit: Ограничение на острите ъгли: Mixed Appearances Смесени изгледи Mixed Objects Смесени обекти + Mixed Смесено Mode: Режим: Model Модел Modified Модифицирано diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 07c73f238..ef160b85a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 994f115ab..4e4ca0275 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a3534cce1..7f1c43e6c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f537725c0..26de8c133 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2616,6 +2616,7 @@ theme Appearance Εμφάνιση Miter Limit: Όριο οξείας σύνδεσης: Mixed Appearances Μεικτές εμφανίσεις Mixed Objects Μεικτά αντικείμενα + Mixed Μικτό Modified Τροποποιήθηκε Modified since it was read: {path} Τροποποιήθηκε μετά την ανάγνωση: {path} Modified: Τροποποιήθηκε: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index da1f4b362..f3ecd85fe 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ebb354f9..917393059 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 943ca1140..e6b8a9459 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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é diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 670c93a99..84b1cc37b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c91631178..4d813f3ba 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cf329b283..8a065733e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7449d1f09..6e6ed4a70 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3510,6 +3510,7 @@ file-filter Templates テンプレート Miter Limit 角の比率 Miter Limit: 角の比率: Mixed Objects 混在したオブジェクト + Mixed 混在 Model モデル Move All Objects すべてのオブジェクトを移動 N pt per N pt ごと diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b22d3d09a..d85bbf2f1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1192,6 +1192,7 @@ lighting Light სინათლე Hide Thumbnail ესკიზის დამალვა Mixed Appearances შერეული იერსახეები Mixed Objects შერეული ობიექტები + Mixed შერეული No options პარამეტრები არ არის No Selection მონიშნული არაფერია Offset X X-წანაცვლება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6d64b057b..a6bccad0b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3503,6 +3503,7 @@ file-filter Templates 템플릿 Miter Limit 모퉁이 한계 Miter Limit: 모퉁이 한계: Mixed Objects 혼합 개체 + Mixed 혼합 Model 모델 Move All Objects 모든 개체 이동 N pt per 당 N pt diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b0e9ed9b8..d53f44c37 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8a52c4c51..e1ccecb25 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7b0a0e623..d4fc9c9a7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 59f8cd4f0..972f4a2b2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1888,6 +1888,7 @@ file-filter Templates Шаблоны Miter Limit: Предел митры: Mixed Appearances Смешанное оформление Mixed Objects Смешанные объекты + Mixed Смешанное Mode: Режим: Model Модель Modified Изменено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c46f2970f..b755101a8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ce6b2229e..cc84fbb5d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 65384df71..32f3ba05c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1884,6 +1884,7 @@ file-filter Templates Шаблони Miter Limit: Межа гострого з’єднання: Mixed Appearances Змішане оформлення Mixed Objects Змішані об’єкти + Mixed Змішане Mode: Режим: Model Модель Modified Змінено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d9ab99612..01be6b1e7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0daa3138f..599fe0e80 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1770,6 +1770,7 @@ file-filter Templates 模板 Miter Limit: 斜接限制: Mixed Appearances 混合外观 Mixed Objects 混杂对象 + Mixed 混合 Mode: 模式: Model 模型 Modified 已修改 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 533516327..8f19370ad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2648,6 +2648,7 @@ file-filter Templates 範本 Miter Limit: 尖角限度: Mixed Appearances 混合外觀 Mixed Objects 混雜物件 + Mixed 混合 Mode: 模式: Model 模型 Modified 已修改 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 9867228b5..63b60e2b5 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -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"), ( diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 9d8bc5937..b1337be67 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -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 { 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 { + 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 = pstate(&ctx, "ap-drag"); + let dragging: Option = pstate::>(&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, 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, 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, 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, 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, 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 diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs index 5df314659..73f0eacf9 100644 --- a/crates/ui-egui/src/panels/appearance/tests.rs +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -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")); +} diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index c7569841e..ed3e26892 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -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] }));