From 8305ccf99825e70796b2ebf0a819fda6b85403dc Mon Sep 17 00:00:00 2001 From: ham9zah Date: Tue, 27 Oct 2026 05:12:17 +0300 Subject: [PATCH 1/2] Appearance: a mixed selection lists the attributes its objects share (#4174) With several objects selected whose appearances differ in anything (two circles whose fills differ but whose stroke and effect are the same), the Appearance panel showed only "Mixed Appearances": no stroke, effect or opacity row, so attributes the objects share couldn't be edited together. It now lists what they share, as the issue describes: - `vectorcraft_doc::appearance::SharedStack` (with `align`, a stacking- order longest-common-subsequence match) gives the fills, strokes and object-level effects every object has an equal copy of, and where each is in every object's own stack. - The panel's object row names the objects' common kind ("Path", "Ellipse"; "Mixed Objects" only when kinds differ, as the Control bar does), with an italic "Mixed Appearances" row under it when they differ, then only the shared rows with their usual controls. The Opacity row reads "Mixed" when opacity or blend mode differ (an edit sets them all). The object row shows no fill chip when the fills differ. Rows aren't dragged in a mixed selection: where a row would go in each of several different stacks isn't one place. - The engine maps a shared row to each object's own item: the active item (a row of the first selected object's stack) always does, so the Color, Stroke and Transparency panels edit every object's matching item, and the item and effect commands do with `aligned: true` (appearance.setItem / removeItem / duplicateItem, effect.remove / duplicate / setParams / lightingPreset, transparency.set), each as one undo step. moveItem and effect.move refuse aligned rows of differing stacks. Without `aligned`, explicit indices keep their meaning for single objects and scripts. - "Mixed" is added to every catalog. Tests: the shared stack of the issue's case, of a stroke at another index, of nothing alike and of equal stacks, and that matches never cross; editing the shared stroke edits both strokes (at index 1 and 2) in one undo step that restores both, also through stroke.set on the active row; removing a shared effect and adding a fill reach both, and reordering differing stacks is refused; the panel lists kind, note, Stroke, Feather and Opacity, not Fill, the full stack for alike objects and "Mixed Objects" for a path and type. The existing object-row test now expects the kind for two rectangles and the shared rows when their opacity differs. --- crates/doc/src/appearance.rs | 100 ++++++++++++ crates/doc/src/appearance/tests.rs | 39 +++++ crates/engine/src/cmd/appearance.rs | 97 ++++++++++-- crates/engine/src/cmd/effectcmd.rs | 93 +++++++---- crates/engine/src/cmd/paint.rs | 6 +- crates/engine/src/tests/appearance.rs | 62 ++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/appearance.rs | 145 +++++++++++++----- crates/ui-egui/src/panels/appearance/tests.rs | 34 ++++ crates/ui-egui/src/panels/tests_appearance.rs | 10 +- 33 files changed, 520 insertions(+), 90 deletions(-) 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..5225c7152 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -311,3 +311,42 @@ fn contents_slot_follows_item_edits_and_round_trips() { assert_eq!(old.contents_index, None); assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index")); } + +/// The issue's case (#4174): two circles with different fills, the same stroke and the same +/// effect share the stroke and the effect, not the fills; a shared stroke at another place in one +/// stack maps to its own index there; nothing shared shares nothing; equal stacks share all. +#[test] +fn mixed_appearances_share_their_equal_rows() { + let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 11.0)); + let fill = |c: Color| AppearanceItem::Fill(FillLayer::new(Paint::solid(c))); + let feather = Effect { id: "stylize.feather".into(), params: serde_json::json!({"radius": 5}), visible: true }; + let a = Appearance { items: vec![fill(Color::rgb(1.0, 1.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() }; + let b = Appearance { items: vec![fill(Color::rgb(1.0, 0.0, 0.0)), stroke.clone()], effects: vec![feather.clone()], ..Appearance::default() }; + let shared = SharedStack::of(&[&a, &b]); + assert_eq!(shared.items, vec![vec![1, 1]], "the stroke, not the fills"); + assert_eq!(shared.effects, vec![vec![0, 0]]); + // The stroke under an extra fill in the second object. + let c = Appearance { + items: vec![fill(Color::BLACK), fill(Color::WHITE), stroke.clone()], + effects: vec![feather.clone()], + ..Appearance::default() + }; + let shared = SharedStack::of(&[&a, &c]); + assert_eq!(shared.items, vec![vec![1, 2]]); + assert_eq!(shared.item_in(1, 1), Some(2)); + assert_eq!(shared.item_in(0, 1), None); + // Nothing alike. + let d = Appearance { items: vec![fill(Color::WHITE)], ..Appearance::default() }; + let none = SharedStack::of(&[&a, &d]); + assert!(none.items.is_empty() && none.effects.is_empty()); + // Equal stacks share everything, each row where it is. + assert_eq!(SharedStack::of(&[&a, &a.clone(), &a.clone()]).items, vec![vec![0, 0, 0], vec![1, 1, 1]]); +} + +/// Matches keep their stacking order: two equal rows in the opposite order match once. +#[test] +fn aligned_rows_never_cross() { + assert_eq!(align(&[1, 2], &[2, 1], |a, b| a == b).iter().filter(|m| m.is_some()).count(), 1); + assert_eq!(align(&[1, 2, 3], &[9, 1, 3], |a, b| a == b), vec![Some(1), None, Some(2)]); + assert_eq!(align::(&[], &[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..8c1678ec9 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,44 @@ impl ItemTarget { match self { ItemTarget::Top => Ok(None), ItemTarget::Item { index, .. } if index < ap.items.len() => Ok(Some(index)), - ItemTarget::Item { index, explicit: true } => Err(bad(cmd, format!("no appearance item {index}"))), + ItemTarget::Item { index, explicit: true, .. } => Err(bad(cmd, format!("no appearance item {index}"))), ItemTarget::Item { explicit: false, .. } => Ok(None), } } } +/// Does `p` say its item and effect indices are rows of the first target's stack (`aligned`), +/// which each other target edits where it has them (see [`ItemTarget::per_target`])? +pub(crate) fn aligned(p: &Value) -> bool { + p.get("aligned").and_then(Value::as_bool).unwrap_or(false) +} + +/// What the appearances of `ids` share, when there are several of them and they all exist. +pub(crate) fn shared_stack(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Option { + 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`: where +/// each target has the item equal to the first's (see [`SharedStack`]), else the same indices. +/// A target missing one of them gets only those it has. +pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec> { + match shared_stack(d, ids).filter(|sh| aligned && idx.iter().all(|i| sh.has_item(*i))) { + Some(sh) => (0..ids.len()).map(|k| idx.iter().filter_map(|i| sh.item_in(*i, k)).collect()).collect(), + None => vec![idx.to_vec(); ids.len()], + } +} + /// Whether the `target` param asks for the contents of groups and layers (see the module docs). fn targets_contents(p: &Value) -> Result { match p.get("target") { @@ -352,9 +401,11 @@ pub(crate) fn edit_items_then( return Ok(()); } let before = s.doc()?.doc.clone(); + let per = item.per_target(&before, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); - for id in ids { + for (id, item) in ids.iter().zip(&per) { + let Some(item) = item else { continue }; let Some(n) = d.node_mut(*id) else { continue }; let index = item.resolve(&n.appearance, fill, cmd)?; f(n, index)?; @@ -562,8 +613,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result { // When every object comes out as it was (for example, when none has item `index`), the document // and its undo history stay as they are. let before = s.doc()?.doc.clone(); + let per = items_per_target(&before, &ids, &[idx], aligned(p)); super::overprint::edit_counted(s, "Appearance", |d| { - for id in &ids { + for (id, at) in ids.iter().zip(&per) { + let Some(&idx) = at.first() else { continue }; let Some(n) = d.node_mut(*id) else { continue }; if p.get("weight").is_none() && let Some(pa) = &paint @@ -625,9 +678,12 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { // When no object has an item at any of the indices, the document and its undo history stay as // they are. let before = s.doc()?.doc.clone(); + let per = items_per_target(&before, &ids, &idx, aligned(p)); super::overprint::edit_counted(s, "Remove Item", |d| { - for id in &ids { + for (id, idx) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { + let mut idx = idx.clone(); + idx.sort_unstable(); // From the top down, so the lower indices stay valid. for &i in idx.iter().rev() { n.appearance.remove_item(i); @@ -650,11 +706,18 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let ids = require_targets(s, p, C)?; // Where the first object's copy landed (the active item lives in the first object's stack). let mut first_at = None; + // Aligned rows of a mixed selection are copied right above themselves in each object. + if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() { + return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows")); + } + let per = items_per_target(&s.doc()?.doc, &ids, &idx, aligned(p)); s.edit("Duplicate Item", |d, _| { let mut any = false; - for id in &ids { + for (id, idx) in ids.iter().zip(&per) { let Some(n) = d.node_mut(*id) else { continue }; let ap = &mut n.appearance; + let mut idx = idx.clone(); + idx.sort_unstable(); // From the top down, so the lower indices stay valid. for &i in idx.iter().rev() { if let Some(item) = ap.items.get(i).cloned() { @@ -697,6 +760,10 @@ fn move_item(s: &mut Session, p: &Value) -> Result { 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..0530b3d28 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -523,3 +523,65 @@ fn clear_and_reduce_keep_a_groups_stack_empty() { // The members keep their own appearance. assert_eq!(node(&s, a).appearance, members); } + +/// #4174: two circles with different fills, the same 11 pt blue stroke and the same Feather, +/// both selected. Their default stacks are `[Fill, Stroke]`; the second gets a second fill under +/// its stroke when `extra_fill`, so the shared stroke sits at another index there. +fn mixed_circles(s: &mut Session, extra_fill: bool) -> (NodeId, NodeId) { + let mut circle = |x: f64, color: &str| { + let id = NodeId(run(s, "shape.ellipse", json!({"x": x, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(s, "paint.setFill", json!({"ids": [id.0], "color": color})); + run(s, "appearance.setItem", json!({"ids": [id.0], "index": 1, "color": "#0000ff", "weight": 11})); + run(s, "effect.apply", json!({"ids": [id.0], "effect": "stylize.feather", "item": null})); + id + }; + let (a, b) = (circle(0.0, "#ffff00"), circle(150.0, "#ff0000")); + if extra_fill { + // Another fill on top of b's fill and moved under its stroke: [Fill, Fill, Stroke]. + run(s, "appearance.addFill", json!({"ids": [b.0]})); + run(s, "appearance.moveItem", json!({"ids": [b.0], "from": 2, "to": 1})); + run(s, "appearance.setItem", json!({"ids": [b.0], "index": 1, "color": "#00ff00"})); + } + run(s, "select.set", json!({"ids": [a.0, b.0]})); + (a, b) +} + +/// The shared stroke row edits the stroke of both circles, whatever its index in each, as one undo +/// step, leaving their fills alone; undo restores both. +#[test] +fn a_shared_row_edits_every_selected_object() { + for extra in [false, true] { + let mut s = session(); + let (a, b) = mixed_circles(&mut s, extra); + let b_stroke = if extra { 2 } else { 1 }; + let fills = (node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone()); + let steps = s.doc().unwrap().history.undo.len(); + // The panel lists the first object's rows: the stroke is a's item 1. + run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 4, "aligned": true})); + assert_eq!(stroke(&node(&s, a), 1).width, 4.0); + assert_eq!(stroke(&node(&s, b), b_stroke).width, 4.0, "extra fill: {extra}"); + assert_eq!(node(&s, a).appearance.items[0], fills.0[0], "a's fill untouched"); + assert_eq!(node(&s, b).appearance.items.len(), fills.1.len()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!((node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone()), fills, "undo restores both"); + // The active row (a's item 1) carries paint edits from other panels to both strokes too. + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + run(&mut s, "stroke.set", json!({"weight": 7})); + assert_eq!(stroke(&node(&s, b), b_stroke).width, 7.0, "extra fill: {extra}"); + } +} + +/// Removing the shared effect removes it from both; adding a fill adds one to both. +#[test] +fn shared_effects_and_new_fills_reach_every_selected_object() { + let mut s = session(); + let (a, b) = mixed_circles(&mut s, true); + run(&mut s, "effect.remove", json!({"index": 0, "item": null, "aligned": true})); + assert!(node(&s, a).appearance.effects.is_empty() && node(&s, b).appearance.effects.is_empty()); + let (na, nb) = (node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()); + run(&mut s, "appearance.addFill", json!({})); + assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (na + 1, nb + 1)); + // Rows of different stacks aren't reordered together. + assert!(s.execute("appearance.moveItem", &json!({"from": 1, "to": 0, "aligned": true})).is_err()); +} 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/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 9d8bc5937..bc760c5ac 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); @@ -846,7 +913,7 @@ 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(); @@ -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] })); From 0365cf3ae3926b934adacdcc3e937242ff4ad4c7 Mon Sep 17 00:00:00 2001 From: ham9zah Date: Tue, 27 Oct 2026 10:19:07 +0300 Subject: [PATCH 2/2] Appearance: shared rows map one by one, and effect dialogs edit each copy (#4174) Review follow-ups: - items_per_target maps each requested row on its own. Rows deleted or duplicated together, one shared and one not, fell back to the literal indices in every object, so the other objects could lose the wrong items. The first object takes its own rows; each other object takes its copies of the shared ones only. - effect.dialog takes `aligned` (the panel passes it on a mixed selection's shared effect row), and its OK and preview run effect.setParams with it, so the dialog edits each object's own copy of the effect instead of the first object's index in every object. Tests: removing a shared stroke with an unshared fill leaves the other object's fills; an aligned dialog's command carries `aligned`. --- crates/doc/src/appearance/tests.rs | 7 ++----- crates/engine/src/cmd/appearance.rs | 11 +++++----- crates/engine/src/tests/appearance.rs | 13 ++++++++++++ crates/ui-egui/src/dialogs/effect.rs | 28 +++++++++++++++++++++++++ crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/appearance.rs | 4 ++-- 6 files changed, 52 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 5225c7152..3e3d610f4 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -326,11 +326,8 @@ fn mixed_appearances_share_their_equal_rows() { 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 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)); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 8c1678ec9..3e1173d8b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -313,12 +313,13 @@ pub(crate) fn appearances_differ(d: &vectorcraft_doc::Document, ids: &[NodeId]) aps.any(|a| a != first) } -/// Indices `idx` of the first target's items (aligned with `aligned`) in each of `ids`: where -/// each target has the item equal to the first's (see [`SharedStack`]), else the same indices. -/// A target missing one of them gets only those it has. +/// 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(|sh| aligned && idx.iter().all(|i| sh.has_item(*i))) { - Some(sh) => (0..ids.len()).map(|k| idx.iter().filter_map(|i| sh.item_in(*i, k)).collect()).collect(), + 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()], } } diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 0530b3d28..d6336b86d 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -585,3 +585,16 @@ fn shared_effects_and_new_fills_reach_every_selected_object() { // 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/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 bc760c5ac..b1337be67 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -774,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); } @@ -916,7 +916,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: 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(); } }