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