M3.24: basic appearance, Layers target dots, Mixed Appearances and Clear resets opacity

An appearance is basic with at most one fill and one stroke, none hidden or
with its own opacity, blend mode or effects, and no object effects; Reduce
to Basic keeps the topmost visible fill and stroke. The Layers panel fills
an object's target circle for any non-default transparency (blend mode,
isolation, knockout or an opacity mask: Node::has_default_transparency).
A multi-selection whose objects differ shows "Mixed Appearances" with no
rows. The Appearance panel's object row and the Control bar name the
selection from one source, and type objects are "Type". Expand Appearance
is only in the Object menu. Clear Appearance also resets the object's
opacity and blend mode. Tests cover addFill/addStroke, setItem/removeItem,
clear, reduceToBasic and graphicStyle.apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 17:30:03 +07:00
parent b6b87ee9b6
commit 3692b9d233
9 changed files with 256 additions and 33 deletions

View File

@ -326,15 +326,14 @@ impl Appearance {
pub fn stroke_width(&self) -> f64 {
self.stroke().filter(|s| !s.paint.is_none()).map(|s| s.width).unwrap_or(0.0)
}
/// Is this the basic one-fill-one-stroke appearance without effects?
/// Is this a basic appearance: at most one fill and one stroke, none hidden or with its own
/// opacity, blend mode or effects, and no object effects?
pub fn is_basic(&self) -> bool {
let count = |fill: bool| self.items.iter().filter(|i| i.is_fill() == fill).count();
self.effects.is_empty()
&& self.items.len() <= 2
&& self.items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() <= 1
&& self.items.iter().all(|i| match i {
AppearanceItem::Fill(f) => f.effects.is_empty() && f.opacity == 1.0 && f.blend == BlendMode::Normal,
AppearanceItem::Stroke(s) => s.effects.is_empty() && s.opacity == 1.0 && s.blend == BlendMode::Normal,
})
&& count(true) <= 1
&& count(false) <= 1
&& self.items.iter().all(|i| i.visible() && i.effects().is_empty() && i.opacity() == 1.0 && i.blend() == BlendMode::Normal)
}
/// Fill item `index`, or the topmost fill for `None`. `None` when that item is not a fill.
pub fn fill_at(&self, index: Option<usize>) -> Option<&FillLayer> {
@ -488,6 +487,27 @@ mod tests {
assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill"));
}
#[test]
fn basic_means_one_plain_fill_and_stroke() {
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0));
let mut a = Appearance { items: vec![stroke(), stroke()], effects: vec![] };
assert!(!a.is_basic(), "two strokes");
a.items.pop();
assert!(a.is_basic() && Appearance::default().is_basic());
let mut b = Appearance::default_art();
b.stroke_mut().unwrap().visible = false;
assert!(!b.is_basic(), "a hidden stroke");
let mut c = Appearance::default_art();
c.fill_mut().unwrap().opacity = 0.5;
assert!(!c.is_basic(), "fill opacity");
let mut d = Appearance::default_art();
d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None)));
assert!(!d.is_basic(), "two fills");
let mut e = Appearance::default_art();
e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true });
assert!(!e.is_basic(), "an effect");
}
#[test]
fn set_creates_missing() {
let mut a = Appearance::default();

View File

@ -357,6 +357,11 @@ impl Node {
pub fn is_container(&self) -> bool {
self.children().is_some()
}
/// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel
/// fills an object's target circle otherwise).
pub fn has_default_transparency(&self) -> bool {
self.opacity >= 1.0 && self.blend == BlendMode::Normal && !self.isolate && !self.knockout && self.mask.is_none()
}
/// Kind name as the Layers panel / Properties panel shows it.
pub fn kind_label(&self) -> &'static str {
match &self.kind {
@ -367,7 +372,7 @@ impl Node {
NodeKind::Path { guide: true, .. } => "Guide",
NodeKind::Path { .. } => "Path",
NodeKind::Compound { .. } => "Compound Path",
NodeKind::Text(_) => "Text",
NodeKind::Text(_) => "Type",
NodeKind::Image(_) => "Image",
NodeKind::SymbolInstance { .. } => "Symbol",
NodeKind::Blend { .. } => "Blend",
@ -545,6 +550,24 @@ mod tests {
assert_eq!(l.label(), "Rectangle");
}
#[test]
fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
assert!(p().has_default_transparency());
let tweaks: [fn(&mut Node); 5] = [
|n| n.opacity = 0.5,
|n| n.blend = BlendMode::Multiply,
|n| n.isolate = true,
|n| n.knockout = true,
|n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
];
for tweak in tweaks {
let mut n = p();
tweak(&mut n);
assert!(!n.has_default_transparency(), "{n:?}");
}
}
#[test]
fn transform_group_recurses() {
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());

View File

@ -38,8 +38,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| add_item(s, p, false)
),
cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, clear_appearance),
cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, reduce_basic),
cmd!(
"appearance.clear",
"Clear Appearance",
["Window", "Appearance"],
None,
"{ids?} leave each object one empty fill and stroke (None) and reset its opacity and blend mode",
has_selection,
clear_appearance
),
cmd!(
"appearance.reduceToBasic",
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects, and drop the object's effects",
has_selection,
reduce_basic
),
cmd!(
"appearance.setItem",
"Appearance Item",
@ -261,7 +277,10 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Clear Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
// No fill, no stroke, and the object's Opacity row back to Default.
n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0);
n.opacity = 1.0;
n.blend = BlendMode::Normal;
}
}
Ok(())
@ -275,10 +294,12 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Reduce to Basic Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let f = n.appearance.fill_paint();
let st = n.appearance.stroke().cloned();
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] };
if let Some(mut st) = st {
// 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();
let fill = top(true).map_or(Paint::None, |f| f.paint().clone());
let stroke = top(false);
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(fill))], effects: vec![] };
if let Some(AppearanceItem::Stroke(mut st)) = stroke {
st.effects.clear();
st.opacity = 1.0;
st.blend = BlendMode::Normal;

View File

@ -267,3 +267,77 @@ fn text_fill_items_composite_with_their_opacity() {
let px = render(&s).pixel(x, y);
assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}");
}
#[test]
fn add_fill_and_stroke_copy_the_top_ones() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "stroke.set", json!({"weight": 4}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke]
let n = node(&s, id);
assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill", "stroke"]);
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#ff0000");
assert_eq!(stroke(&n, 3).width, 4.0);
assert!(!n.appearance.is_basic());
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).appearance.items.len(), 3);
}
#[test]
fn set_item_and_remove_item() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50, "blend": "screen", "weight": 3, "color": "#0000ff"}));
let n = node(&s, id);
let st = stroke(&n, 1);
assert_eq!((st.opacity, st.blend, st.width), (0.5, BlendMode::Screen, 3.0));
assert_eq!(st.paint.color().unwrap().to_hex(), "#0000ff");
run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 0.25}));
let n = node(&s, id);
assert!(!n.appearance.items[0].visible() && n.appearance.items[0].opacity() == 0.25);
assert!(s.execute("appearance.setItem", &json!({})).is_err());
run(&mut s, "appearance.removeItem", json!({"index": 0}));
let n = node(&s, id);
assert_eq!(n.appearance.items.len(), 1);
assert!(n.appearance.fill().is_none() && n.appearance.stroke().is_some());
}
#[test]
fn clear_and_reduce_to_basic() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "visible": false, "color": "#00ff00"}));
run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 0.5}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null}));
run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"}));
// Reduce keeps the topmost visible fill and stroke, plain, and the object's transparency.
run(&mut s, "appearance.reduceToBasic", json!({}));
let n = node(&s, id);
assert!(n.appearance.is_basic(), "{:?}", n.appearance);
assert_eq!(stroke(&n, 1).paint, Paint::solid(Color::BLACK));
assert_eq!((n.opacity, n.blend), (0.4, BlendMode::Multiply));
// Clear leaves one None fill and stroke, and resets the object's Opacity row.
run(&mut s, "appearance.clear", json!({}));
let n = node(&s, id);
assert_eq!(n.appearance, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0));
assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal));
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).opacity, 0.4);
}
#[test]
fn graphic_style_apply_replaces_the_stack() {
let mut s = session();
let a = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
let name = run(&mut s, "graphicStyle.new", json!({"name": "Rough"}))["name"].clone();
let b = rect(&mut s, 250.0);
run(&mut s, "graphicStyle.apply", json!({ "name": name }));
assert_eq!(node(&s, b).appearance, node(&s, a).appearance);
assert!(s.execute("graphicStyle.apply", &json!({"name": "nope"})).is_err());
}

