Files
vectorcraft/crates/ui-egui/src/tests_removeanchors.rs
T
bflatasticandClaude Opus 5.5 d6f8cc879f Control bar: image controls (Image Trace, Embed, Crop, Mask) when an image is selected
Discord feedback: Image Trace and the other image options were hidden when an image was
selected; copy, paste, Image Trace should work from the Control bar as in Illustrator.

- Control bar, one image selected: Linked File/Embedded, file name, colour mode and PPI, then
  Embed (+ Edit Original) or Unembed…, an Image Trace split button (click: Default preset;
  arrow: trace with any preset), Mask and Crop Image, in place of Fill and Stroke.
- Control bar, one Image Trace object selected: "Image Tracing", its Preset (choosing another
  traces it again), the Image Trace panel and Expand.
- Properties panel: Image Trace (split), Crop Image and Mask in Quick Actions for an image; an
  Image Trace section (Preset, Expand, Release) for an Image Trace object. Embed/Unembed buttons
  are shared with the Control bar (place::link_buttons).
- New engine command object.maskImage ("Mask"): clips the selected image with a clipping path
  on its outline (rotated with it) and selects that path so its handles crop; one undo step.
- widgets::split_button (flat button with a menu arrow).
- Translations for the new strings (es, ja, zh-hans, zh-hant; "Mask" also pt-br, cs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:15:48 +07:00

162 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Object › Path › Remove Anchor Points: the menu, the Control bar, the contextual task bar, the
//! Properties panel and the context menu run it on direct-selected anchors.
use egui::{Event, PointerButton, Pos2, Rect, Shape, vec2};
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_engine::Session;
use vectorcraft_geom::Point;
use crate::canvas::Xf;
use crate::menus::{self, Item};
use crate::{VectorcraftApp, canvas, chrome};
fn 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) -> NodeId {
NodeId(app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap())
}
fn anchor_count(app: &VectorcraftApp, id: NodeId) -> usize {
app.session.active().and_then(|st| st.doc.node(id)).and_then(|n| n.path_data()).map(|p| p.anchor_count()).unwrap_or(0)
}
fn labels(items: &[Item]) -> Vec<&'static str> {
items
.iter()
.flat_map(|it| match it {
Item::Cmd(l, ..) => vec![*l],
Item::Sub(l, ch) => std::iter::once(*l).chain(labels(ch)).collect(),
_ => vec![],
})
.collect()
}
fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> {
fn walk(s: &Shape, v: &mut Vec<(String, Rect)>) {
match s {
Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
_ => {}
}
}
let mut v = vec![];
shapes.iter().for_each(|s| walk(s, &mut v));
v
}
pub(crate) fn control_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| chrome::control_bar(app, ui));
out.textures_delta.clear();
shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
}
pub(crate) fn click_control(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
control_frame(app, ctx, vec![Event::PointerMoved(at), press(true)]);
control_frame(app, ctx, vec![press(false)]);
control_frame(app, ctx, vec![]);
}
fn canvas_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| canvas::show(app, ui));
out.textures_delta.clear();
shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
}
fn click_canvas(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2, button: PointerButton) -> Vec<(String, Rect)> {
let press = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() };
canvas_frame(app, ctx, vec![Event::PointerMoved(at)]);
canvas_frame(app, ctx, vec![press(true)]);
canvas_frame(app, ctx, vec![press(false)]);
canvas_frame(app, ctx, vec![])
}
pub(crate) fn properties_frame(app: &mut VectorcraftApp, width: f32) -> Vec<(String, Rect)> {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
crate::panels::properties::show(app, ui);
});
out.textures_delta.clear();
shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
}
pub(crate) fn has(texts: &[(String, Rect)], label: &str) -> bool {
texts.iter().any(|(t, _)| t == label)
}
pub(crate) fn at(texts: &[(String, Rect)], label: &str) -> Pos2 {
texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}`"))
}
#[test]
fn the_path_menu_and_bars_run_it_for_direct_selected_anchors() {
const LABEL: &str = "Remove Anchor Points";
let mut app = app();
let id = rect(&mut app);
assert_eq!(anchor_count(&app, id), 4);
let entries: Vec<_> = menus::menu_entries(&app).into_iter().filter(|e| e.label == LABEL).collect();
assert_eq!(entries.len(), 1, "one Remove Anchor Points item");
assert_eq!(entries[0].command.as_deref(), Some("path.removeAnchors"));
assert_eq!(entries[0].path, ["Object", "Path"]);
assert!(!entries[0].enabled, "an object selection is not enough");
assert!(!labels(&menus::context_items(&app)).contains(&LABEL));
assert!(crate::palette::items().iter().any(|(_, cmd, _)| cmd == "path.removeAnchors"));
app.select_tool("directSelection");
app.run("select.anchors", json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
assert!(menus::enabled(&app, "path.removeAnchors"));
assert!(labels(&menus::context_items(&app)).contains(&LABEL));
// The Control bar's icon follows its "Anchor Point" label.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let bar = control_frame(&mut app, &ctx, vec![]);
let label = bar.iter().find(|(t, _)| t == "Anchor Point").map(|(_, r)| *r).expect("anchor label");
click_control(&mut app, &ctx, Pos2::new(label.max.x + ctx.global_style().spacing.item_spacing.x + 12.0, label.center().y));
assert_eq!(anchor_count(&app, id), 3, "the Control bar button removes the anchor");
let st = app.session.active().unwrap();
assert_eq!(st.history.undo.last().unwrap().label, LABEL);
let sp = &st.doc.node(id).unwrap().path_data().unwrap().subpaths[0];
assert!(sp.closed, "the path stays closed");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(anchor_count(&app, id), 4);
app.run("select.anchors", json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
// 230 pt is the dock's minimum width. The label has to sit inside that row.
let props = properties_frame(&mut app, 230.0);
let row = props.iter().find(|(t, _)| t == LABEL).expect("properties");
assert!(row.1.min.x >= -0.5 && row.1.max.x <= 230.5, "properties clips the label: {:?}", row.1);
// The task bar's area settles on the second frame.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
canvas_frame(&mut app, &ctx, vec![]);
let texts = canvas_frame(&mut app, &ctx, vec![]);
assert!(has(&texts, LABEL), "task bar: {texts:?}");
// Right-click keeps the anchor selection (the object is already selected) and runs the item.
let p = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(100.0, 90.0));
let texts = click_canvas(&mut app, &ctx, p, PointerButton::Secondary);
assert!(has(&texts, LABEL), "context menu: {texts:?}");
let texts = click_canvas(&mut app, &ctx, at(&texts, LABEL), PointerButton::Primary);
assert_eq!(anchor_count(&app, id), 3);
assert!(!has(&texts, LABEL), "the menu closes after the command");
}
#[test]
fn the_control_bar_and_properties_hide_it_without_anchors() {
let mut app = app();
rect(&mut app);
let props = crate::tests_labels::painted_text(&mut app, crate::panels::properties::show);
assert!(!props.contains("Remove Anchor Points"), "{props}");
}