diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index fb845a8e..894b33bb 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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) -> 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(); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index d85a7929..0a81fd3e 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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()); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6460199f..18a00cdc 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -38,8 +38,24 @@ pub fn specs() -> Vec { 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 { 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 { 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; diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index fb39cdfd..413dac6c 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -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::>(), ["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()); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aaac5e0e..8be42e7d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3f8048e9..55488867 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1638,8 +1638,6 @@ fn effect_menu() -> Vec { 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> = + 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()]]); + } } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index afa6ac34..1d7f86c0 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 547bc7f6..af0a676f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -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"); + } +} diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index a49bfcad..fdfc53d4 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -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())); +}