View File

@ -92,16 +92,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = st.doc.units;
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchor_mode = !st.selection.anchors.is_empty();
let label = match (&first, sel.len()) {
(None, _) => "No Selection".to_string(),
(_, n) if n > 1 => "Mixed Objects".to_string(),
(Some(_), _) if anchor_mode => "Anchor Point".to_string(),
(Some(n), _) => match &n.kind {
NodeKind::Text(_) => "Type".into(),
NodeKind::Image(_) => "Embedded".into(),
NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(),
_ => n.kind_label().to_string(),
},
let label = match &first {
Some(_) if sel.len() == 1 && anchor_mode => "Anchor Point",
Some(n) if sel.len() == 1 && matches!(n.kind, NodeKind::Image(_)) => "Embedded",
_ => crate::panels::appearance::object_label(app),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
ui.add_space(6.0);

View File

@ -1638,8 +1638,6 @@ fn effect_menu() -> Vec<Item> {
for e in cat.iter().filter(|e| !e.menu.last().is_some_and(|m| order.contains(m))) {
out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })));
}
out.push(Sep);
out.push(c("Expand Appearance", "effect.expandAppearance"));
out
}
@ -1771,4 +1769,13 @@ mod tests {
}
assert_eq!(pretty_shortcut(""), "");
}
#[test]
fn expand_appearance_is_an_object_menu_item_only() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let paths: Vec<Vec<String>> =
menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("effect.expandAppearance")).map(|e| e.path).collect();
assert_eq!(paths, [vec!["Object".to_string()]]);
}
}

