mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
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:
co-authored by
Claude Opus 5.5
parent
b6b87ee9b6
commit
3692b9d233
@@ -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);
|
||||
|
||||
@@ -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()]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user