mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
G1: Properties: Blend Options, Merge Live Paint, Width Profile, an effect's Delete icon and Direct-Selected corners
The Properties panel has the additional contextual controls the public Properties panel overview lists (https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html), each running the existing command: - Quick Actions: Blend Options for one selected blend while the Blend tool is in use (the Blend Options dialog; OK is one undo step), and Merge Live Paint when a Live Paint group is selected with more objects (livePaint.merge). - Appearance: the stroke's Variable Width Profile list (the Control bar's) with the Width tool or when the stroke has a variable width (stroke.set {profile}); beside fx, with one effect on the one selected object (its own, or a fill's or stroke's), its name and a Delete icon that removes it (effect.remove); with several, the icon opens the Appearance panel, as the overview describes. - With Direct Selection and corners of one path selected, Corner and Corner Radius set those corners' kind and radius (object.setLiveShape); a live rectangle's and polygon's own rows already do, so they get no second row. The overview's Variable Font icon was already in Character. Every label is already in every catalog. Tests in panels::properties::tests: blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool, merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more, the_width_profile_shows_for_a_variable_width_or_the_width_tool, direct_selected_corners_show_their_kind_and_radius, one_effect_has_a_delete_icon_in_appearance (shown for the right selection and tool, hidden otherwise, the right command in one undo step). Documented in docs/target-app-parity.md, docs/ui-parity.md and docs/gaps.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
d2de74e12a
commit
aa35783f47
@@ -162,7 +162,7 @@ fn built_in_effect(id: &str) -> bool {
|
||||
|
||||
/// [`effect_label`] as shown: a built-in effect's in the UI language (an `@id` row for the
|
||||
/// effect's id wins over the plain row of its English label), a plug-in's as it is.
|
||||
fn shown_effect_label(id: &str) -> String {
|
||||
pub(crate) fn shown_effect_label(id: &str) -> String {
|
||||
match catalog().iter().find(|c| c.0 == id) {
|
||||
Some(c) => crate::i18n::t_id(id, &c.1).to_string(),
|
||||
None => effect_label(id),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{LiveShape, NodeKind, Unit};
|
||||
use vectorcraft_doc::{LiveCorners, LiveShape, NodeKind, Unit, WidthProfile};
|
||||
use vectorcraft_engine::cmd::newdoc;
|
||||
|
||||
use super::{corner_radius_row, first_selected, pstate, set_pstate};
|
||||
@@ -48,6 +48,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
divider(ui);
|
||||
}
|
||||
transform_section(app, ui);
|
||||
corner_rows(app, ui);
|
||||
divider(ui);
|
||||
// The Gradient tool: the gradient's type and, for a freeform one, how clicks add points (#921).
|
||||
if app.session.tool_id() == "gradient" {
|
||||
@@ -111,11 +112,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
section_header(ui, tl!("Quick Actions"));
|
||||
let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. }));
|
||||
let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(**l, LiveShape::Path { .. }));
|
||||
// Blend Options for one blend while the Blend tool is in use, Merge Live Paint for a Live
|
||||
// Paint group selected with more to merge into it, as the Properties panel overview lists them
|
||||
// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html>).
|
||||
let blend_options = n_sel == 1 && app.session.tool_id() == "blend" && matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Blend { .. }));
|
||||
let merge_live_paint = n_sel > 1 && selects_live_paint(app);
|
||||
let mut actions: Vec<(&str, &str)> = vec![];
|
||||
if n_sel > 1 {
|
||||
actions.push((tl!("Group"), "object.group"));
|
||||
actions.push((tl!("Make Clipping Mask"), "object.clippingMask.make"));
|
||||
}
|
||||
if merge_live_paint {
|
||||
actions.push((tl!("Merge Live Paint"), "livePaint.merge"));
|
||||
}
|
||||
if blend_options {
|
||||
actions.push((tl!("Blend Options"), "ui.blendOptions"));
|
||||
}
|
||||
if is_group {
|
||||
actions.push((tl!("Ungroup"), "object.ungroup"));
|
||||
actions.push((tl!("Isolate Group"), "object.isolate"));
|
||||
@@ -148,6 +160,49 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
global_edit_row(app, ui);
|
||||
}
|
||||
|
||||
/// Whether a Live Paint group is among the selected objects.
|
||||
fn selects_live_paint(app: &VectorcraftApp) -> bool {
|
||||
app.session
|
||||
.active()
|
||||
.is_some_and(|st| st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).any(vectorcraft_tools::builder::is_live_paint))
|
||||
}
|
||||
|
||||
/// The Corners of the one path whose corners Direct Selection has selected: their kind and radius,
|
||||
/// which `object.setLiveShape` sets on those corners. The Properties panel overview shows the
|
||||
/// control while the Direct Selection tool is chosen and a corner of an object is selected
|
||||
/// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html>).
|
||||
/// A live rectangle's and a live polygon's own rows already set the selected corners.
|
||||
fn corner_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if app.session.tool_id() != "directSelection" {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let [id] = st.selection.objects[..] else { return };
|
||||
let Some(n) = st.doc.node(id) else { return };
|
||||
if matches!(n.live_shape(), Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) {
|
||||
return;
|
||||
}
|
||||
let Some(lc) = LiveCorners::of(n) else { return };
|
||||
if st.selection.partial(id).is_none_or(|a| lc.corners_of(a).is_empty()) {
|
||||
return;
|
||||
}
|
||||
let n = n.clone();
|
||||
let kind = super::corner_style(app, &n).1;
|
||||
let kinds: Vec<&str> = vectorcraft_geom::shapes::CornerKind::ALL.iter().map(|k| crate::dialogs::corners::kind_label(*k)).collect();
|
||||
let fw = super::field_width(ui);
|
||||
egui::Grid::new("props-corners").num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| {
|
||||
dim_label(ui, tl!("Corner:"));
|
||||
let picked = widgets::dropdown_names(ui, ("props-corners", "kind"), kind.map_or("", crate::dialogs::corners::kind_label), &kinds, fw);
|
||||
if let Some(k) = picked.and_then(|i| vectorcraft_geom::shapes::CornerKind::ALL.get(i)) {
|
||||
app.run("object.setLiveShape", json!({"kind": k})).ok();
|
||||
}
|
||||
ui.end_row();
|
||||
dim_label(ui, tl!("Corner Radius:"));
|
||||
super::corner_radius_field(app, ui, &n, ("props-corners", "radius"), fw);
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
/// Start Global Edit (Stop Global Edit while it is on) with the Global Edit Options beside it, in
|
||||
/// Quick Actions as the help page places them
|
||||
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>).
|
||||
@@ -713,6 +768,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(n) = first_selected(app) else { return };
|
||||
section_header(ui, tl!("Appearance"));
|
||||
fill_stroke_rows(app, ui);
|
||||
width_profile_row(app, ui);
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0));
|
||||
@@ -724,16 +780,68 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.ui.open_panel = Some("transparency".into());
|
||||
}
|
||||
});
|
||||
// The effects of one selected object: the fill's and stroke's as well as its own.
|
||||
let effects: Vec<(Option<usize>, usize, &str)> = if super::selection_len(app) == 1 {
|
||||
let own = n.appearance.effects.iter().enumerate().map(|(k, e)| (None, k, e.id.as_str()));
|
||||
let items =
|
||||
n.appearance.items.iter().enumerate().flat_map(|(i, it)| it.effects().iter().enumerate().map(move |(k, e)| (Some(i), k, e.id.as_str())));
|
||||
own.chain(items).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
// The fx button opens the effect menu, as the Appearance panel's does.
|
||||
let r = widgets::flat_button(ui, "fx", 34.0).on_hover_text(tl!("Add New Effect"));
|
||||
egui::Popup::menu(&r).show(|ui| super::appearance::fx_menu(app, ui));
|
||||
effect_delete(app, ui, n.id, &effects);
|
||||
if widgets::icon_button(ui, "ellipsis", tl!("Appearance panel"), false, 22.0).clicked() {
|
||||
app.ui.open_panel = Some("appearance".into());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Delete icon beside the fx button, as the Properties panel overview has it
|
||||
/// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html>):
|
||||
/// with one effect applied, its name and an icon that removes it (`effect.remove`); with several,
|
||||
/// the icon opens the Appearance panel to remove them there. Nothing without effects.
|
||||
fn effect_delete(app: &mut VectorcraftApp, ui: &mut Ui, id: vectorcraft_doc::NodeId, effects: &[(Option<usize>, usize, &str)]) {
|
||||
match effects {
|
||||
[] => {}
|
||||
[(item, k, effect)] => {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let name = super::appearance::shown_effect_label(effect);
|
||||
// A long name is cut short before the Delete and Appearance panel icons.
|
||||
let room = (ui.available_width() - 2.0 * (22.0 + ui.spacing().item_spacing.x)).max(0.0);
|
||||
ui.allocate_ui(egui::vec2(room, 22.0), |ui| ui.add(egui::Label::new(egui::RichText::new(name).size(12.0).color(t.text)).truncate()));
|
||||
if widgets::icon_button(ui, "trash-2", tl!("Remove Effect"), false, 22.0).clicked() {
|
||||
app.run("effect.remove", json!({"ids": [id.0], "item": item, "index": k})).ok();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if widgets::icon_button(ui, "trash-2", tl!("Remove Effect"), false, 22.0).clicked() {
|
||||
app.ui.open_panel = Some("appearance".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke's Variable Width Profile (the Control bar's list), shown while the Width tool is in
|
||||
/// use or the stroke has a variable width, as the Properties panel overview places it
|
||||
/// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html>).
|
||||
fn width_profile_row(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let stroke = super::current_stroke(app);
|
||||
let profile = stroke.as_ref().and_then(|s| s.profile.as_ref());
|
||||
if app.session.tool_id() != "width" && WidthProfile::id_of(profile) == "uniform" {
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Profile:"));
|
||||
if let Some(id) = super::stroke::profile_dropdown(app, ui, profile) {
|
||||
app.run("stroke.set", json!({"profile": id})).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Fill and Stroke rows, with the Control bar's widgets: the chips (a click opens the
|
||||
/// swatches, Shift-click the mixer), the Stroke link that opens the Stroke panel as a popover
|
||||
/// and the weight spinner with its presets. With nothing selected they set up the next object.
|
||||
@@ -1979,4 +2087,248 @@ mod tests {
|
||||
let b = d.node(vectorcraft_doc::NodeId(r)).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 40.0, 20.0, 80.0));
|
||||
}
|
||||
|
||||
/// One frame of the panel 1600 points tall, so Quick Actions at its foot are laid out, with
|
||||
/// `events` → the texts painted, with their rects.
|
||||
fn tall(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 1600.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clicks `at` in the tall panel over two frames (press, release), then lets a popup it opens
|
||||
/// lay out → the texts of the frame after.
|
||||
fn press(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) -> Vec<(String, Rect)> {
|
||||
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
tall(app, ctx, vec![Event::PointerMoved(at), b(true)]);
|
||||
tall(app, ctx, vec![b(false)]);
|
||||
tall(app, ctx, vec![]);
|
||||
tall(app, ctx, vec![])
|
||||
}
|
||||
|
||||
/// Where the tall panel painted `label`.
|
||||
fn text_at(texts: &[(String, Rect)], label: &str) -> Rect {
|
||||
texts.iter().find(|(t, _)| t == label).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"))
|
||||
}
|
||||
|
||||
fn shows(texts: &[(String, Rect)], label: &str) -> bool {
|
||||
texts.iter().any(|(t, _)| t == label)
|
||||
}
|
||||
|
||||
fn undo_depth(app: &VectorcraftApp) -> usize {
|
||||
app.session.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn new_app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> serde_json::Value {
|
||||
app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].clone()
|
||||
}
|
||||
|
||||
/// One blend selected with the Blend tool has Blend Options in Quick Actions, which opens the
|
||||
/// Blend Options dialog on it; OK is one undo step. Not with another tool, nor for a blend
|
||||
/// selected with another object.
|
||||
#[test]
|
||||
fn blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool() {
|
||||
let mut app = new_app();
|
||||
let (a, b) = (rect(&mut app, 20.0, 20.0, 40.0, 40.0), rect(&mut app, 200.0, 20.0, 40.0, 40.0));
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
let blend = app.run("object.blend.make", json!({})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [blend]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "not with the Selection tool");
|
||||
app.select_tool("blend");
|
||||
app.run("select.set", json!({"ids": [blend]})).unwrap();
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
let depth = undo_depth(&app);
|
||||
press(&mut app, &ctx, text_at(&texts, "Blend Options").center());
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::blend_options::KIND));
|
||||
assert_eq!(undo_depth(&app), depth, "opening the dialog changes nothing");
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("spacing".into(), json!("steps"));
|
||||
d.fields.insert("steps".into(), json!(3));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(undo_depth(&app), depth + 1, "OK is one undo step");
|
||||
let c = rect(&mut app, 20.0, 200.0, 40.0, 40.0);
|
||||
app.select_tool("blend");
|
||||
app.run("select.set", json!({"ids": [blend, c]})).unwrap();
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a blend and another object");
|
||||
app.run("select.set", json!({"ids": [c]})).unwrap();
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a rectangle");
|
||||
}
|
||||
|
||||
/// A Live Paint group selected with a path has Merge Live Paint in Quick Actions, which adds
|
||||
/// the path to the group in one undo step. Not for the group alone, nor for plain paths.
|
||||
#[test]
|
||||
fn merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more() {
|
||||
let mut app = new_app();
|
||||
let (a, b) = (rect(&mut app, 20.0, 20.0, 100.0, 60.0), rect(&mut app, 60.0, 40.0, 100.0, 60.0));
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
let group = app.run("livePaint.make", json!({})).unwrap()["id"].clone();
|
||||
let (c, d) = (rect(&mut app, 140.0, 30.0, 60.0, 60.0), rect(&mut app, 300.0, 200.0, 40.0, 40.0));
|
||||
let ctx = egui::Context::default();
|
||||
for (ids, why) in [(json!([group]), "the group alone"), (json!([c, d]), "two plain paths")] {
|
||||
app.run("select.set", json!({"ids": ids})).unwrap();
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Merge Live Paint"), "{why}");
|
||||
}
|
||||
app.run("select.set", json!({"ids": [group, c]})).unwrap();
|
||||
let c_id = vectorcraft_doc::NodeId(c.as_u64().unwrap());
|
||||
let parent = app.session.active().unwrap().doc.parent_of(c_id);
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
let depth = undo_depth(&app);
|
||||
press(&mut app, &ctx, text_at(&texts, "Merge Live Paint").center());
|
||||
assert_eq!(undo_depth(&app), depth + 1, "one undo step");
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.selection.objects.iter().map(|n| json!(n.0)).collect::<Vec<_>>(), std::slice::from_ref(&group));
|
||||
assert!(st.doc.node(c_id).is_none() || st.doc.parent_of(c_id) != parent, "the path is merged into the group");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.parent_of(c_id), parent, "Undo takes it out again");
|
||||
}
|
||||
|
||||
/// A stroke with a variable width shows its Width Profile in the Appearance section, as every
|
||||
/// stroke does with the Width tool; choosing another profile is one undo step. A uniform stroke
|
||||
/// shows none with other tools.
|
||||
#[test]
|
||||
fn the_width_profile_shows_for_a_variable_width_or_the_width_tool() {
|
||||
let mut app = new_app();
|
||||
let line = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 300, "y2": 100})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [line]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Profile:"), "a uniform stroke");
|
||||
app.select_tool("width");
|
||||
app.run("select.set", json!({"ids": [line]})).unwrap();
|
||||
assert!(shows(&tall(&mut app, &ctx, vec![]), "Profile:"), "the Width tool");
|
||||
app.select_tool("selection");
|
||||
app.run("select.set", json!({"ids": [line]})).unwrap();
|
||||
app.run("stroke.set", json!({"profile": "lens"})).unwrap();
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
// The list sits right of its label.
|
||||
let spacing = ctx.global_style().spacing.item_spacing.x;
|
||||
let texts = press(&mut app, &ctx, text_at(&texts, "Profile:").right_center() + vec2(spacing + 50.0, 0.0));
|
||||
let depth = undo_depth(&app);
|
||||
press(&mut app, &ctx, text_at(&texts, "Taper Start").center());
|
||||
assert_eq!(undo_depth(&app), depth + 1, "one undo step");
|
||||
let profile = app
|
||||
.session
|
||||
.active()
|
||||
.unwrap()
|
||||
.doc
|
||||
.node(vectorcraft_doc::NodeId(line.as_u64().unwrap()))
|
||||
.unwrap()
|
||||
.appearance
|
||||
.stroke()
|
||||
.unwrap()
|
||||
.profile
|
||||
.clone();
|
||||
assert_eq!(WidthProfile::id_of(profile.as_ref()), "taperStart");
|
||||
}
|
||||
|
||||
/// With Direct Selection, a path whose corner is selected shows that corner's kind and radius;
|
||||
/// each edit sets that corner alone in one undo step. Not with the Selection tool, nor with the
|
||||
/// whole path selected; a live rectangle keeps its own Corner Radius row instead.
|
||||
#[test]
|
||||
fn direct_selected_corners_show_their_kind_and_radius() {
|
||||
let mut app = new_app();
|
||||
let star = app.run("shape.star", json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("select.anchors", json!({"id": star, "anchors": [[0, 2]], "mode": "set"})).unwrap();
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Corner:"), "not with the Selection tool");
|
||||
app.select_tool("directSelection");
|
||||
app.run("select.anchors", json!({"id": star, "anchors": [[0, 2]], "mode": "set"})).unwrap();
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
assert!(shows(&texts, "Corner:") && shows(&texts, "Corner Radius:"), "{texts:?}");
|
||||
let corners = |app: &VectorcraftApp| {
|
||||
let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(star)).unwrap().clone();
|
||||
let lc = LiveCorners::of(&n).unwrap();
|
||||
(lc.radius(0), lc.radius(2), lc.kind(0), lc.kind(2))
|
||||
};
|
||||
// The radius: the field right of its label.
|
||||
let label = text_at(&texts, "Corner Radius:");
|
||||
let field = texts.iter().find(|(t, r)| t == "0 pt" && r.left() > label.right() && (r.center().y - label.center().y).abs() < 6.0).unwrap().1;
|
||||
press(&mut app, &ctx, field.center());
|
||||
let depth = undo_depth(&app);
|
||||
let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
tall(&mut app, &ctx, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
|
||||
tall(&mut app, &ctx, vec![Event::Text("6".into())]);
|
||||
tall(&mut app, &ctx, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
assert_eq!(undo_depth(&app), depth + 1, "one undo step");
|
||||
let round = vectorcraft_geom::shapes::CornerKind::Round;
|
||||
assert_eq!(corners(&app), (0.0, 6.0, round, round));
|
||||
// The kind, from its list.
|
||||
let texts = press(&mut app, &ctx, text_at(&texts, "Round").center());
|
||||
press(&mut app, &ctx, text_at(&texts, "Chamfer").center());
|
||||
assert_eq!(undo_depth(&app), depth + 2, "one undo step");
|
||||
assert_eq!(corners(&app), (0.0, 6.0, round, vectorcraft_geom::shapes::CornerKind::Chamfer));
|
||||
// The whole path selected.
|
||||
app.run("select.set", json!({"ids": [star]})).unwrap();
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), "Corner:"), "no corner selected");
|
||||
// A live rectangle's corner: its own Corner Radius row, once.
|
||||
let r = rect(&mut app, 20.0, 20.0, 60.0, 40.0).as_u64().unwrap();
|
||||
app.run("select.anchors", json!({"id": r, "anchors": [[0, 1]], "mode": "set"})).unwrap();
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
assert!(!shows(&texts, "Corner:"), "{texts:?}");
|
||||
assert_eq!(texts.iter().filter(|(t, _)| t == "Corner Radius:").count(), 1, "{texts:?}");
|
||||
}
|
||||
|
||||
/// One effect on the one selected object shows its name and a Delete icon beside the fx button,
|
||||
/// which removes it in one undo step, the object's own or its stroke's. With several effects the
|
||||
/// icon opens the Appearance panel; without one, or with several objects selected, no name.
|
||||
#[test]
|
||||
fn one_effect_has_a_delete_icon_in_appearance() {
|
||||
let mut app = new_app();
|
||||
let id = rect(&mut app, 20.0, 20.0, 80.0, 60.0);
|
||||
let node = vectorcraft_doc::NodeId(id.as_u64().unwrap());
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let spacing = ctx.global_style().spacing.item_spacing.x;
|
||||
let effects = |app: &VectorcraftApp| {
|
||||
let n = app.session.active().unwrap().doc.node(node).unwrap().clone();
|
||||
n.appearance.effects.len() + n.appearance.items.iter().map(|i| i.effects().len()).sum::<usize>()
|
||||
};
|
||||
let name = super::super::appearance::shown_effect_label("stylize.dropShadow");
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), &name), "no effect");
|
||||
// The object's own effect.
|
||||
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
let depth = undo_depth(&app);
|
||||
press(&mut app, &ctx, text_at(&texts, &name).right_center() + vec2(spacing + 11.0, 0.0));
|
||||
assert_eq!((effects(&app), undo_depth(&app)), (0, depth + 1), "removed in one undo step");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(effects(&app), 1);
|
||||
// The stroke's effect.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
let stroke = app.session.active().unwrap().doc.node(node).unwrap().appearance.items.iter().position(|i| !i.is_fill()).unwrap();
|
||||
app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})).unwrap();
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
let depth = undo_depth(&app);
|
||||
press(&mut app, &ctx, text_at(&texts, &name).right_center() + vec2(spacing + 11.0, 0.0));
|
||||
assert_eq!((effects(&app), undo_depth(&app)), (0, depth + 1), "the stroke's effect removed in one undo step");
|
||||
// Two effects: the icon opens the Appearance panel.
|
||||
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
|
||||
app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": stroke})).unwrap();
|
||||
let texts = tall(&mut app, &ctx, vec![]);
|
||||
assert!(!shows(&texts, &name), "{texts:?}");
|
||||
let depth = undo_depth(&app);
|
||||
press(&mut app, &ctx, text_at(&texts, "fx").center() + vec2(17.0 + spacing + 11.0, 0.0));
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("appearance"));
|
||||
assert_eq!((effects(&app), undo_depth(&app)), (2, depth));
|
||||
// Two objects with one effect each: no name.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
let other = rect(&mut app, 200.0, 20.0, 40.0, 40.0);
|
||||
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
|
||||
app.run("select.set", json!({"ids": [id, other]})).unwrap();
|
||||
assert!(!shows(&tall(&mut app, &ctx, vec![]), &name), "two objects");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user