View File

@ -173,18 +173,17 @@ 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 label = match (&node, super::selection_len(app)) {
(None, _) => "No Selection".to_string(),
(Some(_), n) if n > 1 => "Mixed Objects".to_string(),
(Some(n), _) => n.kind_label().to_string(),
};
let mixed = mixed_appearances(app);
let label = if mixed { "Mixed Appearances" } else { object_label(app) };
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());
chip(ui, th, &fill);
}
text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), &label, true);
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),
None => default_stack(app, ui),
}
@ -194,6 +193,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
bottom(app, ui, node.as_ref(), sel);
}
/// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects"
/// or the object's kind ("Path", "Type", …).
pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str {
let Some(st) = app.session.active() else { return "No Selection" };
match st.selection.objects.as_slice() {
[] => "No Selection",
[id] => st.doc.node(*id).map_or("No Selection", Node::kind_label),
_ => "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.objects.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 defaults for new art when nothing is selected (read-only rows).
fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) {
let p = app.session.paint.clone();

View File

@ -221,7 +221,7 @@ fn row(
let col_x = r.right() - 43.5;
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
let tc = egui::pos2(r.right() - 28.0, r.center().y);
let styled = !n.appearance.is_basic() || n.opacity < 1.0;
let styled = has_styled_target(n);
ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.icon));
if is_sel {
ui.painter().circle_stroke(tc, 2.8, Stroke::new(1.0, t.icon));
@ -292,6 +292,12 @@ fn row(
/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc`
/// allocation, so the address is a free change detector). Only rendered for visible rows.
/// Whether an object's target circle is filled: its appearance is not basic or its transparency
/// (opacity, blend mode, isolation, knockout or an opacity mask) is not the default.
fn has_styled_target(n: &Node) -> bool {
!n.appearance.is_basic() || !n.has_default_transparency()
}
fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool {
use std::cell::RefCell;
use std::collections::HashMap;
@ -351,3 +357,36 @@ fn thumb(ui: &Ui, n: &Node, r: egui::Rect) {
}
});
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
/// Filled target circles drawn by one headless frame of the panel.
fn filled_targets(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 3.2 && cs.fill != Color32::TRANSPARENT)).count()
}
#[test]
fn target_circle_fills_for_non_default_transparency() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let id = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
run(&mut app, "select.set", json!({ "ids": [id] }));
let ctx = egui::Context::default();
assert_eq!(filled_targets(&mut app, &ctx), 0);
run(&mut app, "transparency.set", json!({"blend": "multiply"}));
assert_eq!(filled_targets(&mut app, &ctx), 1, "a Multiply object");
run(&mut app, "transparency.set", json!({"blend": "normal", "knockout": true}));
assert_eq!(filled_targets(&mut app, &ctx), 1, "a knockout group");
run(&mut app, "transparency.set", json!({"knockout": false}));
run(&mut app, "appearance.addStroke", json!({}));
assert_eq!(filled_targets(&mut app, &ctx), 1, "two strokes");
}
}

View File

@ -157,3 +157,30 @@ fn item_opacity_popup_sets_the_items_opacity() {
assert_eq!(n.appearance.items[1].opacity(), 0.5);
assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0));
}
#[test]
fn object_row_names_the_selection_like_the_control_bar() {
let mut app = app_with_rect();
let a = first_selected(&app).unwrap().id.0;
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".
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.
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:?}");
// 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] }));
assert_eq!(appearance::object_label(&app), "Type");
assert!(frame_in(&ctx, &mut app, appearance::show).contains(&"Type".to_string()));
let fonts = egui::Context::default();
crate::theme::install_fonts(&fonts);
assert!(frame_in(&fonts, &mut app, crate::chrome::control_bar).contains(&"Type".to_string()));
}