diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index de4885564..3da1cb56c 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", has_doc, reduce_basic ), @@ -614,11 +614,30 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { let ids = require_targets(s, p, "appearance.reduceToBasic")?; // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); + // The Appearance panel's active row (a row of the first selected object's stack, and in each + // other target the row equal to it there), when the command acts on that selection. The targets + // are in paint order, so the first selected object can be any of them. + let first = s.doc()?.selection.subjects().first().and_then(|f| ids.iter().position(|id| id == f)); + let active = match (s.appearance_item(), first) { + (Some(index), Some(k)) if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => { + let mut from = ids.clone(); + from.swap(0, k); + let mut rows = items_per_target(&before, &from, &[index], true); + rows.swap(0, k); + rows + } + _ => vec![Vec::new(); ids.len()], + }; super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| { - for id in &ids { + for (id, at) in ids.iter().zip(&active) { if let Some(n) = d.node_mut(*id) { - // The topmost visible fill and stroke stay, without their own transparency or effects. - let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(); + // The active row stays when it is a visible fill or stroke; otherwise the topmost + // visible one of each kind does. Both lose their own transparency and effects. + let picked = at.first().and_then(|i| n.appearance.items.get(*i)).filter(|i| i.visible()).cloned(); + let top = |fill: bool| match &picked { + Some(i) if i.is_fill() == fill => Some(i.clone()), + _ => n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(), + }; let (fill, stroke) = (top(true), top(false)); // Other objects always keep a fill row (None when there was none); a group only // keeps the rows it had. diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 7d8cb2e0f..3a56ff7f1 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -400,6 +400,69 @@ fn clear_and_reduce_to_basic() { assert_eq!(node(&s, id).opacity, 0.4); } +/// Reduce to Basic Appearance keeps the Appearance panel's active fill or stroke row instead of the +/// topmost one of its kind, in each selected object with the same stack. With `ids` it keeps the +/// topmost ones. +#[test] +fn reduce_to_basic_keeps_the_active_row() { + let mut s = session(); + let a = rect(&mut s, 50.0); + let b = rect(&mut s, 200.0); + let c = rect(&mut s, 300.0); + let stack = |s: &mut Session| { + run(s, "appearance.addFill", json!({})); + run(s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke] + run(s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"})); + run(s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); + run(s, "appearance.setItem", json!({"index": 1, "weight": 7})); + }; + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + stack(&mut s); + // The lower, green fill row is active: both objects keep it and their top stroke. + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "appearance.reduceToBasic", json!({})); + for id in [a, b] { + let n = node(&s, id); + assert!(n.appearance.is_basic(), "{:?}", n.appearance); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#00ff00"); + assert_eq!(stroke(&n, 1).width, 1.0); + } + // The lower stroke row is active: it stays, with the top fill. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); + run(&mut s, "appearance.reduceToBasic", json!({})); + let n = node(&s, a); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); + assert_eq!(stroke(&n, 1).width, 7.0); + // Given ids, the topmost fill and stroke stay. + run(&mut s, "select.set", json!({"ids": [c.0]})); + stack(&mut s); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "appearance.reduceToBasic", json!({"ids": [c.0]})); + let n = node(&s, c); + assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); + assert_eq!(stroke(&n, 1).width, 1.0); +} + +/// The active row is a row of the first selected object even when that object paints above the +/// others; another object without an equal row keeps its topmost fill. +#[test] +fn reduce_to_basic_keeps_the_active_row_of_the_first_selected_object() { + let mut s = session(); + let a = rect(&mut s, 50.0); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [Fill, Stroke, Fill blue] + let b = rect(&mut s, 200.0); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"})); + run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); // [Fill green, Stroke, Fill red] + run(&mut s, "select.set", json!({"ids": [b.0, a.0]})); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); + run(&mut s, "appearance.reduceToBasic", json!({})); + assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00"); + assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#0000ff"); +} + /// How many steps there are to undo and to redo, and whether the document has unsaved changes. fn history(s: &Session) -> (usize, usize, bool) { let st = s.doc().unwrap();