Files
vectorcraft/crates/ui-egui/src/tests_place.rs

401 lines
19 KiB
Rust
Raw Permalink 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.
//! File → Place in the app: the Place dialog, files dropped on the window, the loaded place
//! cursor and the Control bar's image details.
use std::sync::Arc;
use egui::{Pos2, Shape, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
use crate::canvas::Xf;
use crate::place::{DropAt, DropTarget};
/// A `w`×`h` red PNG declaring `ppi`.
fn png(w: u32, h: u32, ppi: f64) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::from_pixel(w, h, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out, (ppi, ppi))
}
/// `bytes` written to a fresh temporary file named `name` → its path.
fn temp_file(name: &str, bytes: &[u8]) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-place-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().to_string()
}
/// An app reading files from disk, with a 400×300 document.
fn app() -> VectorcraftApp {
let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app
}
/// File → Place… with `path` chosen in the file picker.
fn place_picked(app: &mut VectorcraftApp, path: &str) {
let path = path.to_string();
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(path.clone())));
app.run("file.place", json!({})).unwrap();
}
#[derive(Debug)]
struct Dropped(std::path::PathBuf);
impl egui::DroppedFile for Dropped {
fn path(&self) -> &std::path::Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, String> {
std::fs::read(&self.0).map_err(|e| e.to_string())
}
}
/// One headless frame of the whole window (800×600) with `events` and `dropped` files.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, mut events: Vec<egui::Event>, dropped: &[&str], shift: bool) {
events.push(egui::Event::ModifiersChanged(egui::Modifiers { shift, ..Default::default() }));
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
events,
dropped_files: dropped.iter().map(|p| Arc::new(Dropped(p.into())) as egui::DroppedFileHandle).collect(),
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node {
let st = app.session.active().unwrap();
let n = st.doc.node(st.selection.objects[0]).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Image(_)), "{:?}", n.kind);
n
}
/// A tiny SVG document.
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="50" height="40"><rect width="20" height="10"/></svg>"#;
/// Two frames: fonts, then the canvas lays out → the canvas.
fn laid_out(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect {
frame(app, ctx, vec![], &[], false);
frame(app, ctx, vec![], &[], false);
app.canvas_rect.expect("the canvas is laid out")
}
/// Where the platform tells where files were dropped (the web), they go as in Illustrator: placed
/// at the pointer on the canvas whatever they are, opened off it (the tab bar).
#[test]
fn a_drop_with_a_position_places_on_the_canvas_and_opens_off_it() {
let mut app = app();
let ctx = egui::Context::default();
let rect = laid_out(&mut app, &ctx);
let pos = rect.center() + vec2(60.0, -40.0);
let want = Xf::new(rect, app.view().unwrap()).to_doc(pos);
let uid = app.session.active().unwrap().uid;
for name in ["a.png", "a.svg", "a.ai", "a.vectorcraft"] {
assert_eq!(app.drop_target(name, Some(pos), true), DropTarget::Place(DropAt { doc: uid, at: want, embed: true }), "{name}");
assert_eq!(app.drop_target(name, Some(Pos2::new(300.0, rect.top() - 10.0)), false), DropTarget::Open, "{name} on the tab bar");
}
}
/// Desktop drags carry no position (#472): a dropped document opens as a tab of its own, a
/// picture or text is placed in the middle of the view.
#[test]
fn a_drop_without_a_position_opens_documents_and_places_pictures() {
let mut app = app();
let ctx = egui::Context::default();
laid_out(&mut app, &ctx);
let center = app.view().unwrap().center;
let uid = app.session.active().unwrap().uid;
for name in ["a.svg", "a.svgz", "a.pdf", "a.ai", "a.eps", "a.vectorcraft", "a.drawcraft", "a.vctemplate", "a.dxf", "a.emf"] {
assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name}");
}
for name in ["a.png", "a.jpg", "a.tiff", "a.webp", "a.txt"] {
assert_eq!(app.drop_target(name, None, false), DropTarget::Place(DropAt { doc: uid, at: center, embed: false }), "{name}");
}
}
#[test]
fn a_dropped_png_is_placed_in_the_middle_of_the_view() {
let mut app = app();
let ctx = egui::Context::default();
laid_out(&mut app, &ctx);
let path = temp_file("drop.png", &png(20, 10, 72.0));
// Where egui last saw the pointer is stale during a desktop drag: it doesn't count.
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0))], &[&path], false);
let n = selected_image(&app);
let want = app.view().unwrap().center;
let c = n.geometric_bounds().unwrap().center();
assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "{c:?} vs {want:?}");
assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_ref().map(|l| l.path.as_str()) == Some(path.as_str())), "linked by default");
assert_eq!(app.session.documents().len(), 1, "placed, not opened");
// Shift embeds.
frame(&mut app, &ctx, vec![], &[&path], true);
assert!(matches!(&selected_image(&app).kind, NodeKind::Image(im) if im.link.is_none()));
}
/// #472: a document dropped on the window opens as a new tab, not into the open document; the
/// pictures dropped with it are placed in the document that was open, before it opens.
#[test]
fn a_dropped_document_opens_as_a_tab_of_its_own() {
let mut app = app();
let ctx = egui::Context::default();
laid_out(&mut app, &ctx);
let svg = temp_file("dropped.svg", SVG.as_bytes());
let pic = temp_file("with-it.png", &png(8, 8, 72.0));
frame(&mut app, &ctx, vec![], &[&svg, &pic], false);
assert_eq!(app.session.documents().len(), 2, "opened, not placed");
assert_eq!(app.session.active_index(), Some(1), "the document opened is shown");
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(svg.as_str()));
assert_eq!((images(&app, 0), images(&app, 1)), (1, 0), "the picture is placed in the document that was open");
assert_eq!(app.ui.recent_files.first(), Some(&svg));
}
#[test]
fn files_dropped_with_no_document_open_open_and_are_recent() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let path = temp_file("open-me.png", &png(8, 8, 72.0));
frame(&mut app, &ctx, vec![], &[], false);
frame(&mut app, &ctx, vec![], &[&path], false);
assert_eq!(app.session.documents().len(), 1, "no document: the drop opens");
assert_eq!(app.ui.recent_files.first(), Some(&path));
}
/// The images in open document `i`.
fn images(app: &VectorcraftApp, i: usize) -> usize {
let mut n = 0;
app.session.documents()[i].doc.walk(|node| n += usize::from(matches!(node.kind, NodeKind::Image(_))));
n
}
/// The web reads dropped files asynchronously (#359): a file that arrives after another document
/// became active still lands where it was dropped, and is dropped itself when that document closed.
#[test]
fn a_file_read_after_another_document_became_active_lands_where_it_was_dropped() {
let mut app = app();
let inbox = crate::place::PlaceInbox::default();
app.services.place_inbox = Some(inbox.clone());
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![], &[], false);
frame(&mut app, &ctx, vec![], &[], false);
let rect = app.canvas_rect.expect("the canvas is laid out");
let pos = rect.center() + vec2(60.0, -40.0);
let drop_on_active = |app: &VectorcraftApp| match app.drop_target("late.png", Some(pos), false) {
DropTarget::Place(d) => d,
t => panic!("{t:?}"),
};
let arrive =
|name: &str, drop| inbox.lock().unwrap().push(crate::place::PlaceArrival { name: name.into(), bytes: png(20, 10, 72.0), drop: Some(drop) });
// Dropped on A; another document opens while the file is read.
let on_a = drop_on_active(&app);
let want = Xf::new(rect, app.view().unwrap()).to_doc(pos);
app.run("file.new", json!({"width": 600, "height": 500})).unwrap();
assert_eq!(app.session.active_index(), Some(1));
arrive("late.png", on_a);
frame(&mut app, &ctx, vec![], &[], false);
assert_eq!(app.session.active_index(), Some(0), "the document it was dropped on is active again");
assert_eq!((images(&app, 0), images(&app, 1)), (1, 0));
let c = selected_image(&app).geometric_bounds().unwrap().center();
assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "where it was dropped: {c:?} vs {want:?}");
// Dropped on B, which closes before the file arrives: nothing is placed.
app.run("document.activate", json!({"index": 1})).unwrap();
let on_b = drop_on_active(&app);
app.run("file.close", json!({})).unwrap();
assert_eq!(app.session.documents().len(), 1);
arrive("orphan.png", on_b);
frame(&mut app, &ctx, vec![], &[], false);
assert_eq!(images(&app, 0), 1, "not placed in the document left open");
assert!(app.ui.status.contains("orphan.png") && app.ui.status.contains("closed"), "{}", app.ui.status);
}
#[test]
fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() {
let mut app = app();
let a = temp_file("a.png", &png(300, 150, 300.0));
let b = temp_file("b.png", &png(10, 10, 72.0));
app.run("file.place", json!({"paths": [a, b]})).unwrap();
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert_eq!(d.kind, crate::dialogs::place::KIND);
assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one file and one selected object");
assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)");
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for label in ["Place", "a.png", "b.png", "Link", "Template", "Replace", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.tool_id(), "place");
assert_eq!(app.session.tool_options()["count"], 2);
// The cursor carries the current file's thumbnail and the number of files.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut shapes = vec![];
for _ in 0..2 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::place::paint_cursor(&mut app, ui.ctx(), ui.painter(), Pos2::new(50.0, 50.0)));
out.textures_delta.clear();
shapes = out.shapes;
}
let mut text = String::new();
let mut textured = false;
for s in shapes {
match s.shape {
Shape::Text(t) => text.push_str(t.galley.text()),
Shape::Mesh(m) => textured |= m.texture_id != egui::TextureId::default(),
_ => {}
}
}
assert!(textured, "the thumbnail");
assert_eq!(text, "2", "the badge");
}
#[test]
fn one_file_places_centred_in_the_view_and_replace_swaps_the_selection() {
let mut app = app();
app.view_mut().unwrap().center = vectorcraft_geom::Point::new(120.0, 80.0);
place_picked(&mut app, &temp_file("one.png", &png(300, 150, 300.0)));
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert_eq!(d.kind, crate::dialogs::place::KIND);
assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one selected object");
assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)");
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for label in ["Place", "one.png", "Link", "Template", "Replace", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let first = selected_image(&app);
assert_eq!(first.geometric_bounds().unwrap().center(), vectorcraft_geom::Point::new(120.0, 80.0), "centred in the view");
// With one object selected, Replace applies.
place_picked(&mut app, &temp_file("two.png", &png(40, 40, 144.0)));
assert!(app.ui.dialog.as_ref().unwrap().bool("__replace"));
app.ui.dialog.as_mut().unwrap().fields.insert("replace".into(), json!(true));
app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false));
crate::dialogs::confirm(&mut app).unwrap();
let second = selected_image(&app);
assert_eq!(second.name.as_deref(), Some("two.png"));
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1, "replaced");
assert!(!app.ui.place_link, "Link is remembered");
}
#[test]
fn the_control_bar_shows_the_image_file_link_colour_mode_and_ppi() {
let mut app = app();
let path = temp_file("photo.png", &png(30, 30, 300.0));
app.run("file.place", json!({"path": path})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar);
for s in ["Linked File", "photo.png", "RGB PPI: 300"] {
assert!(text.contains(s), "{s} in {text}");
}
let v: Value = app.run("file.place", json!({"path": path, "link": false})).unwrap();
assert_eq!(v["linked"], false);
let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar);
assert!(text.contains("Embedded"), "{text}");
}
/// Click the Control bar's `label`.
fn click_label(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
use crate::tests_removeanchors::{at, click_control, control_frame};
let p = at(&control_frame(app, ctx, vec![]), label);
click_control(app, ctx, p);
}
#[test]
fn the_control_bar_and_properties_trace_a_selected_image() {
use crate::panels::image_trace::{TRACE_BUTTON_W, selected_preset};
use crate::tests_removeanchors::{at, click_control, control_frame, has, properties_frame};
let mut app = app();
let data = vectorcraft_format::base64_encode(&png(30, 30, 72.0));
app.run("file.place", json!({"name": "red.png", "dataBase64": data, "link": false})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let bar = control_frame(&mut app, &ctx, vec![]);
for s in ["Embedded", "Unembed…", "Image Trace", "Mask", "Crop Image", "Opacity:"] {
assert!(has(&bar, s), "{s}");
}
assert!(!has(&bar, "Stroke:"), "an image has no Fill and Stroke in the Control bar");
let props = properties_frame(&mut app, 260.0);
for s in ["Unembed…", "Image Trace", "Mask", "Crop Image"] {
assert!(has(&props, s), "Properties: {s}");
}
// The button's arrow lists the presets; choosing one traces the image with it.
click_control(&mut app, &ctx, at(&bar, "Image Trace") + vec2(TRACE_BUTTON_W / 2.0, 0.0));
click_label(&mut app, &ctx, "6 Colors");
assert_eq!(selected_preset(&app).as_deref(), Some("6 Colors"));
// The button itself traces with the Default preset.
app.run("edit.undo", json!({})).unwrap();
click_label(&mut app, &ctx, "Image Trace");
assert_eq!(selected_preset(&app).as_deref(), Some("Default"));
// An Image Trace object: its preset (another traces it again), the panel and Expand.
let bar = control_frame(&mut app, &ctx, vec![]);
for s in ["Image Tracing", "Preset:", "Default", "Expand"] {
assert!(has(&bar, s), "{s}");
}
assert!(has(&properties_frame(&mut app, 260.0), "Release"));
click_control(&mut app, &ctx, at(&bar, "Default"));
click_label(&mut app, &ctx, "3 Colors");
assert_eq!(selected_preset(&app).as_deref(), Some("3 Colors"));
click_label(&mut app, &ctx, "Expand");
let st = app.session.active().unwrap();
let g = st.doc.node(st.selection.objects[0]).unwrap();
assert!(selected_preset(&app).is_none() && g.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_))), "expanded");
}
#[test]
fn the_control_bars_mask_clips_the_image_and_selects_the_clipping_path() {
let mut app = app();
let data = vectorcraft_format::base64_encode(&png(30, 30, 72.0));
let img = app.run("file.place", json!({"name": "red.png", "dataBase64": data, "link": false})).unwrap()["ids"][0].as_u64().unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
click_label(&mut app, &ctx, "Mask");
let st = app.session.active().unwrap();
let group = &st.doc.layers[0].children().unwrap()[0];
assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }));
let ids: Vec<u64> = group.children().unwrap().iter().map(|c| c.id.0).collect();
assert_eq!((ids[1], st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>()), (img, vec![ids[0]]));
}
#[test]
fn a_vectorcraft_document_places_linked_and_edit_original_opens_it() {
let mut app = app();
// A one-artboard document with a rectangle.
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
let bytes = vectorcraft_format::save_file(&src.active().unwrap().doc);
let path = temp_file("badge.vectorcraft", &bytes);
place_picked(&mut app, &path);
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert!(d.bool("link") && d.bool("__documents") && !d.bool("__others"), "Link on, for a document");
// Link's tooltip says what it does for a document.
assert!(crate::dialogs::place::link_tip(d).contains("editable copy"));
crate::dialogs::confirm(&mut app).unwrap();
let st = app.session.active().unwrap();
let id = st.selection.objects[0];
let vectorcraft_doc::NodeKind::PlacedDocument(p) = &st.doc.node(id).unwrap().kind else { panic!("not a placed document") };
assert_eq!(p.link.path, path);
// Edit Original opens the document in a new tab.
let tabs = app.session.documents().len();
app.run("links.editOriginal", json!({})).unwrap();
assert_eq!(app.session.documents().len(), tabs + 1);
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(path.as_str()));
// A PNG keeps the image tooltip; a PNG and a document, both.
let pic = temp_file("pic.png", &png(10, 10, 72.0));
place_picked(&mut app, &pic);
let d = app.ui.dialog.as_ref().unwrap();
assert!(!d.bool("__documents") && crate::dialogs::place::link_tip(d).contains("image file"));
app.ui.dialog = None;
let both = vec![pic.clone(), path.clone()];
app.services.pick_open_multi = Some(Box::new(move || both.clone()));
app.run("file.place", json!({})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert!(crate::dialogs::place::link_tip(d).contains("VectorCraft documents"));
}