From d9fefde06188463d51d6b6278aa4f8605ad9f03e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 01:05:24 -0700 Subject: [PATCH 1/2] Appearance: Reduce to Basic Appearance keeps the selected fill or stroke row Reduce to Basic Appearance kept the topmost visible fill and stroke even when another fill or stroke row was selected in the Appearance panel. It now keeps the selected row in place of the topmost one of its kind, and in each other selected object the row equal to it. With ids, or with no selected row, it keeps the topmost ones as before. --- crates/engine/src/cmd/appearance.rs | 23 +++++++++++--- crates/engine/src/tests/appearance.rs | 44 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index de4885564..df691490f 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,26 @@ 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. + let active = match s.appearance_item() { + Some(index) + if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? && s.doc()?.selection.subjects().first() == ids.first() => + { + items_per_target(&before, &ids, &[index], true) + } + _ => 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..490622812 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -400,6 +400,50 @@ 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); +} + /// 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(); From 45b266c8056e0f9b4648efbce5c638907e08c7c1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 01:14:24 -0700 Subject: [PATCH 2/2] Appearance: Reduce to Basic Appearance finds the active row in the first selected object The targets are in paint order, so the first selected object is not always the first target. The active row is now matched against the first selected object wherever it is among them. --- crates/engine/src/cmd/appearance.rs | 16 ++++++++++------ crates/engine/src/tests/appearance.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index df691490f..3da1cb56c 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -615,12 +615,16 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { // 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. - let active = match s.appearance_item() { - Some(index) - if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? && s.doc()?.selection.subjects().first() == ids.first() => - { - items_per_target(&before, &ids, &[index], true) + // 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()], }; diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 490622812..3a56ff7f1 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -444,6 +444,25 @@ fn reduce_to_basic_keeps_the_active_row() { 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();