mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
7940 lines
446 KiB
Rust
7940 lines
446 KiB
Rust
use super::*;
|
||
use vectorcraft_engine::Session;
|
||
use vectorcraft_geom::Shape as _;
|
||
|
||
#[test]
|
||
fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() {
|
||
let mut observed = Vec::new();
|
||
for width in [1324.0 + 29.0 / 32.0, 1324.875, 1325.875] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
ctx.set_pixels_per_point(2.0);
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))),
|
||
max_texture_side: Some(8192),
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR));
|
||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||
show(&mut app, &mut child);
|
||
});
|
||
out.textures_delta.clear();
|
||
let rect = app.canvas_rect.unwrap();
|
||
observed.push((rect, Xf::new(rect, app.view().unwrap()).to_doc(pos2(440.0, 330.0))));
|
||
}
|
||
assert_eq!(observed[0], observed[1], "the same physical canvas must map the same pointer identically");
|
||
assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping");
|
||
}
|
||
|
||
/// A rendered raster becomes the image egui's own conversion makes of it, half-transparent
|
||
/// edges and all; one whose bytes don't match its size becomes an empty image of that size.
|
||
#[test]
|
||
fn rendered_rasters_become_the_same_egui_images() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &serde_json::json!({})).unwrap();
|
||
s.execute("shape.ellipse", &serde_json::json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap();
|
||
s.execute("paint.setFill", &serde_json::json!({"color": "#3366cc"})).unwrap();
|
||
let doc = s.active().unwrap().doc.clone();
|
||
let img = vectorcraft_render::Renderer::new().render(&doc, 97, 61, Affine::IDENTITY, &vectorcraft_render::RenderOptions::default());
|
||
assert!(img.pixels.as_chunks::<4>().0.iter().any(|p| p[3] != 0 && p[3] != 255), "partly covered pixels");
|
||
let egui = egui::ColorImage::from_rgba_premultiplied([97, 61], &img.pixels);
|
||
assert_eq!(color_image(&img), egui);
|
||
let short = vectorcraft_render::Rendered { width: 3, height: 2, pixels: vec![255; 5] };
|
||
assert_eq!(color_image(&short), egui::ColorImage::new([3, 2], vec![egui::Color32::TRANSPARENT; 6]));
|
||
}
|
||
|
||
/// With VECTORCRAFT_SYSTEM_CURSORS set the glyphs aren't drawn and the system's cursors stand in: those of
|
||
/// the Pen and the other drawing tools are the system crosshair already, so Use Precise Cursors makes no
|
||
/// difference to them there.
|
||
#[test]
|
||
fn system_cursors_show_the_same_crosshair_with_precise_cursors_on_or_off() {
|
||
for c in [
|
||
Cursor::Pen,
|
||
Cursor::PenDrawing,
|
||
Cursor::PenAdd,
|
||
Cursor::PenDelete,
|
||
Cursor::PenClose,
|
||
Cursor::PenContinue,
|
||
Cursor::PenJoin,
|
||
Cursor::PenConvert,
|
||
Cursor::Eyedropper,
|
||
Cursor::Slice,
|
||
Cursor::Blend,
|
||
Cursor::BlendObject,
|
||
Cursor::BlendAnchor,
|
||
Cursor::ShapeBuilder,
|
||
Cursor::ShapeBuilderErase,
|
||
Cursor::PencilNew,
|
||
Cursor::PencilStraight,
|
||
Cursor::PencilContinue,
|
||
] {
|
||
assert_eq!(cursor_icon(c), egui::CursorIcon::Crosshair, "{c:?}");
|
||
assert_eq!(cursor_icon(c), cursor_icon(c.precise()), "{c:?}");
|
||
}
|
||
}
|
||
|
||
/// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one
|
||
/// reads x where the canvas has it.
|
||
#[test]
|
||
fn rulers_cover_their_length_at_any_rotation() {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let full = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||
let [top, left, _] = ruler_rects(full);
|
||
for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] {
|
||
let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
|
||
let scale = RulerScale::document(Unit::Points, Point::ZERO);
|
||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx())));
|
||
out.textures_delta.clear();
|
||
// The ticks' positions along each ruler.
|
||
let along = |ruler: egui::Rect, x: bool| -> Vec<f32> {
|
||
let mut at: Vec<f32> = out
|
||
.shapes
|
||
.iter()
|
||
.filter(|c| c.clip_rect == ruler)
|
||
.filter_map(|c| match &c.shape {
|
||
Shape::LineSegment { points: [a, b], .. } if x && a.x == b.x => Some(a.x),
|
||
Shape::LineSegment { points: [a, b], .. } if !x && a.y == b.y => Some(a.y),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
at.sort_by(f32::total_cmp);
|
||
at
|
||
};
|
||
for (ruler, x) in [(top, true), (left, false)] {
|
||
let at = along(ruler, x);
|
||
let (start, end) = if x { (ruler.left(), ruler.right()) } else { (ruler.top(), ruler.bottom()) };
|
||
let (Some(first), Some(last)) = (at.first(), at.last()) else { panic!("{deg}°: no ticks") };
|
||
assert!(first - start < 20.0 && end - last < 20.0, "{deg}°: ticks from {first} to {last} on {start}..{end}");
|
||
assert!(at.windows(2).all(|w| w[1] - w[0] < 20.0), "{deg}°: a gap in the ticks");
|
||
}
|
||
if deg == 0.0 {
|
||
let origin = xf.to_screen(Point::ZERO).x;
|
||
assert!(along(top, true).iter().any(|x| (x - origin).abs() < 0.01), "a tick at x = 0");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_origin_zero_ticks_follow_the_origin_at_any_rotation_and_unit() {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let [top, left, _] = ruler_rects(full);
|
||
let origin = Point::new(350.25, 250.5);
|
||
for deg in [0.0_f64, 30.0, 90.0, 200.0] {
|
||
for unit in [Unit::Points, Unit::Millimeters] {
|
||
let xf = Xf { rect: full, zoom: 1.7, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
|
||
let scale = RulerScale::document(unit, origin);
|
||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx())));
|
||
out.textures_delta.clear();
|
||
let at = xf.to_screen(origin);
|
||
for (r, horizontal) in [(top, true), (left, false)] {
|
||
let zeros: Vec<_> = out
|
||
.shapes
|
||
.iter()
|
||
.filter(|c| c.clip_rect == r)
|
||
.filter_map(|c| match &c.shape {
|
||
Shape::Text(t) if t.galley.text() == "0" => Some(t.pos),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
// Left-ruler labels are separate digits: a zero in "100" isn't the zero label.
|
||
assert!(
|
||
zeros.iter().any(|zero| {
|
||
let delta = if horizontal { zero.x - 2.0 - at.x } else { zero.y - 2.0 - at.y };
|
||
delta.abs() < 0.02
|
||
}),
|
||
"{deg}°, {unit:?}: zeros at {zeros:?}, origin {at:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// View › Rulers › Show Video Rulers counts the artboard in video pixels from its top-left
|
||
/// corner, a pixel as wide as the artboard's Video Ruler Pixel Aspect Ratio. At 0.9 a
|
||
/// 720 × 480 pt artboard is 800 pixels wide and 480 tall: the top ruler reads 0 at its left
|
||
/// edge and 800 at its right, labelled every 100 pixels (90 points on screen at 100%), and the
|
||
/// left ruler counts a pixel a point, from 0 at the top edge to a tick at the bottom one. At 1
|
||
/// (square pixels) the artboard is 720 pixels wide.
|
||
#[test]
|
||
fn video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 720, "height": 480})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "videoRulerPixelAspectRatio": 0.9})).unwrap();
|
||
let ab = s.active().unwrap().doc.artboards[0].clone();
|
||
let r = ab.rect;
|
||
let scale = RulerScale::video(&ab);
|
||
let (x, y) = (vectorcraft_geom::Vec2::new(1.0, 0.0), vectorcraft_geom::Vec2::new(0.0, 1.0));
|
||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||
assert_eq!((scale.unit, scale.origin), (Unit::Pixels, Point::new(r.x0, r.y0)));
|
||
assert!(close(scale.along(Point::new(r.x1, r.y1), x), 800.0) && close(scale.along(Point::new(r.x1, r.y1), y), 480.0));
|
||
assert!(close(scale.along(Point::new(r.x0 + 9.0, r.y0), x), 10.0), "a pixel is 0.9 pt wide");
|
||
assert!(close(scale.zoom_along(2.0, x), 1.8) && close(scale.zoom_along(2.0, y), 2.0));
|
||
let square = RulerScale { stretch: [1.0, 1.0], ..scale };
|
||
assert!(close(square.along(Point::new(r.x1, r.y0), x), 720.0));
|
||
// Drawn at 100%.
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let [top, left, _] = ruler_rects(full);
|
||
let xf = Xf { rect: full, zoom: 1.0, center: r.center(), rot: 0.0 };
|
||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx())));
|
||
out.textures_delta.clear();
|
||
let labels = |ruler: egui::Rect| -> Vec<(String, Pos2)> {
|
||
out.shapes
|
||
.iter()
|
||
.filter(|c| c.clip_rect == ruler)
|
||
.filter_map(|c| match &c.shape {
|
||
Shape::Text(t) => Some((t.galley.text().to_string(), t.pos)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
let (l, rgt) = (xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1)));
|
||
let at = |labels: &[(String, Pos2)], text: &str, x: f32| labels.iter().any(|(t, p)| t == text && (p.x - 2.0 - x).abs() < 0.02);
|
||
let tops = labels(top);
|
||
assert!(at(&tops, "0", l.x) && at(&tops, "800", rgt.x), "{tops:?} from {l:?} to {rgt:?}");
|
||
assert!(at(&tops, "100", l.x + 90.0) && !tops.iter().any(|(t, _)| t == "50"), "{tops:?}");
|
||
assert!(!tops.iter().any(|(t, _)| t == "720"), "points aren't pixels: {tops:?}");
|
||
let lefts = labels(left);
|
||
assert!(lefts.iter().any(|(t, p)| t == "0" && (p.y - 2.0 - l.y).abs() < 0.02), "{lefts:?}");
|
||
let ticks: Vec<f32> = out
|
||
.shapes
|
||
.iter()
|
||
.filter(|c| c.clip_rect == left)
|
||
.filter_map(|c| match &c.shape {
|
||
Shape::LineSegment { points: [a, b], .. } if a.y == b.y => Some(a.y),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(ticks.iter().any(|y| (y - rgt.y).abs() < 0.02), "a tick at 480 px: {ticks:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn shaper_selection_highlights_only_the_visible_result() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
|
||
let line = vectorcraft_doc::NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap());
|
||
for x in [0.0, 150.0] {
|
||
s.execute("shaper.scribble", &json!({"points":[[60.0+x,140],[70.0+x,160],[80.0+x,140],[90.0+x,160]]})).unwrap();
|
||
}
|
||
let st = s.active().unwrap();
|
||
let g = st.doc.node(st.selection.objects[0]).unwrap();
|
||
assert!(g.extras().shaper.is_some());
|
||
let symbols = st.doc.symbol_boxes();
|
||
let bounds = node_outline(g, &symbols).bounding_box();
|
||
assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}");
|
||
assert_eq!(node_outline(st.doc.node(line).unwrap(), &symbols).bounding_box().x0, 50.0, "an isolated original still shows its full outline");
|
||
}
|
||
|
||
/// A clip group made from scratch: a 320 × 40 image and a 300 × 200 ellipse, both reaching
|
||
/// outside a 100 × 100 square at (100, 100) that clips them, and a rectangle beside them. The
|
||
/// task bar and rulers are off. → the app, the clip group, the ellipse and the rectangle.
|
||
fn clip_group_app() -> (VectorcraftApp, serde_json::Value, serde_json::Value, serde_json::Value) {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let img = image::RgbaImage::from_pixel(320, 40, image::Rgba([200, 120, 40, 255]));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&png);
|
||
app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap();
|
||
let image = json!(app.session.active().unwrap().selection.objects[0].0);
|
||
let ellipse = app.run("shape.ellipse", json!({"x": 0, "y": 50, "width": 300, "height": 200})).unwrap()["id"].clone();
|
||
let square = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [image, ellipse, square]})).unwrap();
|
||
let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone();
|
||
let beside = app.run("shape.rectangle", json!({"x": 330, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||
(app, group, ellipse, beside)
|
||
}
|
||
|
||
/// The screen points of what one frame draws in `color` with lines `width` px wide: outlines and
|
||
/// the bounding box, and with `width` 1 the anchors and the bounding box's handles too.
|
||
fn drawn_in(app: &mut VectorcraftApp, ctx: &egui::Context, color: Color32, width: f32) -> Vec<Pos2> {
|
||
let solid = egui::epaint::ColorMode::Solid(color);
|
||
let mut at = vec![];
|
||
for s in shapes(app, ctx) {
|
||
match s {
|
||
Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == width => at.extend(ps.points),
|
||
Shape::Rect(r) if width == 1.0 && (r.fill == color || (r.stroke.color == color && r.stroke.width == width)) => {
|
||
at.extend([r.rect.min, r.rect.max]);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
at
|
||
}
|
||
|
||
/// #2422: a selected clip group shows its clipping path's outline and anchors, the bounding
|
||
/// box of what shows, and not the art the path clips; so do its hover highlight and its key
|
||
/// object outline.
|
||
#[test]
|
||
fn a_selected_clip_group_shows_its_clipping_path_not_its_hidden_art() {
|
||
let (mut app, group, _, beside) = clip_group_app();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap())));
|
||
// The square on screen. `inside` allows 8 px around it for the anchors and the bounding box's handles.
|
||
let clip = egui::Rect::from_two_pos(xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(200.0, 200.0)));
|
||
// Each case that draws nothing, or draws outside the square.
|
||
let mut wrong = vec![];
|
||
let mut inside = |at: Vec<Pos2>, what: &str| {
|
||
if at.is_empty() || !at.iter().all(|q| clip.expand(8.0).contains(*q)) {
|
||
wrong.push(format!("{what}: {at:?}"));
|
||
}
|
||
};
|
||
for tool in ["selection", "directSelection", "groupSelection"] {
|
||
app.select_tool(tool);
|
||
app.run("select.set", json!({"ids": [group]})).unwrap();
|
||
inside(drawn_in(&mut app, &ctx, color, 1.0), tool);
|
||
}
|
||
app.select_tool("selection");
|
||
app.run("select.set", json!({"ids": [group, beside]})).unwrap();
|
||
app.run("select.key", json!({"id": group})).unwrap();
|
||
inside(drawn_in(&mut app, &ctx, color, KEY_OUTLINE), "the key object");
|
||
app.run("select.none", json!({})).unwrap();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 150.0)))]);
|
||
inside(drawn_in(&mut app, &ctx, color, 1.5), "the hover highlight");
|
||
assert!(wrong.is_empty(), "drawn outside {clip:?}:\n{}", wrong.join("\n"));
|
||
}
|
||
|
||
/// A group of four clip groups, each a placed image larger than the rounded square that clips
|
||
/// it: one click with the Selection tool selects the group, which shows the four squares'
|
||
/// outlines and no image's rectangle, and a bounding box that is the union of the squares.
|
||
#[test]
|
||
fn a_group_of_clipped_images_shows_its_masks_and_their_bounds() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let img = image::RgbaImage::from_pixel(160, 160, image::Rgba([40, 120, 200, 255]));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&png);
|
||
// Masks 100 × 100 at x = 20, 150, 280, 410; each image reaches 30 past its mask on every side.
|
||
let masks: Vec<Rect> = (0..4).map(|i| Rect::new(20.0 + 130.0 * i as f64, 100.0, 120.0 + 130.0 * i as f64, 200.0)).collect();
|
||
let mut clips = vec![];
|
||
for m in &masks {
|
||
app.run("file.place", json!({"name": "photo.png", "dataBase64": data, "link": false, "at": [m.center().x, m.center().y]})).unwrap();
|
||
let image = json!(app.session.active().unwrap().selection.objects[0].0);
|
||
let mask = app.run("shape.rectangle", json!({"x": m.x0, "y": m.y0, "width": 100, "height": 100, "radius": 16})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [image, mask]})).unwrap();
|
||
clips.push(app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone());
|
||
}
|
||
app.run("select.set", json!({"ids": clips})).unwrap();
|
||
let group = vectorcraft_doc::NodeId(app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap());
|
||
app.run("select.none", json!({})).unwrap();
|
||
let union = masks.iter().copied().reduce(|a, b| a.union(b)).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
// One click on a visible part of the second image selects the whole group.
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 200.0, 150.0), view).unwrap();
|
||
}
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![group]);
|
||
// The bounding box, the bounds and the transform box fit the masks, not the images.
|
||
let close =
|
||
|r: Option<Rect>| r.is_some_and(|r| [r.x0 - union.x0, r.y0 - union.y0, r.x1 - union.x1, r.y1 - union.y1].iter().all(|d| d.abs() < 1e-9));
|
||
let st = app.session.active().unwrap();
|
||
assert!(close(st.doc.node(group).unwrap().geometric_bounds()), "the group's bounds");
|
||
assert!(close(app.selection_box().map(|b| b.rect)), "the bounding box");
|
||
// Everything drawn in the layer's colour lies on a mask: on its outline, its anchors or the
|
||
// bounding box round them (8 px for the anchors and the box's handles).
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(group));
|
||
let screen = |r: Rect| egui::Rect::from_two_pos(xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1)));
|
||
let drawn = drawn_in(&mut app, &ctx, color, 1.0);
|
||
assert!(!drawn.is_empty());
|
||
let near_a_mask = |q: &Pos2| masks.iter().any(|m| screen(*m).expand(8.0).contains(*q));
|
||
let on_the_box = |q: &Pos2| {
|
||
let b = screen(union);
|
||
b.expand(8.0).contains(*q) && !b.shrink(8.0).contains(*q)
|
||
};
|
||
let stray: Vec<_> = drawn.iter().filter(|q| !near_a_mask(q) && !on_the_box(q)).collect();
|
||
assert!(stray.is_empty(), "drawn off the masks: {stray:?}");
|
||
// Each mask's outline is drawn: points on its left and right edges.
|
||
for m in &masks {
|
||
let r = screen(*m);
|
||
let on = |x: f32| drawn.iter().any(|q| (q.x - x).abs() < 1.0 && q.y > r.min.y + 16.0 && q.y < r.max.y - 16.0);
|
||
assert!(on(r.min.x) && on(r.max.x), "the outline of {m:?}");
|
||
}
|
||
// No image's rectangle: nothing along the images' top edge, 30 points above the masks.
|
||
let top = xf.to_screen(Point::new(0.0, union.y0 - 30.0)).y;
|
||
assert!(!drawn.iter().any(|q| (q.y - top).abs() < 0.5), "the images' top edge at y {top}");
|
||
}
|
||
|
||
/// #2422: a clip group whose hidden art has more anchors than the overlay draws (#525) still
|
||
/// shows its clipping path's anchors and hover highlight.
|
||
#[test]
|
||
fn a_clip_group_over_a_huge_path_shows_its_clipping_path() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let n = OVERLAY_MAX_ANCHORS + 10;
|
||
let points = (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::<String>();
|
||
let huge = app.run("path.create", json!({"d": format!("M20 20 {points} Z")})).unwrap()["id"].clone();
|
||
let square = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [huge, square]})).unwrap();
|
||
let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone();
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap())));
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
app.select_tool("selection");
|
||
app.run("select.set", json!({"ids": [group]})).unwrap();
|
||
let anchors = shapes(&mut app, &ctx).iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == color)).count();
|
||
app.run("select.none", json!({})).unwrap();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 150.0)))]);
|
||
let hovered = !drawn_in(&mut app, &ctx, color, 1.5).is_empty();
|
||
assert_eq!((anchors, hovered), (4, true), "the square's four anchors, and the hover highlight");
|
||
}
|
||
|
||
/// #2422: the art inside a clip group still shows its whole outline when it is picked on its
|
||
/// own: by Edit Contents, by a Direct Selection or Group Selection click, and in isolation mode;
|
||
/// so does its Group Selection hover highlight.
|
||
#[test]
|
||
fn the_art_a_clip_group_clips_shows_whole_when_picked() {
|
||
let (mut app, group, ellipse, _) = clip_group_app();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap())));
|
||
// Something is drawn at the ellipse's left and right ends, outside the square.
|
||
let reaches = |at: &[Pos2], what: &str| {
|
||
let (left, right) = (xf.to_screen(Point::new(0.0, 150.0)).x, xf.to_screen(Point::new(300.0, 150.0)).x);
|
||
assert!(at.iter().any(|q| (q.x - left).abs() < 2.0) && at.iter().any(|q| (q.x - right).abs() < 2.0), "{what}: {at:?}");
|
||
};
|
||
app.select_tool("selection");
|
||
app.run("select.set", json!({"ids": [group]})).unwrap();
|
||
app.run("object.clippingMask.editContents", json!({})).unwrap();
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.0), "Edit Contents");
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("directSelection");
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 150.0, 120.0), view).unwrap();
|
||
}
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(ellipse.as_u64().unwrap())]);
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.0), "a Direct Selection click");
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("groupSelection");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 120.0)))]);
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.5), "the Group Selection hover highlight");
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 150.0, 120.0), view).unwrap();
|
||
}
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(ellipse.as_u64().unwrap())]);
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.0), "a Group Selection click");
|
||
app.select_tool("selection");
|
||
app.run("object.isolate", json!({"id": group})).unwrap();
|
||
app.run("select.all", json!({})).unwrap();
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.0), "isolation mode");
|
||
}
|
||
|
||
/// A Shaper Group's mode widget, drawn for the Shaper and Selection tools with selection edges
|
||
/// shown or hidden: its arrow points right in Face Selection mode and left in Construction mode.
|
||
#[test]
|
||
fn the_shaper_widget_points_to_the_other_mode() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
for x in [0, 50] {
|
||
app.session.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap();
|
||
}
|
||
let points = json!({"points": [[25, 20], [75, 35], [30, 50], [125, 65]]});
|
||
let g = app.session.execute("shaper.scribble", &points).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
// For each widget drawn, whether its arrow's tip is right of the circle's center.
|
||
let right = |app: &mut VectorcraftApp| -> Vec<bool> {
|
||
let drawn = shapes(app, &ctx);
|
||
let r = vectorcraft_tools::shaperwidget::RADIUS_PX;
|
||
let centers: Vec<egui::Pos2> = drawn
|
||
.iter()
|
||
.filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill == Color32::WHITE).then_some(c.center) } else { None })
|
||
.collect();
|
||
drawn
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Path(p) if p.points.len() == 3 => centers.iter().find(|c| c.distance(p.points[0]) <= r).map(|c| p.points[0].x > c.x),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
app.select_tool("shaper");
|
||
assert_eq!(right(&mut app), vec![true], "the scribble selects the group");
|
||
app.session.execute("object.isolate", &json!({"id": g})).unwrap();
|
||
assert_eq!(right(&mut app), vec![false]);
|
||
app.select_tool("selection");
|
||
assert_eq!(right(&mut app), vec![false]);
|
||
// View › Hide Edges keeps the widget, which still answers clicks.
|
||
app.ui.view.edges = false;
|
||
assert_eq!(right(&mut app), vec![false]);
|
||
app.select_tool("pen");
|
||
assert!(right(&mut app).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_symbol_instance_is_outlined_around_its_art() {
|
||
// A symbol holding the square of the SVG import tests' `Star` symbol, 20 × 20 from (0, 0),
|
||
// and an instance of it at (20, 30).
|
||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Star"><path d="M0 0H20V20H0Z" fill="#e0a000"/></symbol></defs><use href="#Star" x="20" y="30"/></svg>"##;
|
||
let mut s = Session::new();
|
||
s.execute("document.open", &json!({"name": "star.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||
let st = s.active().unwrap();
|
||
let mut inst = None;
|
||
st.doc.walk(|n| {
|
||
if matches!(n.kind, NodeKind::SymbolInstance { .. }) {
|
||
inst = Some(n);
|
||
}
|
||
});
|
||
assert_eq!(node_outline(inst.unwrap(), &st.doc.symbol_boxes()).bounding_box(), Rect::new(20.0, 30.0, 40.0, 50.0));
|
||
}
|
||
|
||
/// One headless canvas frame on an 800 × 600 window.
|
||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
/// The Image Trace panel's eye-button hold ends when the pointer is released, even when the
|
||
/// panel stopped drawing while the button was held, so a later press on the canvas does not
|
||
/// show the source image.
|
||
#[test]
|
||
fn a_release_ends_the_image_trace_hold_without_the_panel() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||
let made = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||
let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap());
|
||
let ctx = egui::Context::default();
|
||
let at = pos2(400.0, 300.0);
|
||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), press(true)]);
|
||
app.ui.trace_hold_source = Some(std::sync::Arc::new(vec![id]));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert!(app.ui.trace_hold_source.is_some(), "held while the button is down");
|
||
frame(&mut app, &ctx, vec![press(false)]);
|
||
assert_eq!(app.ui.trace_hold_source, None, "the release ends the hold");
|
||
frame(&mut app, &ctx, vec![press(true)]);
|
||
assert_eq!(app.ui.trace_hold_source, None, "a later press does not show the source image");
|
||
frame(&mut app, &ctx, vec![press(false)]);
|
||
}
|
||
|
||
/// The baseline marker of selected point type runs under the line, wherever its alignment
|
||
/// puts the anchor: under centred and right-aligned type too, not from the anchor rightwards
|
||
/// (#2879).
|
||
#[test]
|
||
fn point_type_baselines_run_under_the_line_whatever_its_alignment() {
|
||
for justify in ["left", "center", "right"] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let made = app.run("text.create", json!({"x": 300, "y": 150, "text": "Baseline marker", "size": 24, "justify": justify})).unwrap();
|
||
let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap());
|
||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let st = app.session.active().unwrap();
|
||
let b = st.doc.node(id).unwrap().geometric_bounds().unwrap();
|
||
let color = c32(st.doc.layer_color(id));
|
||
let y = xf.to_screen(Point::new(0.0, 150.0)).y;
|
||
let (left, right) = (xf.to_screen(Point::new(b.x0, 150.0)).x, xf.to_screen(Point::new(b.x1, 150.0)).x);
|
||
let on: Vec<f32> = shapes(&mut app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::LineSegment { points, stroke } if stroke.color == color => Some(points),
|
||
_ => None,
|
||
})
|
||
.flatten()
|
||
.filter(|q| (q.y - y).abs() < 0.5)
|
||
.map(|q| q.x)
|
||
.collect();
|
||
let (lo, hi) = on.iter().fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
|
||
assert!((lo - left).abs() < 3.0 && (hi - right).abs() < 3.0, "{justify}: the baseline runs {lo}..{hi}, the type {left}..{right}");
|
||
}
|
||
}
|
||
|
||
/// A click with the Selection tool inside an artboard makes it the active one, and Paste in
|
||
/// Place then pastes onto it, where the objects were on their own artboard (#693).
|
||
#[test]
|
||
fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||
app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + 10.0, "y": boards[0].y0 + 15.0, "width": 20, "height": 20})).unwrap();
|
||
app.run("edit.copy", json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
let at = boards[2].center();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view);
|
||
}
|
||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||
let ids: Vec<vectorcraft_doc::NodeId> = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap()))
|
||
.collect();
|
||
let b = app.session.active().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0));
|
||
}
|
||
|
||
/// A Shift-click with the Selection tool on an empty spot inside an artboard makes it the active
|
||
/// one and keeps the selection, and Align to Artboard then aligns the objects to it unless the
|
||
/// call names another artboard.
|
||
#[test]
|
||
fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||
let mut rect = |x: f64, y: f64| {
|
||
let r = app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + x, "y": boards[0].y0 + y, "width": 20, "height": 20}));
|
||
vectorcraft_doc::NodeId(r.unwrap()["id"].as_u64().unwrap())
|
||
};
|
||
let ids = [rect(10.0, 15.0), rect(50.0, 40.0)];
|
||
app.session.execute("select.set", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view);
|
||
}
|
||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||
assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays");
|
||
let lefts = |app: &VectorcraftApp| ids.map(|id| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0);
|
||
app.run("object.align", json!({"horizontal": "left", "to": "artboard"})).unwrap();
|
||
assert_eq!(lefts(&app), [boards[2].x0; 2], "the left edges are on the active artboard's");
|
||
app.run("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap();
|
||
assert_eq!(lefts(&app), [boards[1].x0; 2], "an artboard the caller names is kept");
|
||
}
|
||
|
||
/// The canvas outlines the active artboard in black, 1 px wide, and outlines the other
|
||
/// artboards in gray, 0.6 px wide. In the test, the active artboard is Artboard 1 at first,
|
||
/// then the one Go to Artboard went to, and then, with several artboards selected by the
|
||
/// Artboard tool, the tool's active one (the one Shift-clicked last).
|
||
#[test]
|
||
fn the_active_artboard_is_outlined_in_black() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// → each artboard's edge as drawn: (width, color).
|
||
let edges = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let on = |p: &[Pos2], q: &[Pos2]| p.len() == q.len() && p.iter().zip(q).all(|(a, b)| (*a - *b).length() < 0.01);
|
||
let boards: Vec<Rect> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||
boards
|
||
.iter()
|
||
.map(|r| {
|
||
let q = xf.quad(*r);
|
||
s.iter()
|
||
.find_map(|s| match s {
|
||
Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT && on(&p.points, &q) => {
|
||
Some((p.stroke.width, p.stroke.color.clone()))
|
||
}
|
||
_ => None,
|
||
})
|
||
.expect("the artboard's edge")
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let (black, gray) = (egui::epaint::ColorMode::Solid(Color32::from_gray(0)), egui::epaint::ColorMode::Solid(Color32::from_gray(120)));
|
||
let active = |i: usize| -> Vec<(f32, egui::epaint::ColorMode)> {
|
||
(0..3).map(|j| if j == i { (1.0, black.clone()) } else { (0.6, gray.clone()) }).collect()
|
||
};
|
||
assert_eq!(edges(&mut app), active(0));
|
||
app.run("view.goToArtboard", json!({"index": 1})).unwrap();
|
||
assert_eq!(edges(&mut app), active(1), "Artboard 2 is active: its edge is black and Artboard 1's edge is gray");
|
||
// The Artboard tool starts on Artboard 2; a Shift-click on Artboard 3 selects it too and
|
||
// makes it the active one.
|
||
app.select_tool("artboard");
|
||
let at = app.session.active().unwrap().doc.artboards[2].rect.center();
|
||
let view = app.view_info();
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, pressure: 1.0 }, view);
|
||
}
|
||
assert_eq!(app.session.tool_options()["selected"], json!([2, 1]));
|
||
assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected");
|
||
}
|
||
|
||
// Regression test for #824: with the Artboard tool, the active artboard's name was drawn twice,
|
||
// once by the canvas as for every artboard and once, a little higher and in blue, by the tool.
|
||
// Now each artboard is named once, and the active one's name sits where the others' do.
|
||
#[test]
|
||
fn the_artboard_tool_names_its_active_artboard_once() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||
app.select_tool("artboard");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let drawn = shapes(&mut app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
// Each name drawn: how far its left edge is right of the artboard's corner, and its bottom above it.
|
||
let named = |name: &str, board: usize| -> Vec<(f32, f32)> {
|
||
let r = app.session.active().unwrap().doc.artboards[board].rect;
|
||
let corner = xf.to_screen(Point::new(r.x0, r.y0));
|
||
drawn
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Text(t) if t.galley.text() == name => Some((t.pos.x - corner.x, corner.y - (t.pos.y + t.galley.size().y))),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
let (active, other) = (named("01 - Artboard 1", 0), named("02 - Artboard 2", 1));
|
||
assert_eq!(other.len(), 1, "the other artboard is named once: {other:?}");
|
||
assert_eq!(active.len(), 1, "the active artboard is named once: {active:?}");
|
||
assert!((active[0].0 - other[0].0).abs() < 0.6, "flush with the artboard's left edge like the other name: {active:?} vs {other:?}");
|
||
assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn shift_clicking_canvas_artboard_label_toggles_multi_selection() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
app.select_tool("artboard");
|
||
if let Some(v) = app.view_mut() {
|
||
v.center = Point::new(320.0, 50.0);
|
||
v.zoom = 0.8;
|
||
v.fitted = true;
|
||
}
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_options()["selected"], json!([0]));
|
||
|
||
let drawn = shapes(&mut app, &ctx);
|
||
let label_pos = |name: &str| {
|
||
drawn
|
||
.iter()
|
||
.find_map(|s| match s {
|
||
Shape::Text(t) if t.galley.text() == name => Some(t.pos + vec2(10.0, 5.0)),
|
||
_ => None,
|
||
})
|
||
.unwrap()
|
||
};
|
||
let at1 = label_pos("02 - Artboard 2");
|
||
|
||
// Shift-click artboard 2's name label (Shift held on press, released before mouse release)
|
||
let shift = egui::Modifiers::SHIFT;
|
||
let button = |pressed| egui::Event::PointerButton { pos: at1, button: egui::PointerButton::Primary, pressed, modifiers: shift };
|
||
frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(shift), egui::Event::PointerMoved(at1), button(true)]);
|
||
frame(
|
||
&mut app,
|
||
&ctx,
|
||
vec![
|
||
egui::Event::ModifiersChanged(egui::Modifiers::NONE),
|
||
egui::Event::PointerButton { pos: at1, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },
|
||
],
|
||
);
|
||
|
||
assert_eq!(app.session.tool_options()["selected"], json!([1, 0]), "Shift-clicking artboard label adds it to selection");
|
||
|
||
// Click artboard 3's name label without Shift
|
||
let at2 = label_pos("03 - Artboard 3");
|
||
let plain_btn =
|
||
|pressed| egui::Event::PointerButton { pos: at2, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||
frame(&mut app, &ctx, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), egui::Event::PointerMoved(at2), plain_btn(true)]);
|
||
frame(&mut app, &ctx, vec![plain_btn(false)]);
|
||
|
||
assert_eq!(app.session.tool_options()["selected"], json!([2]), "Plain click selects that artboard alone");
|
||
}
|
||
|
||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||
#[test]
|
||
fn two_finger_tap_undoes_and_three_finger_tap_redoes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
// `fingers` fingers tap at the centre, the first also pressing as the pointer and moving a
|
||
// little (as a rectangle drag would begin).
|
||
let tap = |app: &mut VectorcraftApp, fingers: u64| {
|
||
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
|
||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]);
|
||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]);
|
||
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect());
|
||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]);
|
||
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect());
|
||
frame(app, &ctx, vec![]);
|
||
};
|
||
tap(&mut app, 2);
|
||
assert_eq!(count(&app), 0, "the rectangle undone, none drawn");
|
||
tap(&mut app, 3);
|
||
assert_eq!(count(&app), 1, "redone");
|
||
app.session.prefs.touch_gestures = false;
|
||
tap(&mut app, 2);
|
||
assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone");
|
||
}
|
||
|
||
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
|
||
/// labels are interface text.
|
||
#[test]
|
||
fn artboard_labels_name_the_artboard() {
|
||
assert!(names_an_artboard("01 - Layers") && names_an_artboard("12 - Artboard 12 - copy"));
|
||
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
|
||
}
|
||
|
||
/// Double-clicking the name drawn above an artboard edits that name and does not fit a new
|
||
/// artboard or open Artboard Options.
|
||
#[test]
|
||
fn double_clicking_an_artboard_name_edits_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 80, "name": "Far"})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap();
|
||
app.select_tool("artboard");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let mut target = None;
|
||
let _ = ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| {
|
||
let ab = &app.session.active().unwrap().doc.artboards[0];
|
||
target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center());
|
||
});
|
||
let at = target.unwrap();
|
||
assert!(rect.contains(at), "the name is in the working area, at {at:?} canvas {rect:?}");
|
||
let before = app.session.active().unwrap().doc.artboards.len();
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let click = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
click(&mut app);
|
||
click(&mut app);
|
||
assert!(ctx.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).is_some()), "the name is being edited");
|
||
assert_eq!(app.session.active().unwrap().doc.artboards.len(), before, "clicking the name does not fit an artboard");
|
||
assert!(app.ui.dialog.is_none(), "Artboard Options stays closed");
|
||
commit_artboard_name(&mut app, 0, "Poster");
|
||
assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster");
|
||
commit_artboard_name(&mut app, 0, " ");
|
||
assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster", "an empty name is left as it is");
|
||
}
|
||
|
||
/// Double-clicking the name drawn above a locked artboard doesn't edit it (the Artboards panel
|
||
/// still renames the artboard), fit an artboard or open Artboard Options.
|
||
#[test]
|
||
fn double_clicking_a_locked_artboards_name_leaves_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap();
|
||
app.session.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||
app.select_tool("artboard");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let mut target = None;
|
||
let _ = ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| {
|
||
let ab = &app.session.active().unwrap().doc.artboards[0];
|
||
target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center());
|
||
});
|
||
let at = target.unwrap();
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
for _ in 0..2 {
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(true)]);
|
||
frame(&mut app, &ctx, vec![button(false)]);
|
||
}
|
||
assert!(ctx.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).is_none()), "the name isn't edited");
|
||
assert_eq!(app.session.active().unwrap().doc.artboards.len(), 1);
|
||
assert!(app.ui.dialog.is_none(), "Artboard Options stays closed");
|
||
}
|
||
|
||
/// The canvas artboard name field keeps its own inset: egui 0.37 insets a text field by the theme's button
|
||
/// padding unless told otherwise, but this field sits over the canvas label, so its glyphs must not move when
|
||
/// that padding changes (an ordinary themed field's do, as the probe shows).
|
||
#[test]
|
||
fn artboard_name_field_glyphs_stay_put_when_button_padding_changes() {
|
||
/// Where the frame paints `text` (a field's text starts at its galley's top left).
|
||
fn text_at(shapes: &[Shape], text: &str) -> Pos2 {
|
||
shapes
|
||
.iter()
|
||
.find_map(|s| match s {
|
||
Shape::Text(t) if t.galley.text() == text => Some(t.pos),
|
||
_ => None,
|
||
})
|
||
.unwrap_or_else(|| panic!("no {text:?} text"))
|
||
}
|
||
/// Build raw input with a fixed 800 by 600 screen so headless frames keep a stable canvas texture
|
||
/// size.
|
||
fn raw() -> egui::RawInput {
|
||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }
|
||
}
|
||
/// The name field's text origin relative to its rectangle with the theme's button padding `padding`: the
|
||
/// field opened as a double click opens it, measured in the steady frame after it takes focus.
|
||
fn editor_inset(padding: Vec2) -> Vec2 {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("artboard.setProps", &json!({"index": 0, "name": "Inset"})).unwrap();
|
||
app.select_tool("artboard");
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
crate::theme::apply(&ctx, crate::theme::Brightness::default());
|
||
ctx.global_style_mut(|s| s.spacing.button_padding = padding);
|
||
ctx.run_ui(raw(), |ui| begin_artboard_name_edit(ui, &app, 0)).textures_delta.clear();
|
||
// The first frame opens and focuses the field; the next one is its steady state.
|
||
frame(&mut app, &ctx, vec![]);
|
||
let (shapes, mut delta) = frame_output(&mut app, &ctx);
|
||
delta.clear();
|
||
let origin = text_at(&shapes, "Inset");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let mut field = None;
|
||
ctx.run_ui(raw(), |ui| field = artboard_name_field_rect(&app, ui, &xf, 0).map(|r| r.min)).textures_delta.clear();
|
||
origin - field.unwrap()
|
||
}
|
||
/// An ordinary themed field's text origin relative to its rectangle: the padding moves it.
|
||
fn plain_inset(padding: Vec2) -> Vec2 {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
crate::theme::apply(&ctx, crate::theme::Brightness::default());
|
||
ctx.global_style_mut(|s| s.spacing.button_padding = padding);
|
||
let mut text = String::from("Probe");
|
||
let rect = egui::Rect::from_min_size(pos2(40.0, 40.0), vec2(140.0, 18.0));
|
||
let mut out = ctx.run_ui(raw(), |ui| {
|
||
ui.put(rect, egui::TextEdit::singleline(&mut text).id(egui::Id::unique("inset-probe")).font(egui::FontId::proportional(11.0)));
|
||
});
|
||
out.textures_delta.clear();
|
||
let shapes: Vec<Shape> = out.shapes.into_iter().map(|c| c.shape).collect();
|
||
text_at(&shapes, "Probe") - rect.min
|
||
}
|
||
let (old, themed) = (vec2(4.0, 2.0), vec2(6.0, 2.0));
|
||
let (editor_old, editor_themed) = (editor_inset(old), editor_inset(themed));
|
||
let (plain_old, plain_themed) = (plain_inset(old), plain_inset(themed));
|
||
assert!((plain_themed.x - plain_old.x).abs() > 1.0, "the probe field follows the padding: {plain_old:?} → {plain_themed:?}");
|
||
assert!((editor_themed - editor_old).length() < 0.01, "the artboard name moves with the button padding: {editor_old:?} → {editor_themed:?}");
|
||
}
|
||
|
||
/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when
|
||
/// longer, and an artboard too narrow shows none.
|
||
#[test]
|
||
fn artboard_labels_fit_their_artboard() {
|
||
let ctx = egui::Context::default();
|
||
let drawn = |width: f32| {
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
artboard_label(ui.painter(), pos2(50.0, 50.0), width, "01 - A long artboard name", Color32::WHITE)
|
||
});
|
||
out.textures_delta.clear();
|
||
out.shapes
|
||
.into_iter()
|
||
.filter_map(|c| match c.shape {
|
||
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.galley.size().x)),
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let full = drawn(400.0);
|
||
assert_eq!(full.first().map(|(s, _)| s.as_str()), Some("01 - A long artboard name"));
|
||
// Laid out no wider than the artboard (the galley keeps the whole text; it draws "…").
|
||
let (_, w) = drawn(30.0).first().cloned().unwrap();
|
||
assert!(full[0].1 > 30.0 && w <= 30.0 + 0.5, "{full:?} {w}");
|
||
assert!(drawn(10.0).is_empty());
|
||
}
|
||
|
||
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
|
||
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
|
||
}
|
||
|
||
/// One headless frame with the canvas under a 40-point bar, as it is under the document tabs.
|
||
fn frame_under_bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
ui.add_space(40.0);
|
||
show(app, ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
#[test]
|
||
fn a_click_just_outside_the_canvas_does_not_reach_the_tool() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// A square in the middle of the artboard, where the view is centred.
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame_under_bar(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let click = |app: &mut VectorcraftApp, p: Pos2| {
|
||
let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame_under_bar(app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||
frame_under_bar(app, &ctx, vec![button(true)]);
|
||
frame_under_bar(app, &ctx, vec![button(false)]);
|
||
};
|
||
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone();
|
||
// Two pixels above the canvas, within egui's interaction radius: nothing happens.
|
||
click(&mut app, pos2(rect.center().x, rect.top() - 2.0));
|
||
assert!(selected(&app).is_empty(), "a click above the canvas selected {:?}", selected(&app));
|
||
// At the canvas's centre: the square.
|
||
click(&mut app, rect.center());
|
||
assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.guides = false;
|
||
// A drawing tool: the drag must not draw.
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(app, &ctx, vec![button(from, true)]);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false)]);
|
||
};
|
||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||
// From the top ruler: a horizontal guide where the button was released.
|
||
let to = pos2(rect.center().x, rect.top() + 120.0);
|
||
drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to);
|
||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y)]);
|
||
assert!(app.ui.view.guides, "the new guide shows");
|
||
// From the left ruler: a vertical one.
|
||
let to2 = pos2(rect.left() + 200.0, rect.center().y);
|
||
drag(&mut app, pos2(rect.left() - RULER / 2.0, rect.center().y), to2);
|
||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(to2).x)]);
|
||
// Released back on the ruler: no guide.
|
||
drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), pos2(rect.center().x + 40.0, rect.top() - 4.0));
|
||
assert_eq!(guides(&app).len(), 2);
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing");
|
||
}
|
||
|
||
/// #4646: a guide dragged out of a ruler shows as a line across the canvas while it is dragged,
|
||
/// also with Preferences › Performance › Real-time Drawing and Editing off (only its position
|
||
/// label showed then).
|
||
#[test]
|
||
fn a_guide_dragged_out_of_a_ruler_shows_while_dragged() {
|
||
for real_time in [true, false] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let zoom = app.view().unwrap().zoom as f32;
|
||
let from = pos2(rect.center().x, rect.top() - RULER / 2.0);
|
||
let at = pos2(rect.center().x, rect.top() + 120.0);
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
// A horizontal line across the canvas at the pointer (snapped to a whole point at most).
|
||
let across = |s: &Shape| {
|
||
matches!(s, Shape::LineSegment { points, .. }
|
||
if (points[0].y - at.y).abs() <= zoom && points[0].y == points[1].y && (points[1].x - points[0].x).abs() >= rect.width() - 0.5)
|
||
};
|
||
assert!(shapes(&mut app, &ctx).iter().any(across), "real-time drawing {real_time}: the guide shows while dragged");
|
||
frame(&mut app, &ctx, vec![button(at, false)]);
|
||
assert_eq!(app.session.active().unwrap().doc.guides.len(), 1, "real-time drawing {real_time}: released, it is made");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn new_ruler_guides_follow_alt_through_preview_release_and_undo() {
|
||
use egui::{Event, Modifiers};
|
||
for source_vertical in [false, true] {
|
||
for initially_alt in [false, true] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("document.setRulerOrigin", &json!({"x": 17, "y": 23})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let from =
|
||
if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) };
|
||
let at = xf.to_screen(Point::new(80.0, 120.0));
|
||
assert!(rect.contains(at));
|
||
let held = |alt| if alt { Modifiers::ALT } else { Modifiers::NONE };
|
||
let button = |pos, pressed, alt| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: held(alt) };
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(initially_alt)), Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true, initially_alt)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
let assert_guide = |app: &VectorcraftApp, alt: bool, pos: Point| {
|
||
let guides = &app.session.active().unwrap().doc.guides;
|
||
assert_eq!(guides.len(), 1);
|
||
assert_eq!(guides[0].vertical, source_vertical != alt);
|
||
let expected = if source_vertical != alt { pos.x } else { pos.y };
|
||
assert!((guides[0].pos - expected).abs() < 1e-4, "{:?}, expected {expected}", guides[0]);
|
||
assert_eq!(guides[0].artboard, None);
|
||
};
|
||
assert_guide(&app, initially_alt, Point::new(80.0, 120.0));
|
||
// Modifier changes with no pointer movement refresh the existing preview.
|
||
for alt in [!initially_alt, initially_alt] {
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(alt))]);
|
||
assert_guide(&app, alt, Point::new(80.0, 120.0));
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||
}
|
||
// Equal coordinates must not hide an orientation change in the preview.
|
||
let equal = xf.to_screen(Point::new(120.0, 120.0));
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(equal)]);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(!initially_alt))]);
|
||
assert_guide(&app, !initially_alt, Point::new(120.0, 120.0));
|
||
let release = Point::new(147.0, 183.0);
|
||
let to = xf.to_screen(release);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(to), button(to, false, true)]);
|
||
assert_guide(&app, true, release);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.history.undo.len(), undo + 1);
|
||
assert_eq!(st.history.undo.last().unwrap().label, "New Guide");
|
||
assert_eq!(st.doc.ruler_origin, before.ruler_origin);
|
||
assert_eq!(st.doc.art_bounds(), before.art_bounds(), "the rectangle tool never drew");
|
||
let committed = st.doc.clone();
|
||
// Canvas guides still span the viewport after changing orientation.
|
||
let painted = shapes(&mut app, &ctx);
|
||
let window = app.canvas_rect.unwrap();
|
||
let position = Xf::new(window, app.view().unwrap()).to_screen(release);
|
||
let ends = if source_vertical {
|
||
[pos2(window.left(), position.y), pos2(window.right(), position.y)]
|
||
} else {
|
||
[pos2(position.x, window.top()), pos2(position.x, window.bottom())]
|
||
};
|
||
assert!(painted.iter().any(|s| matches!(s, Shape::LineSegment { points, .. }
|
||
if (points[0] - ends[0]).length() < 0.01 && (points[1] - ends[1]).length() < 0.01)));
|
||
app.session.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(&*app.session.active().unwrap().doc, &*before);
|
||
app.session.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(&*app.session.active().unwrap().doc, &*committed);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn new_ruler_guide_alt_cancel_keeps_document_and_redo() {
|
||
use egui::{Event, Modifiers};
|
||
for source_vertical in [false, true] {
|
||
for tool in ["rectangle", "artboard"] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("document.setRulerOrigin", &json!({"x": 17, "y": 23})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap();
|
||
app.session.execute("edit.undo", &json!({})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
app.select_tool(tool);
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let from =
|
||
if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) };
|
||
let at = xf.to_screen(Point::new(80.0, 120.0));
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT };
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let history = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||
};
|
||
let before_history = history(&app);
|
||
assert_eq!(before_history.1, 1);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
let guides = &app.session.active().unwrap().doc.guides;
|
||
assert_eq!(guides.len(), 1);
|
||
assert_eq!(guides[0].vertical, !source_vertical);
|
||
let artboard = (tool == "artboard").then_some(before.artboards[0].id);
|
||
assert_eq!(guides[0].artboard, artboard);
|
||
// Returning to the source ruler cancels the preview and keeps the existing Redo.
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]);
|
||
assert_eq!(&*app.session.active().unwrap().doc, &*before);
|
||
assert_eq!(history(&app), before_history);
|
||
app.session.execute("edit.redo", &json!({})).unwrap();
|
||
assert!(app.session.active().unwrap().doc.art_bounds().is_some());
|
||
assert!(app.session.active().unwrap().doc.guides.is_empty());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #451: a guide dragged out of a ruler lands on the art's side midpoints (halving an
|
||
/// artboard-sized rectangle) and on the artboard's edges; an artboard guide is drawn across
|
||
/// its artboard only.
|
||
#[test]
|
||
fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(app, &ctx, vec![button(from, true)]);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false)]);
|
||
};
|
||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||
// 3 px off the middle of the top and bottom sides: onto the line through them.
|
||
let mid = xf.to_screen(Point::new(200.0, 150.0));
|
||
drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0));
|
||
// 3 px inside the artboard's bottom edge: onto it.
|
||
let bottom = xf.to_screen(Point::new(100.0, 300.0));
|
||
drag(&mut app, pos2(bottom.x, rect.top() - RULER / 2.0), bottom - vec2(0.0, 3.0));
|
||
assert_eq!(guides(&app), [(true, 200.0), (false, 300.0)]);
|
||
assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "New Guide");
|
||
// An artboard guide runs from the artboard's left edge to its right edge.
|
||
app.session.execute("guide.add", &json!({"vertical": false, "pos": 75, "artboard": 0})).unwrap();
|
||
let s = shapes(&mut app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let (a, b) = (xf.to_screen(Point::new(0.0, 75.0)), xf.to_screen(Point::new(400.0, 75.0)));
|
||
let at = |p: Pos2, q: Pos2| (p - q).length() < 0.01;
|
||
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, .. } if at(points[0], a) && at(points[1], b))), "across the artboard");
|
||
// A canvas guide crosses the whole window.
|
||
let y = xf.to_screen(Point::new(0.0, 300.0)).y;
|
||
let window = app.canvas_rect.unwrap();
|
||
assert!(
|
||
s.iter()
|
||
.any(|s| matches!(s, Shape::LineSegment { points, .. } if points[0] == pos2(window.left(), y) && points[1] == pos2(window.right(), y)))
|
||
);
|
||
}
|
||
|
||
/// #414: guides dragged out of the rulers are picked and dragged with the Selection tool
|
||
/// (highlighted while selected), go with Delete or Backspace, and dragged back onto their
|
||
/// ruler.
|
||
#[test]
|
||
fn the_selection_tool_moves_and_deletes_ruler_guides() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// A square under the guides: the press takes the guide.
|
||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 300, "height": 200})).unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let art = app.session.active().unwrap().doc.art_bounds();
|
||
app.ui.view.rulers = true;
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(app, &ctx, vec![button(from, true)]);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false)]);
|
||
};
|
||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||
// Two guides out of the rulers.
|
||
let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y));
|
||
drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h);
|
||
drag(&mut app, pos2(rect.left() - RULER / 2.0, v.y), v);
|
||
assert_eq!(guides(&app), [(false, xf.to_doc(h).y), (true, xf.to_doc(v).x)]);
|
||
// Drag the horizontal one down 30 px (across the vertical one: the nearer is picked).
|
||
let to = pos2(h.x + 10.0, h.y + 30.0);
|
||
drag(&mut app, h, to);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]);
|
||
assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![]));
|
||
assert_eq!(st.history.undo.last().unwrap().label, "Move Guide");
|
||
let picked = Tokens::get(&ctx).selection;
|
||
let s = shapes(&mut app, &ctx);
|
||
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == picked && points[0].y == to.y)), "highlighted");
|
||
// Backspace deletes the selected guide; Delete too.
|
||
let press = |app: &mut VectorcraftApp, key| {
|
||
let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||
let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
};
|
||
press(&mut app, egui::Key::Backspace);
|
||
assert_eq!(guides(&app), [(true, xf.to_doc(v).x)]);
|
||
app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||
press(&mut app, egui::Key::Delete);
|
||
assert!(guides(&app).is_empty());
|
||
app.session.execute("edit.undo", &json!({})).unwrap();
|
||
// Hide Guides deselects them.
|
||
app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||
app.run("view.guides", json!({})).unwrap();
|
||
assert!(app.session.active().unwrap().selection.guides.is_empty());
|
||
app.run("view.guides", json!({})).unwrap();
|
||
// Dragged back onto its ruler, the vertical guide goes.
|
||
drag(&mut app, v, pos2(rect.left() - RULER / 2.0, v.y + 20.0));
|
||
assert!(guides(&app).is_empty(), "{:?}", guides(&app));
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), art, "the square stayed");
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let cmd_frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let events = std::iter::once(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)).chain(events).collect();
|
||
let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
let button =
|
||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND };
|
||
// A Cmd drag from `a` to `b` with the Pen tool; → the tool that did it.
|
||
let cmd_drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| {
|
||
app.select_tool("pen");
|
||
cmd_frame(app, vec![egui::Event::PointerMoved(a), button(a, true)]);
|
||
let used = app.session.tool_id().to_string();
|
||
cmd_frame(app, vec![egui::Event::PointerMoved(b)]);
|
||
cmd_frame(app, vec![button(b, false)]);
|
||
assert_eq!(app.session.tool_id(), "pen", "back to the Pen tool");
|
||
used
|
||
};
|
||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once
|
||
// the rectangle isn't selected whole, which would move all its anchors).
|
||
app.select_tool("directSelection");
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let corner = xf.to_screen(Point::new(200.0, 150.0));
|
||
assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection");
|
||
let b = bounds(&app);
|
||
assert_eq!((b.x0, b.y0), (100.0, 100.0));
|
||
assert!((b.x1 - xf.to_doc(corner + vec2(20.0, 20.0)).x).abs() < 1e-6, "{b:?}");
|
||
// Group Selection, then Selection: each is the one used.
|
||
app.select_tool("groupSelection");
|
||
let inside = xf.to_screen(Point::new(130.0, 120.0));
|
||
assert_eq!(cmd_drag(&mut app, inside, inside), "groupSelection");
|
||
app.select_tool("selection");
|
||
assert_eq!(cmd_drag(&mut app, inside, inside), "selection");
|
||
}
|
||
|
||
/// #525: a selection with more anchors than the overlay draws (a traced photo) shows its bounds,
|
||
/// not every outline and anchor, which made a mesh larger than the GPU takes; an ordinary one
|
||
/// still shows its outline and anchors.
|
||
#[test]
|
||
fn a_huge_selection_shows_its_bounds_instead_of_every_outline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let points = |n: usize| (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::<String>();
|
||
let ctx = egui::Context::default();
|
||
let mut drawn = |n: usize| {
|
||
app.session.execute("path.create", &json!({"d": format!("M20 20 {} Z", points(n))})).unwrap();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let shapes = shapes(&mut app, &ctx);
|
||
let points: usize = shapes.iter().map(|s| if let Shape::Path(ps) = s { ps.points.len() } else { 0 }).sum();
|
||
let squares = shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count();
|
||
(points, squares)
|
||
};
|
||
let (points, squares) = drawn(OVERLAY_MAX_ANCHORS + 10);
|
||
assert!(points < 100 && squares < 100, "{points} outline points, {squares} squares");
|
||
let (points, squares) = drawn(200);
|
||
assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares");
|
||
}
|
||
|
||
/// #2460: type, images, symbol instances, gradient meshes and envelopes count toward the
|
||
/// points the overlay outlines, as paths' anchors do. 32,768 of one kind (an opened PDF can
|
||
/// hold as many type lines, images or mesh pieces) add only the selection's bounds to the
|
||
/// frame's mesh, under 100 vertices; one alone adds its outline.
|
||
#[test]
|
||
fn a_huge_selection_of_type_images_symbols_or_meshes_shows_its_bounds() {
|
||
for kind in ["point type", "area type", "type on a path", "image", "symbol", "mesh", "envelope"] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
// The Rectangle tool draws nothing of its own (no bounding box, no type on a path
|
||
// brackets): only the selection's overlay changes the frame.
|
||
app.select_tool("rectangle");
|
||
match kind {
|
||
"point type" => {
|
||
app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type"})).unwrap();
|
||
}
|
||
"area type" => {
|
||
let area = json!({"width": 150, "height": 20});
|
||
app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type", "area": area})).unwrap();
|
||
}
|
||
"type on a path" => {
|
||
let path = app.session.execute("path.create", &json!({"d": "M20 100 L140 100 L260 100 L380 100"})).unwrap()["id"].clone();
|
||
app.session.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Type"})).unwrap();
|
||
}
|
||
"image" => {
|
||
let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png());
|
||
app.run("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap();
|
||
}
|
||
"symbol" => {
|
||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
|
||
app.session.execute("symbol.new", &json!({})).unwrap();
|
||
}
|
||
"mesh" => {
|
||
app.session.execute("shape.rectangle", &json!({"x": 150, "y": 50, "width": 30, "height": 20})).unwrap();
|
||
app.session.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
}
|
||
_ => {
|
||
app.session.execute("shape.rectangle", &json!({"x": 150, "y": 50, "width": 30, "height": 20})).unwrap();
|
||
app.session.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
}
|
||
}
|
||
let ctx = egui::Context::default();
|
||
// The vertices that selecting everything adds to the frame's mesh.
|
||
let overlay = |app: &mut VectorcraftApp| {
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let none = frame_vertices(app, &ctx);
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
frame_vertices(app, &ctx).saturating_sub(none)
|
||
};
|
||
let one = overlay(&mut app);
|
||
assert!(one > 0, "{kind}: one shows its outline");
|
||
for _ in 0..15 {
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.run("edit.duplicate", json!({})).unwrap();
|
||
}
|
||
let many = overlay(&mut app);
|
||
assert_eq!(app.session.active().unwrap().selection.objects.len(), 32_768, "{kind}");
|
||
assert!(many < 100, "{kind}: 32,768 show the selection's bounds, not {many} vertices");
|
||
}
|
||
}
|
||
|
||
/// #2460: a group of 32,768 images is too much to outline: under the pointer it is not
|
||
/// highlighted, and as the key object its outline is its bounds.
|
||
#[test]
|
||
fn a_group_of_too_many_images_has_no_hover_outline_and_a_key_outline_of_its_bounds() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png());
|
||
app.run("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap();
|
||
for _ in 0..15 {
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.run("edit.duplicate", json!({})).unwrap();
|
||
}
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
let group = app.session.execute("object.group", &json!({})).unwrap()["id"].clone();
|
||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap();
|
||
app.select_tool("selection");
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let over = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(200.0, 150.0));
|
||
let lines = |app: &mut VectorcraftApp, width: f32| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == width)).count()
|
||
};
|
||
assert_eq!(lines(&mut app, 1.5), 0, "no hover outline");
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.session.execute("select.key", &json!({"id": group})).unwrap();
|
||
assert_eq!(lines(&mut app, KEY_OUTLINE), 1, "the key object's bounds");
|
||
}
|
||
|
||
/// #1111: a selected gradient mesh shows its mesh lines and points to the selection tools, not
|
||
/// only to the Mesh tool.
|
||
#[test]
|
||
fn a_selected_gradient_mesh_shows_its_lines_and_points() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 200, "height": 100})).unwrap();
|
||
app.session.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let drawn = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![]);
|
||
let shapes = shapes(app, &ctx);
|
||
let lines = shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.points.len() > 1)).count();
|
||
(lines, shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count())
|
||
};
|
||
let (none_lines, none_points) = {
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
drawn(&mut app)
|
||
};
|
||
for tool in ["selection", "directSelection"] {
|
||
app.select_tool(tool);
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
let (lines, points) = drawn(&mut app);
|
||
// The outline, two inner lines each way; the 3 × 3 points.
|
||
assert!(lines >= none_lines + 3 && points >= none_points + 9, "{tool}: {lines} lines, {points} points");
|
||
}
|
||
}
|
||
|
||
/// #541: the key object's outline is drawn thicker than the rest of the selection's.
|
||
#[test]
|
||
fn the_key_object_has_a_thicker_outline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let a = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||
let b = app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 60, "height": 40})).unwrap()["id"].clone();
|
||
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let thick =
|
||
|app: &mut VectorcraftApp| shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == KEY_OUTLINE)).count();
|
||
assert_eq!(thick(&mut app), 0, "no key yet");
|
||
app.session.execute("select.key", &json!({"id": b})).unwrap();
|
||
assert!(thick(&mut app) > 0, "the key's outline");
|
||
}
|
||
|
||
/// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened.
|
||
fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
|
||
let (shapes, mut delta) = frame_output(app, ctx);
|
||
delta.clear();
|
||
shapes
|
||
}
|
||
|
||
/// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the
|
||
/// GPU takes in one buffer.
|
||
fn frame_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize {
|
||
frame(app, ctx, vec![]);
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 };
|
||
ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum()
|
||
}
|
||
|
||
/// One headless canvas frame → the shapes drawn (`Shape::Vec`s flattened) and the textures
|
||
/// uploaded by it (the art's raster among them, when it was re-rendered).
|
||
fn frame_output(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Vec<Shape>, egui::TexturesDelta) {
|
||
fn flat(s: Shape, out: &mut Vec<Shape>) {
|
||
match s {
|
||
Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)),
|
||
s => out.push(s),
|
||
}
|
||
}
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
let mut v = vec![];
|
||
out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v));
|
||
(v, out.textures_delta)
|
||
}
|
||
|
||
/// An edit of one object renders the canvas again only where it changed, into its texture; a
|
||
/// change of the view renders all of it.
|
||
#[test]
|
||
fn an_edit_renders_only_its_part_of_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
app.canvas.worker_started = true; // every frame on this thread, however long it takes
|
||
let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// Where the canvas texture changed in a frame, and how much of it.
|
||
let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| {
|
||
let tex = app.canvas.texture.as_ref().map(|t| t.id());
|
||
let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size()));
|
||
delta.clear();
|
||
set
|
||
};
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it");
|
||
app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap();
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
let (pos, size) = canvas(&app, delta).expect("the edit shows");
|
||
let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap();
|
||
assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}");
|
||
// An edit with other render options than the frame shown's renders all of it.
|
||
app.session.prefs.pattern_tile_edge_color = "#ff00ff".into();
|
||
app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap();
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it");
|
||
app.run("view.setZoom", json!({"zoom": 2})).unwrap();
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it");
|
||
}
|
||
|
||
/// A placed document's bitmap made in the background renders the canvas again only where the
|
||
/// objects showing its file are: both copies of the file are in the part rendered again.
|
||
#[test]
|
||
fn a_ready_placed_document_bitmap_renders_only_its_part_of_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
app.canvas.worker_started = true; // every frame on this thread, however long it takes
|
||
// A document of one 60 × 30 artboard with a rectangle, placed linked twice.
|
||
let mut src = Session::new();
|
||
src.execute("file.new", &json!({"width": 60, "height": 30})).unwrap();
|
||
src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 30})).unwrap();
|
||
let dir = std::env::temp_dir().join(format!("vectorcraft-placed-redraw-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("card.vectorcraft");
|
||
std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap();
|
||
let path = path.to_string_lossy().into_owned();
|
||
let ids: Vec<u64> = [[60, 50], [140, 50]]
|
||
.iter()
|
||
.map(|at| {
|
||
let r = app.session.execute("file.place", &json!({"path": path, "at": at})).unwrap();
|
||
assert_eq!(r["linked"], true, "{r}");
|
||
r["ids"][0].as_u64().unwrap()
|
||
})
|
||
.collect();
|
||
app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// Where the canvas texture changed in a frame, and how much of it.
|
||
let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| {
|
||
let tex = app.canvas.texture.as_ref().map(|t| t.id());
|
||
let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size()));
|
||
delta.clear();
|
||
set
|
||
};
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it");
|
||
let t = std::time::Instant::now();
|
||
while vectorcraft_render::placed_document::busy() && t.elapsed().as_secs() < 20 {
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
let (pos, size) = canvas(&app, delta).expect("the bitmap shows");
|
||
let [px, py] = pos.expect("a part of the canvas");
|
||
let key = app.canvas.key.clone().unwrap();
|
||
let view = Affine::translate((f64::from(key.w) / 2.0, f64::from(key.h) / 2.0))
|
||
* Affine::rotate(key.rot.to_radians())
|
||
* Affine::scale(key.zoom * f64::from(key.ppp))
|
||
* Affine::translate((-key.cx, -key.cy));
|
||
let doc = &app.session.active().unwrap().doc;
|
||
for id in ids {
|
||
let b = doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
let [x0, y0, x1, y1] = vectorcraft_render::view_pixels(b, view, key.w, key.h).unwrap();
|
||
let inside = x0 as usize >= px && y0 as usize >= py && x1 as usize <= px + size[0] && y1 as usize <= py + size[1];
|
||
assert!(inside, "object {id} at {:?} in the part at {:?} of size {size:?}", [x0, y0, x1, y1], [px, py]);
|
||
}
|
||
assert!(size[0] * size[1] * 2 <= key.w as usize * key.h as usize, "a part of it: {size:?}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the
|
||
/// mask's coverage wherever its art reaches, past its object's bounds.
|
||
#[test]
|
||
fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
app.canvas.worker_started = true; // every frame on this thread, however long it takes
|
||
let rect =
|
||
|app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone();
|
||
let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0));
|
||
app.run("select.set", json!({"ids": [obj, mask]})).unwrap();
|
||
app.run("transparency.makeOpacityMask", json!({})).unwrap();
|
||
app.run("transparency.viewOpacityMask", json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let (_, mut delta) = frame_output(&mut app, &ctx);
|
||
delta.clear();
|
||
app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap();
|
||
let (_, mut delta) = frame_output(&mut app, &ctx);
|
||
let tex = app.canvas.texture.as_ref().map(|t| t.id());
|
||
let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos);
|
||
delta.clear();
|
||
assert_eq!(pos, Some(None), "all of it");
|
||
}
|
||
|
||
/// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles.
|
||
#[test]
|
||
fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":200,"height":150})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("directSelection");
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 150.0, 50.0), view).unwrap();
|
||
}
|
||
let before = app.session.doc().unwrap().doc.clone();
|
||
let color = c32(before.layer_color(vectorcraft_doc::NodeId(id)));
|
||
let selected = selected_anchor(color);
|
||
let ctx = egui::Context::default();
|
||
let painted = shapes(&mut app, &ctx);
|
||
assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 0);
|
||
assert_eq!(
|
||
painted.iter().filter(|s| matches!(s, Shape::Path(p) if p.stroke.width == 2.0 && p.points.len() == 2)).count(),
|
||
1,
|
||
"only the chosen straight edge has emphasis"
|
||
);
|
||
assert!(!painted.iter().any(|s| matches!(s, Shape::Circle(_))), "segment picks have no corner widgets");
|
||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||
app.run("select.anchors", json!({"id":id,"anchors":[[0,0],[0,1]]})).unwrap();
|
||
let painted = shapes(&mut app, &ctx);
|
||
assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 2);
|
||
app.run(
|
||
"path.create",
|
||
json!({"anchors":[{"x":50,"y":260,"out":[100,220]},{"x":200,"y":260,"in":[150,220],"out":[250,300]},{"x":330,"y":260,"in":[280,300]}]}),
|
||
)
|
||
.unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 125.0, 230.0), view).unwrap();
|
||
}
|
||
let before = app.session.doc().unwrap().doc.clone();
|
||
let painted = shapes(&mut app, &ctx);
|
||
assert_eq!(
|
||
painted.iter().filter(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color)).count(),
|
||
2,
|
||
"only the chosen curve's two controls show"
|
||
);
|
||
app.ui.view.edges = false;
|
||
let painted = shapes(&mut app, &ctx);
|
||
assert!(!painted.iter().any(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color)));
|
||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||
}
|
||
|
||
/// A group, a compound path or a clip group selected whole has every anchor selected, and Direct
|
||
/// Selection, the Pen and the anchor point tools draw its anchors filled, as they draw the anchors
|
||
/// of a path selected whole. They do the same after Group Selection clicks select a path and then its group.
|
||
/// Direction handles still show only on a path selected itself.
|
||
#[test]
|
||
fn the_anchors_of_a_container_selected_whole_draw_selected() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let rect = |app: &mut VectorcraftApp, x: i32, y: i32| {
|
||
app.run("shape.rectangle", json!({"x": x, "y": y, "width": 60, "height": 60})).unwrap()["id"].clone()
|
||
};
|
||
let in_group = rect(&mut app, 20, 20);
|
||
let ellipse = app.run("shape.ellipse", json!({"x": 100, "y": 20, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [in_group, ellipse]})).unwrap();
|
||
let group = app.run("object.group", json!({})).unwrap()["id"].clone();
|
||
let (c1, c2) = (rect(&mut app, 200, 20), rect(&mut app, 220, 40));
|
||
app.run("select.set", json!({"ids": [c1, c2]})).unwrap();
|
||
let compound = app.run("object.compoundPath.make", json!({})).unwrap()["id"].clone();
|
||
let hidden = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 200, "height": 100})).unwrap()["id"].clone();
|
||
let square = rect(&mut app, 60, 170);
|
||
app.run("select.set", json!({"ids": [hidden, square]})).unwrap();
|
||
let clip = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone();
|
||
let lone = app.run("shape.ellipse", json!({"x": 300, "y": 150, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(lone.as_u64().unwrap())));
|
||
let ctx = egui::Context::default();
|
||
// Filled and hollow anchor squares, and direction handle lines, drawn in the layer color.
|
||
let looks = |app: &mut VectorcraftApp| {
|
||
let painted = shapes(app, &ctx);
|
||
let small = |r: &egui::epaint::RectShape| r.rect.width() < 12.0;
|
||
let filled = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == color)).count();
|
||
let hollow = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == Color32::WHITE)).count();
|
||
let handles = painted.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count();
|
||
(filled, hollow, handles)
|
||
};
|
||
let mut wrong = vec![];
|
||
for tool in ["directSelection", "pen", "addAnchor", "deleteAnchor", "anchorPoint"] {
|
||
app.select_tool(tool);
|
||
// What is selected, and what it shows: the lone ellipse selected whole is the reference
|
||
// (four anchors with two handles each, and its center point, filled too).
|
||
for (what, ids, want) in [
|
||
("a path", json!([lone]), (5, 0, 8)),
|
||
("a group", json!([group]), (8, 0, 0)),
|
||
("a compound path", json!([compound]), (8, 0, 0)),
|
||
("a clip group", json!([clip]), (4, 0, 0)),
|
||
] {
|
||
app.run("select.set", json!({ "ids": ids })).unwrap();
|
||
let got = looks(&mut app);
|
||
if got != want {
|
||
wrong.push(format!("{tool}, {what}: (filled, hollow, handles) {got:?}, want {want:?}"));
|
||
}
|
||
}
|
||
}
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("groupSelection");
|
||
let view = app.view_info();
|
||
for _ in 0..2 {
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 20.0, 50.0), view).unwrap();
|
||
}
|
||
}
|
||
let id = |v: &serde_json::Value| vectorcraft_doc::NodeId(v.as_u64().unwrap());
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![id(&in_group), id(&group)]);
|
||
app.select_tool("directSelection");
|
||
// The rectangle's anchors and center point, then the group's eight anchors, four of them over the rectangle's.
|
||
let got = looks(&mut app);
|
||
if got != (13, 0, 0) {
|
||
wrong.push(format!("a Group Selection click on a path, then on its group: {got:?}, want (13, 0, 0)"));
|
||
}
|
||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||
}
|
||
|
||
/// View › Pixel Preview: magnified, the art shows as the document pixels it rasterizes to (one
|
||
/// per point, hard-edged), not as smooth vectors; zoomed out or out of Pixel Preview the canvas
|
||
/// renders for the screen again.
|
||
#[test]
|
||
fn pixel_preview_shows_the_document_pixels() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// Its 1 pt stroke covers a quarter of document pixel 99 and most of pixel 100.
|
||
app.session.execute("shape.rectangle", &json!({"x": 100.25, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let ctx = egui::Context::default();
|
||
// The art's raster uploaded by one frame: (width, height, hard-edged, pixels).
|
||
let upload = |app: &mut VectorcraftApp| {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let up = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
(img.width(), img.height(), d.options.magnification == egui::TextureFilter::Nearest, img.pixels.clone())
|
||
});
|
||
delta.clear();
|
||
up.expect("the art re-rendered")
|
||
};
|
||
let screen = upload(&mut app); // the first frame fits the view to the artboard
|
||
assert!(screen.0 > 600 && !screen.2, "out of Pixel Preview: the screen's pixels, smoothed");
|
||
app.ui.view.pixel_preview = true;
|
||
let view = app.view_mut().unwrap();
|
||
(view.zoom, view.center) = (8.0, Point::new(100.0, 150.0));
|
||
let (w, h, hard, px) = upload(&mut app);
|
||
assert!(hard, "hard-edged pixels");
|
||
assert!(w <= screen.0 / 8 + 2 && h <= screen.1 / 8 + 2, "one pixel per point: {w} × {h}");
|
||
let [x0, y0, x1, y1] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered");
|
||
assert_eq!(((x1 - x0) as usize, (y1 - y0) as usize), (w, h));
|
||
// Each document pixel is one texel, anti-aliased by how much of it the art covers.
|
||
let alpha = |x: i64| px[((150 - y0) * w as i64 + x - x0) as usize].a();
|
||
assert!((40..=90).contains(&alpha(99)), "a quarter of pixel 99: {}", alpha(99));
|
||
assert!(alpha(100) > 200, "most of pixel 100: {}", alpha(100));
|
||
assert_eq!((alpha(98), alpha(101)), (0, 255));
|
||
// The pixels are placed over the document rect they cover.
|
||
let s = shapes(&mut app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let Some(Shape::Mesh(mesh)) = s.iter().find(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)) else { panic!("the art") };
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(x0 as f64, y0 as f64));
|
||
assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos);
|
||
// Zoomed out, a document pixel is no bigger than a screen pixel: rendered for the screen.
|
||
app.view_mut().unwrap().zoom = 0.5;
|
||
let (w, _, hard, _) = upload(&mut app);
|
||
assert!(w > 600 && !hard, "zoomed out: the screen's pixels");
|
||
}
|
||
|
||
/// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template
|
||
/// draws it again, dimmed as before; each re-renders at once.
|
||
#[test]
|
||
fn hide_template_leaves_template_layers_off_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
let layer = app.session.active().unwrap().doc.layers[0].id.0;
|
||
app.session.execute("layer.template", &json!({"ids": [layer], "on": true})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// How many pixels of the art's raster one frame uploads are painted, None when it was not
|
||
// re-rendered.
|
||
let painted = |app: &mut VectorcraftApp| -> Option<usize> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.iter().filter(|c| c.a() != 0).count()
|
||
});
|
||
delta.clear();
|
||
n
|
||
};
|
||
let shown = painted(&mut app).expect("the first frame renders the art");
|
||
assert!(shown > 1000, "the template layer's art is drawn: {shown} pixels");
|
||
assert_eq!(app.run("view.template", json!({})).unwrap(), json!(false));
|
||
assert_eq!(painted(&mut app), Some(0), "Hide Template re-renders without the template layer");
|
||
assert_eq!(painted(&mut app), None, "nothing changed: no re-render");
|
||
assert_eq!(app.run("view.template", json!({})).unwrap(), json!(true));
|
||
assert_eq!(painted(&mut app), Some(shown), "Show Template draws it again as before");
|
||
}
|
||
|
||
/// General › Anti-aliased Artwork (#394): on (the default), the art's edges are smoothed on
|
||
/// screen; off, every pixel is either painted or not, and turning it either way re-renders.
|
||
#[test]
|
||
fn anti_aliased_artwork_preference_smooths_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// Render on this thread: the worker's frame would land later than the test looks.
|
||
app.canvas.worker_started = true;
|
||
// A disc: its edge crosses pixels at every angle.
|
||
app.session.execute("shape.ellipse", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// The alphas of the art's raster uploaded by one frame, None when it was not re-rendered.
|
||
let alphas = |app: &mut VectorcraftApp| -> Option<Vec<u8>> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let alphas = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.iter().map(|c| c.a()).collect()
|
||
});
|
||
delta.clear();
|
||
alphas
|
||
};
|
||
let partial = |a: &[u8]| a.iter().filter(|&&a| a != 0 && a != 255).count();
|
||
let on = alphas(&mut app).expect("the first frame renders the art");
|
||
assert!(partial(&on) > 50, "anti-aliased: edge pixels partly covered ({} of them)", partial(&on));
|
||
assert!(alphas(&mut app).is_none(), "nothing changed: no re-render");
|
||
app.session.execute("prefs.set", &json!({"key": "antiAliasedArtwork", "value": false})).unwrap();
|
||
let off = alphas(&mut app).expect("the preference change re-renders");
|
||
assert_eq!(partial(&off), 0, "hard edges: every pixel painted or not");
|
||
assert!(off.iter().filter(|&&a| a == 255).count() > 1000, "the disc is still painted");
|
||
app.session.execute("prefs.set", &json!({"key": "antiAliasedArtwork", "value": true})).unwrap();
|
||
assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again");
|
||
}
|
||
|
||
/// Type › Highlight Substituted Fonts: on (the default), type in a missing font has pink
|
||
/// behind it on the canvas; off, it does not, and turning it either way re-renders.
|
||
#[test]
|
||
fn highlight_substituted_fonts_preference_reaches_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
app.session.execute("text.create", &json!({"x": 20, "y": 120, "text": "Hamburg", "size": 60, "font": "Missing Family"})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// How many pink pixels the art's raster uploaded by one frame has, None when it was not
|
||
// re-rendered.
|
||
let pink = |app: &mut VectorcraftApp| -> Option<usize> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.iter().filter(|c| c.a() != 0 && c.r() > c.g().saturating_add(30) && c.b() > c.g()).count()
|
||
});
|
||
delta.clear();
|
||
n
|
||
};
|
||
let on = pink(&mut app).expect("the first frame renders the art");
|
||
assert!(on > 100, "on by default: the text has pink behind it ({on} pixels)");
|
||
assert!(app.canvas.shown.as_ref().is_some_and(|(_, o)| o.highlight_substituted_fonts));
|
||
app.session.execute("prefs.set", &json!({"key": "highlightSubstitutedFonts", "value": false})).unwrap();
|
||
assert_eq!(pink(&mut app), Some(0), "the preference off re-renders without the highlight");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.highlight_substituted_fonts));
|
||
app.session.execute("prefs.set", &json!({"key": "highlightSubstitutedFonts", "value": true})).unwrap();
|
||
assert_eq!(pink(&mut app), Some(on), "on again, as before");
|
||
}
|
||
|
||
/// Images whose pixels can't be decoded draw as crossed boxes on the canvas, and the status
|
||
/// bar reports each of them once: two such images give one message, and later frames that
|
||
/// draw them again give none.
|
||
#[test]
|
||
fn undecodable_images_are_marked_and_reported_once() {
|
||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
// PNGs of noisy pixels cut off halfway: their headers read, their pixels don't decode.
|
||
let cut = |w: u32| {
|
||
let img = image::RgbaImage::from_fn(w, 41, |x, y| image::Rgba([(x * 37 + y * 101) as u8, (x * y) as u8, (x ^ y) as u8, 255]));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
png.truncate(png.len() / 2);
|
||
png
|
||
};
|
||
{
|
||
let st = app.session.doc_mut().unwrap();
|
||
let d = std::sync::Arc::make_mut(&mut st.doc);
|
||
for (i, w) in [53u32, 59].into_iter().enumerate() {
|
||
let key = format!("cut{i}");
|
||
d.images.insert(key.clone(), ImageBlob::new("image/png", cut(w)));
|
||
let im = ImageObject {
|
||
key,
|
||
width: w,
|
||
height: 41,
|
||
xf: Affine::translate((40.0 + 150.0 * i as f64, 100.0)),
|
||
link: None,
|
||
placement: Default::default(),
|
||
pixels: None,
|
||
};
|
||
let (id, layer) = (d.alloc_id(), d.layers[0].id);
|
||
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||
}
|
||
st.revision += 1;
|
||
}
|
||
let ctx = egui::Context::default();
|
||
app.status("");
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert!(app.canvas.shown.as_ref().is_some_and(|(_, o)| o.mark_undecoded_images), "the canvas marks them");
|
||
assert_eq!(app.ui.status, "2 image(s) couldn't be decoded and show as crossed boxes");
|
||
app.status("");
|
||
app.session.doc_mut().unwrap().revision += 1;
|
||
frame(&mut app, &ctx, vec![]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.ui.status, "", "said once");
|
||
}
|
||
|
||
/// A drag of an object with raster effects (#1257): the drag's frames render while
|
||
/// interacting and draw the object's drop shadow and filtered content from the rasters of the
|
||
/// frame before; the frame after the drag renders without `interacting`, on the render worker,
|
||
/// and draws what a fresh renderer draws.
|
||
#[test]
|
||
fn dragging_an_object_with_raster_effects_draws_its_rasters_moved() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone();
|
||
for effect in ["stylize.dropShadow", "texture.craquelure"] {
|
||
app.run("effect.apply", json!({"effect": effect, "ids": [id]})).unwrap();
|
||
}
|
||
let ctx = egui::Context::default();
|
||
// The art's raster uploaded by one frame, None when it was not re-rendered.
|
||
let upload = |app: &mut VectorcraftApp| -> Option<Vec<Color32>> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.clone()
|
||
});
|
||
delta.clear();
|
||
px
|
||
};
|
||
upload(&mut app).expect("the first frame renders the art");
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap();
|
||
for step in [1.0, 2.0, 3.0] {
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 160.0 + 7.3 * step, 125.0 + 2.1 * step), view).unwrap();
|
||
upload(&mut app).expect("each frame of the drag renders");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "step {step}");
|
||
assert_eq!(app.canvas.renderer.stats.moved, 2, "the shadow and the filtered content, step {step}");
|
||
assert!(app.canvas.moved);
|
||
}
|
||
// The frame after the drag runs the effects again: on the worker, while the canvas shows
|
||
// the drag's last frame.
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap();
|
||
assert!(upload(&mut app).is_none(), "not rendered on this thread");
|
||
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker");
|
||
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||
let mut after = None;
|
||
while after.is_none() && std::time::Instant::now() < until {
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
after = upload(&mut app);
|
||
}
|
||
let after = after.expect("the worker's frame arrives");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting));
|
||
assert!(!app.canvas.moved);
|
||
app.canvas.worker = None;
|
||
app.canvas.renderer = vectorcraft_render::Renderer::new();
|
||
app.canvas.key = None;
|
||
assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws");
|
||
}
|
||
|
||
/// A tool's drag on the canvas renders with `dragging` (#2558). At 3200% a rectangle with
|
||
/// Texturizer covers more than the raster budget, so no raster is kept for it, and each frame
|
||
/// of a Selection tool drag draws its filtered content at the drag's lower resolution. The
|
||
/// frame after the drag draws what a fresh renderer draws. A dialog's preview, an interaction
|
||
/// without a drag, renders without `dragging` and draws the same.
|
||
#[test]
|
||
fn a_drag_on_the_canvas_draws_raster_effects_at_a_lower_resolution() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150})).unwrap()["id"].clone();
|
||
app.run("effect.apply", json!({"effect": "texture.texturizer", "ids": [id]})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
// One headless frame on a 1920 × 1200 window (a view larger than the drag's budget) with
|
||
// `events` → the art's raster it uploaded, None when it was not re-rendered.
|
||
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| -> Option<Vec<Color32>> {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1200.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
let art = app.canvas.texture.as_ref().map(|t| t.id());
|
||
let px = out.textures_delta.set.iter().find(|e| Some(*e.0) == art).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.clone()
|
||
});
|
||
out.textures_delta.clear();
|
||
px
|
||
};
|
||
run(&mut app, vec![]);
|
||
if let Some(vm) = app.view_mut() {
|
||
(vm.zoom, vm.center) = (32.0, Point::new(200.0, 155.0));
|
||
}
|
||
let still = run(&mut app, vec![]).expect("the zoomed view renders");
|
||
assert_eq!(app.canvas.renderer.stats.previewed, 0, "a still frame");
|
||
let at = app.canvas_rect.unwrap().center();
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
run(&mut app, vec![egui::Event::PointerMoved(at)]);
|
||
run(&mut app, vec![button(at, true)]);
|
||
for step in [1.0, 2.0] {
|
||
let drawn = run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]).expect("each frame of the drag renders");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}");
|
||
assert_eq!(app.canvas.renderer.stats.previewed, 1, "the filtered content, step {step}");
|
||
assert!(app.canvas.moved);
|
||
assert!(drawn != still, "the rectangle moved, step {step}");
|
||
}
|
||
let after = run(&mut app, vec![button(at + vec2(26.0, 14.0), false)]).expect("the frame after the drag renders");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting && !k.dragging));
|
||
assert_eq!(app.canvas.renderer.stats.previewed, 0, "the frame after the drag");
|
||
assert!(!app.canvas.moved);
|
||
app.canvas.renderer = vectorcraft_render::Renderer::new();
|
||
app.canvas.key = None;
|
||
assert!(run(&mut app, vec![]) == Some(after.clone()), "the frame after the drag is what a fresh renderer draws");
|
||
// A dialog's preview: an interaction, and no drag.
|
||
app.session.begin_interaction("Texturizer").unwrap();
|
||
assert!(run(&mut app, vec![]) == Some(after), "a dialog's preview draws the effect at full resolution");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && !k.dragging));
|
||
assert_eq!(app.canvas.renderer.stats.previewed, 0);
|
||
}
|
||
|
||
/// Dragging a small square over an A4 page filled by a rectangle blurred by 500 pt, at 200%:
|
||
/// the blur reaches far past the raster budget, so the still frame keeps the backdrop's raster
|
||
/// cropped to the view, and each frame of a Selection tool drag draws it from there instead of
|
||
/// previewing it at the drag's lower resolution: on this thread, and on the worker, where the
|
||
/// frames of a heavy document render.
|
||
#[test]
|
||
fn a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame() {
|
||
for on_worker in [false, true] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap();
|
||
app.canvas.worker_started = true; // no worker unless `on_worker`
|
||
let back = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone();
|
||
app.run("paint.setFill", json!({"ids": [back], "color": "#1a4ce6"})).unwrap();
|
||
app.run("effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 500}, "ids": [back]})).unwrap();
|
||
let square = app.run("shape.rectangle", json!({"x": 260, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [square]})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
// One headless frame with `events`, then, on the worker, frames until the frame it renders
|
||
// for them is shown.
|
||
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||
let frame = |app: &mut VectorcraftApp, events| {
|
||
let raw =
|
||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 870.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
frame(app, events);
|
||
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||
while app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) && std::time::Instant::now() < until {
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
frame(app, vec![]);
|
||
}
|
||
};
|
||
let moved = |app: &VectorcraftApp| if on_worker { app.canvas.moved } else { app.canvas.renderer.stats.moved > 0 };
|
||
run(&mut app, vec![]);
|
||
if let Some(vm) = app.view_mut() {
|
||
(vm.zoom, vm.center) = (2.0, Point::new(290.0, 410.0));
|
||
}
|
||
if on_worker {
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
app.canvas.last_ms = 50.0;
|
||
}
|
||
run(&mut app, vec![]);
|
||
assert_eq!(app.canvas.from_worker, on_worker);
|
||
assert!(!moved(&app), "a still frame, on the worker: {on_worker}");
|
||
let at = app.canvas_rect.unwrap().center();
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
run(&mut app, vec![egui::Event::PointerMoved(at)]);
|
||
run(&mut app, vec![button(at, true)]);
|
||
for step in [1.0, 2.0, 3.0] {
|
||
run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]);
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}, on the worker: {on_worker}");
|
||
assert_eq!(app.canvas.from_worker, on_worker, "step {step}");
|
||
assert!(
|
||
!moved(&app),
|
||
"the blur drawn from the still frame's raster, step {step}, on the worker: {on_worker} ({} ms)",
|
||
app.perf.render_ms
|
||
);
|
||
}
|
||
run(&mut app, vec![button(at + vec2(39.0, 21.0), false)]);
|
||
let info = app.run("document.inspect", json!({})).unwrap();
|
||
let children = info["layers"][0]["children"].as_array().unwrap().clone();
|
||
let x0 = |id: &serde_json::Value| children.iter().find(|c| c["id"] == *id).unwrap()["bounds"]["x"].as_f64().unwrap();
|
||
assert!(x0(&square) > 270.0 && x0(&back) == 0.0, "the square moved, the backdrop did not");
|
||
app.canvas.worker = None;
|
||
}
|
||
}
|
||
|
||
/// An interaction's frames stay on the worker once it drew the frame shown, however fast that
|
||
/// frame was: the worker's renderer keeps the effect rasters that made it fast, and this
|
||
/// thread's renderer, without them, would draw the next frame slowly. Without a frame from the
|
||
/// worker, a fast frame renders on this thread as before.
|
||
#[test]
|
||
fn an_interaction_stays_on_the_worker_that_drew_the_frame_shown() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
app.canvas.worker_started = true; // the first frame renders on this thread
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx);
|
||
for from_worker in [true, false] {
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
(app.canvas.from_worker, app.canvas.last_ms) = (from_worker, 1.0);
|
||
app.session.begin_interaction("Preview").unwrap();
|
||
shapes(&mut app, &ctx);
|
||
let pending = app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.interacting);
|
||
assert_eq!(pending, from_worker, "sent to the worker: {from_worker}");
|
||
if !from_worker {
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "rendered on this thread");
|
||
}
|
||
app.session.cancel_interaction().unwrap();
|
||
app.canvas.worker = None;
|
||
shapes(&mut app, &ctx);
|
||
}
|
||
}
|
||
|
||
/// The frame after an interaction renders without `interacting` also when the interaction
|
||
/// leaves the document as it was (#1257): here a drag back to where it started.
|
||
#[test]
|
||
fn a_drag_back_to_its_start_renders_again_after_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone();
|
||
app.run("effect.apply", json!({"effect": "texture.craquelure", "ids": [id]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx);
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap();
|
||
for (x, y) in [(173.3, 129.1), (160.0, 125.0)] {
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Drag, x, y), view).unwrap();
|
||
shapes(&mut app, &ctx);
|
||
assert_eq!(app.canvas.renderer.stats.moved, 1, "the filtered content at ({x}, {y})");
|
||
}
|
||
let revision = app.session.active().unwrap().revision;
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 125.0), view).unwrap();
|
||
assert_eq!(app.session.active().unwrap().revision, revision, "the drag left the document as it was");
|
||
shapes(&mut app, &ctx);
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting), "rendered again");
|
||
assert_eq!(app.canvas.renderer.stats.moved, 0);
|
||
assert!(!app.canvas.moved);
|
||
}
|
||
|
||
/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size.
|
||
/// Keep the worker controlled so huge virtual viewports never allocate huge test images.
|
||
#[test]
|
||
fn canvas_render_jobs_use_renderer_limited_dimensions() {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true;
|
||
app.ui.view.rulers = false;
|
||
let frame = |app: &mut VectorcraftApp, size, ppp| {
|
||
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() };
|
||
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp);
|
||
let mut output = ctx.run_ui(input, |ui| show(app, ui));
|
||
output.textures_delta.clear();
|
||
};
|
||
frame(&mut app, vec2(400.0, 300.0), 1.0);
|
||
let (worker, jobs, _completed) = crate::render_worker::Worker::test_channel();
|
||
app.canvas.worker = Some(worker);
|
||
app.canvas.last_ms = 50.0;
|
||
for (size, ppp, wide) in [(vec2(70000.25, 32.0), 1.0, true), (vec2(32.0, 70000.25), 1.0, false), (vec2(40000.25, 32.0), 2.0, true)] {
|
||
frame(&mut app, size, ppp);
|
||
let job = jobs.try_recv().expect("oversized viewport queued on controlled worker");
|
||
assert_eq!((job.key.w, job.key.h), (job.w, job.h));
|
||
assert_eq!(if wide { job.w } else { job.h }, u32::from(u16::MAX));
|
||
assert!((1..=u32::from(u16::MAX)).contains(&job.w));
|
||
assert!((1..=u32::from(u16::MAX)).contains(&job.h));
|
||
let center = job.view * Point::new(job.key.cx, job.key.cy);
|
||
assert!((center.x - f64::from(job.w) / 2.0).abs() < 0.001);
|
||
assert!((center.y - f64::from(job.h) / 2.0).abs() < 0.001);
|
||
}
|
||
}
|
||
|
||
/// A cached texture keeps the renderer's document mapping when the viewport changes. The
|
||
/// controlled worker cannot finish early, even if egui repeats a layout pass.
|
||
#[test]
|
||
fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() {
|
||
for (old_rot, new_rot, old_ppp, new_ppp, pixel, resize_only) in [
|
||
// Isolate the original panel/window resize: zoom, pan, rotation and DPI stay fixed.
|
||
(0.0, 0.0, 1.0, 1.0, false, true),
|
||
(30.0, 30.0, 1.25, 1.25, false, true),
|
||
(0.0, 0.0, 1.0, 1.0, false, false),
|
||
(30.0, 30.0, 1.25, 1.25, false, false),
|
||
(30.0, 75.0, 1.25, 1.25, false, false),
|
||
(30.0, 75.0, 1.25, 2.0, false, false),
|
||
(30.0, 30.0, 2.0, 1.25, false, false),
|
||
(30.0, 75.0, 1.25, 2.0, true, false),
|
||
] {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true;
|
||
app.ui.view.pixel_preview = pixel;
|
||
let frame = |app: &mut VectorcraftApp, size: egui::Vec2, ppp: f32| {
|
||
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(31.25, 19.5), size)), ..Default::default() };
|
||
// Monitor DPI is viewport input, not egui's UI zoom (which rescales the last
|
||
// screen rect and would override the simultaneous resize under test).
|
||
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp);
|
||
let mut out = ctx.run_ui(input, |ui| show(app, ui));
|
||
assert!((out.pixels_per_point - ppp).abs() < 0.001);
|
||
out.textures_delta.clear();
|
||
let tex = app.canvas.texture.as_ref().expect("rendered canvas texture").id();
|
||
out.shapes
|
||
.into_iter()
|
||
.find_map(|s| match s.shape {
|
||
Shape::Mesh(m) if m.texture_id == tex => Some(m),
|
||
_ => None,
|
||
})
|
||
.expect("the canvas texture is drawn")
|
||
};
|
||
frame(&mut app, vec2(800.25, 600.35), old_ppp);
|
||
{
|
||
let v = app.view_mut().unwrap();
|
||
v.rotation = old_rot;
|
||
v.zoom = 2.5;
|
||
v.center = Point::new(210.0, 160.0);
|
||
}
|
||
frame(&mut app, vec2(800.25, 600.35), old_ppp);
|
||
let old = app.canvas.key.clone().unwrap();
|
||
let (worker, jobs, completed) = crate::render_worker::Worker::test_channel();
|
||
app.canvas.worker = Some(worker);
|
||
app.canvas.last_ms = 50.0;
|
||
{
|
||
let v = app.view_mut().unwrap();
|
||
v.rotation = new_rot;
|
||
if !resize_only {
|
||
v.zoom = 3.25;
|
||
v.center = Point::new(225.0, 170.0);
|
||
}
|
||
}
|
||
let size = vec2(602.5, 720.1);
|
||
let mesh = frame(&mut app, size, new_ppp);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the cached frame is still displayed");
|
||
let job = jobs.try_recv().expect("the replacement frame was submitted");
|
||
assert_eq!(job.key.ppp, new_ppp);
|
||
assert_eq!(job.key.rot, new_rot);
|
||
if resize_only {
|
||
assert_eq!((job.key.zoom, job.key.cx, job.key.cy, job.key.rot, job.key.ppp), (old.zoom, old.cx, old.cy, old.rot, old.ppp));
|
||
}
|
||
assert_ne!((job.key.w, job.key.h), (old.w, old.h));
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let render_view = Affine::translate((old.w as f64 / 2.0, old.h as f64 / 2.0))
|
||
* Affine::rotate(old.rot.to_radians())
|
||
* Affine::scale(old.zoom * old.ppp as f64)
|
||
* Affine::translate((-old.cx, -old.cy));
|
||
assert_eq!(mesh.vertices.len(), 4);
|
||
for vertex in &mesh.vertices {
|
||
assert!([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)].contains(&vertex.uv));
|
||
let doc = if let Some([x0, y0, x1, y1]) = old.pixel {
|
||
Point::new(x0 as f64 + vertex.uv.x as f64 * (x1 - x0) as f64, y0 as f64 + vertex.uv.y as f64 * (y1 - y0) as f64)
|
||
} else {
|
||
render_view.inverse() * Point::new(vertex.uv.x as f64 * old.w as f64, vertex.uv.y as f64 * old.h as f64)
|
||
};
|
||
assert!(
|
||
(vertex.pos - xf.to_screen(doc)).length() < 0.002,
|
||
"{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}"
|
||
);
|
||
}
|
||
// Deliver the actual replacement and verify that it replaces the cached key.
|
||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||
let expected = job.key.clone();
|
||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap();
|
||
frame(&mut app, size, new_ppp);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived");
|
||
}
|
||
}
|
||
|
||
/// When the view turns while the worker renders the frame at the new angle (Reset Rotate View on
|
||
/// a heavy document), the canvas draws the last frame turned until that frame arrives: each
|
||
/// corner goes where the new view puts the document point that the corner showed. The new frame
|
||
/// is then drawn unturned. In Pixel Preview the last frame's pixels stay over the document rect
|
||
/// they cover.
|
||
#[test]
|
||
fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx); // the first frame fits the view to the artboard
|
||
app.view_mut().unwrap().rotation = 30.0;
|
||
shapes(&mut app, &ctx);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let old = Xf::new(rect, app.view().unwrap());
|
||
// `art` runs one frame and finds the mesh that draws the art's texture.
|
||
let art = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let tex = app.canvas.texture.as_ref().unwrap().id();
|
||
s.into_iter().find_map(|s| match s {
|
||
Shape::Mesh(m) if m.texture_id == tex => Some(m),
|
||
_ => None,
|
||
})
|
||
};
|
||
// With a worker and a last frame that took 50 ms, the next frame renders on the worker.
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
app.canvas.last_ms = 50.0;
|
||
app.run("view.rotateReset", json!({})).unwrap();
|
||
let mesh = art(&mut app).expect("the last frame, turned");
|
||
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.rot == 0.0), "sent to the worker");
|
||
let new = Xf::new(rect, app.view().unwrap());
|
||
let corners = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()];
|
||
assert_eq!(mesh.vertices.len(), 4);
|
||
for ((v, corner), uv) in mesh.vertices.iter().zip(corners).zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) {
|
||
let at = new.to_screen(old.to_doc(corner));
|
||
assert!((v.pos - at).length() < 0.01 && v.uv == uv, "{v:?}: texture corner {uv:?} at {at:?}");
|
||
}
|
||
// The point where the last frame drew the rectangle's top-left corner lands where the new view puts that corner.
|
||
let p = Point::new(100.0, 50.0);
|
||
let uv = (old.to_screen(p) - rect.min) / rect.size();
|
||
let [a, b, _, d] = [0, 1, 2, 3].map(|i| mesh.vertices[i].pos);
|
||
assert!((a + (b - a) * uv.x + (d - a) * uv.y - new.to_screen(p)).length() < 0.01, "the rectangle's corner");
|
||
// The worker's frame at 0° then covers the canvas unturned.
|
||
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||
while app.canvas.key.as_ref().is_some_and(|k| k.rot != 0.0) && std::time::Instant::now() < until {
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
shapes(&mut app, &ctx);
|
||
}
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.rot == 0.0), "the worker's frame arrives");
|
||
let b = art(&mut app).expect("the worker's frame").calc_bounds();
|
||
assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}");
|
||
// In Pixel Preview the last frame's pixels are drawn over the document rect they cover.
|
||
app.canvas.worker = None;
|
||
app.ui.view.pixel_preview = true;
|
||
app.view_mut().unwrap().zoom = 8.0;
|
||
shapes(&mut app, &ctx);
|
||
let [x0, y0, ..] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered");
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
app.canvas.last_ms = 50.0;
|
||
app.view_mut().unwrap().rotation = 30.0;
|
||
let mesh = art(&mut app).expect("the last frame's pixels");
|
||
let at = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(x0 as f64, y0 as f64));
|
||
assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos);
|
||
app.canvas.worker = None;
|
||
}
|
||
|
||
/// While the worker renders the frame for a panned view, the fill worker renders a
|
||
/// low-resolution frame, and the part of the canvas the last frame no longer covers shows the
|
||
/// art from it, drawn only there. When the worker's frame arrives, it covers the canvas and the
|
||
/// fill is dropped.
|
||
#[test]
|
||
fn a_pan_fills_the_area_the_last_frame_leaves_until_the_worker_frame_arrives() {
|
||
// The canvas key holds the global placed-document bitmap clock, which tests on other threads
|
||
// move: a frame whose key changed by more than the view drops the fill. An attempt that fails
|
||
// while the clock moved runs again; one that fails while it stood still is a real failure.
|
||
for _ in 0..20 {
|
||
let clock = vectorcraft_render::placed_document::generation();
|
||
match std::panic::catch_unwind(pan_fills_the_area_the_last_frame_leaves) {
|
||
Ok(()) => return,
|
||
Err(_) if vectorcraft_render::placed_document::generation() != clock => {}
|
||
Err(e) => std::panic::resume_unwind(e),
|
||
}
|
||
}
|
||
pan_fills_the_area_the_last_frame_leaves();
|
||
}
|
||
|
||
fn pan_fills_the_area_the_last_frame_leaves() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 80, "height": 100})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
app.ui.view.rulers = false;
|
||
app.ui.screen_mode = 2; // no scroll bars: the canvas is the window
|
||
// One frame → the meshes drawn and the fill's pixels, when this frame uploaded them.
|
||
let frame = |app: &mut VectorcraftApp| {
|
||
let (shapes, mut delta) = frame_output(app, &ctx);
|
||
let fill = app.canvas.fill.as_ref().map(|f| f.tex.id());
|
||
let pixels = delta.set.iter().find(|(id, _)| Some(**id) == fill).and_then(|(_, d)| d.last()).map(|d| match &d.image {
|
||
egui::ImageData::Color(img) => img.clone(),
|
||
});
|
||
delta.clear();
|
||
let meshes: Vec<egui::Mesh> = shapes
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Mesh(m) => Some(std::sync::Arc::unwrap_or_clone(m)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
(meshes, pixels)
|
||
};
|
||
frame(&mut app);
|
||
{
|
||
let v = app.view_mut().unwrap();
|
||
v.zoom = 4.0;
|
||
v.center = Point::new(100.0, 150.0);
|
||
}
|
||
frame(&mut app); // the art from about x 0 to 200, without the rectangle
|
||
let old = app.canvas.key.clone().unwrap();
|
||
let before = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
assert!(app.canvas.fill.is_none());
|
||
let (worker, jobs, completed) = crate::render_worker::Worker::test_channel();
|
||
app.canvas.worker = Some(worker);
|
||
let (fill_worker, fill_jobs, fill_completed) = crate::render_worker::Worker::test_channel();
|
||
app.canvas.fill_worker = Some(fill_worker);
|
||
app.canvas.last_ms = 50.0;
|
||
// Pan right by 150 pt: the last frame covers the canvas's left quarter, and the rectangle
|
||
// comes into its right quarter.
|
||
app.view_mut().unwrap().center = Point::new(250.0, 150.0);
|
||
frame(&mut app);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown");
|
||
let job = jobs.try_recv().expect("the frame for the new view is on the worker");
|
||
let fill_job = fill_jobs.try_recv().expect("the fill is on the fill worker");
|
||
let (fw, fh, _) = fill_view(&job.key);
|
||
assert!(fill_job.opts.kept_images_only && (fill_job.w, fill_job.h) == (fw, fh), "{:?}", (fill_job.w, fill_job.h));
|
||
assert!(app.canvas.fill.is_none(), "nothing to draw until the fill arrives");
|
||
// The fill arrives; the next frame draws it, and asks for no other fill.
|
||
let img = app.canvas.renderer.render(&fill_job.doc, fill_job.w, fill_job.h, fill_job.view, &fill_job.opts);
|
||
let (doc, opts) = (fill_job.doc, fill_job.opts);
|
||
fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0 }).unwrap();
|
||
let (meshes, pixels) = frame(&mut app);
|
||
assert!(fill_jobs.try_recv().is_err(), "the fill covers the canvas");
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown");
|
||
let fill = app.canvas.fill.as_ref().expect("the fill is kept");
|
||
let mesh = meshes.iter().find(|m| m.texture_id == fill.tex.id()).expect("the fill is drawn");
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let edge = xf.to_screen(before.to_doc(rect.right_top())).x; // the last frame's right edge
|
||
assert!(edge > rect.left() + 100.0 && edge < xf.to_screen(Point::new(300.0, 0.0)).x, "{edge} in {rect:?}");
|
||
assert!(
|
||
mesh.vertices.iter().all(|v| v.pos.x >= edge - 1e-3 && rect.expand(1e-3).contains(v.pos)),
|
||
"only right of the last frame: {:?}",
|
||
mesh.vertices
|
||
);
|
||
let area: f32 = mesh.indices.as_chunks::<3>().0.iter().map(|t| polygon_area(&t.map(|i| mesh.vertices[i as usize].pos))).sum();
|
||
let uncovered = (rect.right() - edge) * rect.height();
|
||
assert!((area - uncovered).abs() < 1.0, "the whole uncovered area, {uncovered} square points: {area}");
|
||
// The fill's pixel under a point of the canvas, from the uv the mesh gives that point.
|
||
let img = pixels.expect("the fill's pixels were uploaded");
|
||
let to_uv = Affine::scale_non_uniform(1.0 / fill.size.0 as f64, 1.0 / fill.size.1 as f64) * fill.view * xf.affine().inverse();
|
||
let alpha = |p: Pos2| {
|
||
let uv = to_uv * Point::new(p.x as f64, p.y as f64);
|
||
img.pixels[(uv.y * img.size[1] as f64) as usize * img.size[0] + (uv.x * img.size[0] as f64) as usize].a()
|
||
};
|
||
assert!(alpha(xf.to_screen(Point::new(340.0, 150.0))) > 0, "the rectangle, in the uncovered area");
|
||
assert_eq!(alpha(xf.to_screen(Point::new(250.0, 150.0))), 0, "no art there");
|
||
for v in &mesh.vertices {
|
||
assert!((to_uv * Point::new(v.pos.x as f64, v.pos.y as f64) - Point::new(v.uv.x as f64, v.uv.y as f64)).hypot() < 1e-4);
|
||
}
|
||
// Panned until the fill reaches only the canvas's left half: it is drawn only there, and the
|
||
// fill for the new view is asked for.
|
||
let fill_tex = fill.tex.id();
|
||
let reach = fill.view.inverse() * Point::new(fill.size.0 as f64, 0.0); // the fill's right edge
|
||
app.view_mut().unwrap().center = Point::new(reach.x, 150.0);
|
||
let (meshes, _) = frame(&mut app);
|
||
let right = Xf::new(rect, app.view().unwrap()).to_screen(reach).x;
|
||
assert!((right - rect.center().x).abs() < 1.0, "{right} in {rect:?}");
|
||
let mesh = meshes.iter().find(|m| m.texture_id == fill_tex).expect("the fill is drawn where it reaches");
|
||
assert!(mesh.vertices.iter().all(|v| v.pos.x <= right + 1e-3), "only where the fill reaches: {:?}", mesh.vertices);
|
||
let next = fill_jobs.try_recv().expect("the next fill");
|
||
assert_eq!(Some(&next.key), app.canvas.worker.as_ref().unwrap().pending.as_ref());
|
||
app.view_mut().unwrap().center = Point::new(250.0, 150.0);
|
||
// That fill finishes after the worker's frame: it is taken and not kept.
|
||
let img = app.canvas.renderer.render(&next.doc, next.w, next.h, next.view, &next.opts);
|
||
fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0 }).unwrap();
|
||
// The worker's frame arrives: it covers the canvas, and the fill is no longer drawn or kept.
|
||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||
let expected = job.key.clone();
|
||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0 }).unwrap();
|
||
let (meshes, _) = frame(&mut app);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame is shown");
|
||
assert!(app.canvas.fill.is_none() && meshes.iter().all(|m| m.texture_id != fill_tex), "the fill is dropped");
|
||
assert!(app.canvas.fill_worker.as_ref().unwrap().pending.is_none(), "the late fill was taken");
|
||
// A pan with Outline turned on: the frame shown's options are not the ones asked for, and
|
||
// no fill is asked for.
|
||
app.ui.view.outline = true;
|
||
app.view_mut().unwrap().center = Point::new(400.0, 150.0);
|
||
frame(&mut app);
|
||
assert!(jobs.try_iter().last().is_some_and(|j| j.key.outline), "the outline frame is on the worker");
|
||
assert!(fill_jobs.try_recv().is_err() && app.canvas.fill.is_none(), "no fill");
|
||
app.canvas.worker = None;
|
||
app.canvas.fill_worker = None;
|
||
}
|
||
|
||
/// With the web build's setting (`waits_for_still`, no render worker), a heavy document's canvas
|
||
/// is not rendered while the view moves: each frame draws the frame shown moved and scaled with
|
||
/// the view and asks for a frame within STILL_SECS. The canvas renders once the view has been
|
||
/// still for STILL_SECS, and at once after an edit. With a worker's build settings the same frames render at once, as
|
||
/// before.
|
||
#[test]
|
||
fn without_a_worker_a_heavy_canvas_is_reprojected_until_the_view_is_still() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
assert!(!app.canvas.waits_for_still, "off the web build");
|
||
app.canvas.worker_started = true; // no worker, as in the web build
|
||
app.canvas.waits_for_still = true;
|
||
let ctx = egui::Context::default();
|
||
// One frame → (the bounds of the mesh that draws the art's texture, the repaint delay asked for).
|
||
let frame = |app: &mut VectorcraftApp| {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
let tex = app.canvas.texture.as_ref().unwrap().id();
|
||
let art = out.shapes.iter().find_map(|c| match &c.shape {
|
||
Shape::Mesh(m) if m.texture_id == tex => Some(m.calc_bounds()),
|
||
_ => None,
|
||
});
|
||
(art.expect("the art's texture is drawn"), out.viewport_output[&egui::ViewportId::ROOT].repaint_delay)
|
||
};
|
||
frame(&mut app); // the first frame fits the view to the artboard
|
||
frame(&mut app);
|
||
let rect = app.canvas_rect.unwrap();
|
||
// A light document renders every change of view at once.
|
||
app.canvas.last_ms = 1.0;
|
||
app.view_mut().unwrap().center.x += 10.0;
|
||
frame(&mut app);
|
||
let shown = app.canvas.key.clone().unwrap();
|
||
assert_eq!(shown.cx, app.view().unwrap().center.x, "rendered at once");
|
||
// A heavy one draws the frame shown moved and scaled with the view.
|
||
app.canvas.last_ms = 50.0;
|
||
let old = Xf::new(rect, app.view().unwrap());
|
||
for (dx, zoom) in [(10.0, 1.0), (10.0, 1.5), (-25.0, 1.5)] {
|
||
let v = app.view_mut().unwrap();
|
||
v.center.x += dx;
|
||
v.zoom *= zoom;
|
||
let (b, delay) = frame(&mut app);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&shown), "not rendered while the view moves");
|
||
let new = Xf::new(rect, app.view().unwrap());
|
||
let (a, z) = (new.to_screen(old.to_doc(rect.min)), new.to_screen(old.to_doc(rect.max)));
|
||
assert!((b.min - a).length() < 0.01 && (b.max - z).length() < 0.01, "{b:?}: the frame shown at {a:?} to {z:?}");
|
||
assert!(delay <= std::time::Duration::from_secs_f64(STILL_SECS), "a frame within STILL_SECS: {delay:?}");
|
||
}
|
||
// The view stays still: frames at 60 fps (egui's input time without a clock) wait until
|
||
// STILL_SECS has passed, and the next one renders.
|
||
let mut frames = 0;
|
||
let b = loop {
|
||
frames += 1;
|
||
let (b, _) = frame(&mut app);
|
||
if app.canvas.key.as_ref() != Some(&shown) || frames == 60 {
|
||
break b;
|
||
}
|
||
};
|
||
assert!((12..=13).contains(&frames), "rendered after about 12 frames: {frames}");
|
||
let now = app.canvas.key.clone().unwrap();
|
||
assert_eq!((now.cx, now.zoom), (app.view().unwrap().center.x, app.view().unwrap().zoom), "rendered for the view");
|
||
assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}");
|
||
// An edit renders at once, even while the view moves.
|
||
app.canvas.last_ms = 50.0;
|
||
app.view_mut().unwrap().center.y += 10.0;
|
||
frame(&mut app);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&now), "waiting");
|
||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||
frame(&mut app);
|
||
let k = app.canvas.key.clone().unwrap();
|
||
assert_eq!((k.revision, k.cy), (app.session.active().unwrap().revision, app.view().unwrap().center.y), "rendered after the edit");
|
||
// With a worker's build settings and no worker (VECTORCRAFT_SYNC_RENDER), a heavy document
|
||
// renders every change of view at once.
|
||
app.canvas.waits_for_still = false;
|
||
app.canvas.last_ms = 50.0;
|
||
app.view_mut().unwrap().center.x += 10.0;
|
||
frame(&mut app);
|
||
assert_eq!(app.canvas.key.as_ref().map(|k| k.cx), Some(app.view().unwrap().center.x), "rendered at once");
|
||
}
|
||
|
||
/// [`still_wait`] waits while only the view changes on a slow canvas: each change starts
|
||
/// STILL_SECS again. It does not wait for a light document, a still view, a clock that went
|
||
/// back, an edit, a switch into Pixel Preview or a canvas with no frame shown.
|
||
#[test]
|
||
fn the_canvas_waits_only_while_the_view_of_a_slow_frame_changes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
shapes(&mut app, &egui::Context::default());
|
||
let shown = app.canvas.key.clone().unwrap();
|
||
let mut still = Some((shown.clone(), 0.0));
|
||
let moved = CacheKey { cx: shown.cx + 5.0, zoom: shown.zoom * 2.0, ..shown.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0), Some(STILL_SECS), "the view moved");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0625), Some(STILL_SECS - 0.0625), "still for 62.5 ms");
|
||
let turned = CacheKey { rot: 15.0, w: shown.w + 40, ppp: 2.0, ..moved.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.0), Some(STILL_SECS), "turned and resized: again");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.25), None, "still for longer than STILL_SECS");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &turned, false, 11.0), None, "a light document");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 10.5), None, "the clock went back");
|
||
let edited = CacheKey { revision: shown.revision + 1, ..turned.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &edited, true, 12.0), None, "an edit");
|
||
let pixels = CacheKey { pixel: Some([0, 0, 10, 10]), ..moved.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &pixels, true, 13.0), None, "into Pixel Preview");
|
||
let shown_pixels = CacheKey { pixel: Some([5, 5, 20, 20]), ..shown.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown_pixels), &pixels, true, 13.0), Some(STILL_SECS), "Pixel Preview, moved");
|
||
assert_eq!(still_wait(&mut still, None, &moved, true, 14.0), None, "no frame shown");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &shown, true, 15.0), None, "the frame shown");
|
||
}
|
||
|
||
/// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel
|
||
/// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the
|
||
/// canvas always does out of Pixel Preview.
|
||
#[test]
|
||
fn pixel_preview_smooths_bitmaps_only_when_asked() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let ctx = egui::Context::default();
|
||
let smooth = |app: &mut VectorcraftApp| {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
delta.clear();
|
||
app.canvas.key.as_ref().map(|k| k.smooth_images).unwrap()
|
||
};
|
||
assert!(smooth(&mut app), "out of Pixel Preview");
|
||
app.ui.view.pixel_preview = true;
|
||
app.view_mut().unwrap().zoom = 8.0;
|
||
assert!(!smooth(&mut app), "Pixel Preview, off by default");
|
||
app.run("prefs.set", json!({"key": "antiAliasedBitmaps", "value": true})).unwrap();
|
||
assert!(smooth(&mut app), "Pixel Preview, on");
|
||
}
|
||
|
||
/// Appearance of Black › On Screen (#394): Display All Blacks Accurately (the default) shows
|
||
/// pure CMYK black through the CMYK profile, a dark grey; Display All Blacks as Rich Black
|
||
/// shows it as RGB black. A rich black shows the same either way.
|
||
#[test]
|
||
fn appearance_of_black_on_screen_shows_pure_black_as_rgb_black() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
// Larger than the view: the middle of the art's raster is its fill.
|
||
let id = app.run("shape.rectangle", json!({"x": -5000, "y": -5000, "width": 10000, "height": 10000})).unwrap()["id"].clone();
|
||
let fill = |app: &mut VectorcraftApp, cmyk: [u8; 4]| {
|
||
let [c, m, y, k] = cmyk;
|
||
app.run("paint.setFill", json!({"ids": [id], "color": {"c": c, "m": m, "y": y, "k": k}})).unwrap();
|
||
};
|
||
let ctx = egui::Context::default();
|
||
// The middle pixel of the art's raster uploaded by one frame, None when it was not re-rendered.
|
||
let middle = |app: &mut VectorcraftApp| -> Option<[u8; 4]> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
let [w, h] = img.size;
|
||
img.pixels[h / 2 * w + w / 2].to_array()
|
||
});
|
||
delta.clear();
|
||
px
|
||
};
|
||
let shown = |cmyk: [f32; 4]| vectorcraft_color::Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0);
|
||
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1);
|
||
|
||
fill(&mut app, [0, 0, 0, 100]);
|
||
let accurate = middle(&mut app).expect("the first frame renders the art");
|
||
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0])), "accurate: through the CMYK profile, {accurate:?}");
|
||
assert!(accurate[..3].iter().all(|v| *v > 10), "accurate: a dark grey, {accurate:?}");
|
||
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "rich"})).unwrap();
|
||
assert_eq!(middle(&mut app).expect("the preference change re-renders"), [0, 0, 0, 255], "rich: RGB black");
|
||
|
||
fill(&mut app, [60, 40, 40, 100]);
|
||
let rich = middle(&mut app).expect("re-rendered");
|
||
assert!(near(rich, shown([0.6, 0.4, 0.4, 1.0])), "a rich black is not pure black: {rich:?}");
|
||
|
||
fill(&mut app, [0, 0, 0, 100]);
|
||
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "accurate"})).unwrap();
|
||
assert!(near(middle(&mut app).expect("re-rendered"), accurate), "accurate again");
|
||
}
|
||
|
||
/// Guides & Grid › Show Pixel Grid (Above 600% Zoom) (#394): in Pixel Preview at 600% zoom and
|
||
/// above, a line at every document pixel over the art; none below 600%, out of Pixel Preview,
|
||
/// or with the option off.
|
||
#[test]
|
||
fn pixel_grid_shows_in_pixel_preview_from_600_percent() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx); // the first frame fits the view to the artboard
|
||
// → (the pixel grid's lines, whether the first of them is drawn over the art).
|
||
let pixel_lines = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art");
|
||
let is_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == PIXEL_GRID);
|
||
let lines: Vec<usize> = s.iter().enumerate().filter(|(_, s)| is_line(s)).map(|(i, _)| i).collect();
|
||
(lines.len(), lines.first().is_some_and(|&i| i > image))
|
||
};
|
||
app.ui.view.pixel_preview = true;
|
||
app.view_mut().unwrap().zoom = 8.0;
|
||
let (n, over) = pixel_lines(&mut app);
|
||
assert!(n >= 150, "a line every 8 px across the 800 × 600 canvas: {n}");
|
||
assert!(over, "over the art");
|
||
app.view_mut().unwrap().zoom = 5.0;
|
||
assert_eq!(pixel_lines(&mut app).0, 0, "below 600%: none");
|
||
app.view_mut().unwrap().zoom = 6.0;
|
||
assert!(pixel_lines(&mut app).0 >= 200, "from 600%");
|
||
app.ui.view.pixel_preview = false;
|
||
assert_eq!(pixel_lines(&mut app).0, 0, "out of Pixel Preview: none");
|
||
app.ui.view.pixel_preview = true;
|
||
app.session.execute("prefs.set", &json!({"key": "showPixelGrid", "value": false})).unwrap();
|
||
assert_eq!(pixel_lines(&mut app).0, 0, "the option off: none");
|
||
// A rotated view: the lines turn with the pixels, across the whole canvas.
|
||
app.session.execute("prefs.set", &json!({"key": "showPixelGrid", "value": true})).unwrap();
|
||
app.view_mut().unwrap().rotation = 30.0;
|
||
let s = shapes(&mut app, &ctx);
|
||
let turned: Vec<_> = s
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::LineSegment { points: [a, b], stroke } if stroke.color == PIXEL_GRID => Some((b.x - a.x).atan2(b.y - a.y).to_degrees().abs()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(turned.len() >= 200, "{}", turned.len());
|
||
assert!(turned.iter().all(|a| [30.0, 60.0, 120.0, 150.0].iter().any(|t| (a - t).abs() < 0.5)), "turned 30°: {turned:?}");
|
||
}
|
||
|
||
/// Guides & Grid (#394): the grid in Grid Color, behind the art or, with Grids In Back off,
|
||
/// over it, as dots in the Dots style; the guides in Guides Color and Style.
|
||
#[test]
|
||
fn grid_and_guides_take_their_preferences() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
|
||
app.ui.view.grid = true;
|
||
app.ui.view.guides = true;
|
||
let (red, green) = (Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 255, 0));
|
||
app.session.execute("prefs.set", &json!({"values": {"gridColor": "#ff0000", "guideColor": "#00ff00"}})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let red_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == red);
|
||
// → (index of the first red gridline, index of the art's image).
|
||
let order = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art");
|
||
(s.iter().position(red_line).expect("a gridline in Grid Color"), image, s)
|
||
};
|
||
let (grid, art, s) = order(&mut app);
|
||
assert!(grid < art, "Grids In Back: under the art");
|
||
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == green)), "the guide in Guides Color");
|
||
app.session.execute("prefs.set", &json!({"key": "gridsInBack", "value": false})).unwrap();
|
||
let (grid, art, _) = order(&mut app);
|
||
assert!(grid > art, "Grids In Back off: over the art");
|
||
app.session.execute("prefs.set", &json!({"values": {"gridStyle": "dots", "guideStyle": "dots"}})).unwrap();
|
||
let s = shapes(&mut app, &ctx);
|
||
assert!(!s.iter().any(red_line), "no gridlines in the Dots style");
|
||
let red_dots = |s: &Shape| matches!(s, Shape::Mesh(m) if m.vertices.len() > 40 && m.vertices.iter().all(|v| v.color == red));
|
||
assert!(s.iter().any(red_dots), "the grid's dots");
|
||
assert!(s.iter().any(|s| matches!(s, Shape::Circle(c) if c.fill == green)), "the guide dotted");
|
||
}
|
||
|
||
/// A canvas guide (on no artboard) is drawn along its document line in a rotated view, where it
|
||
/// snaps and is picked: both ends of each guide's line are on x = 100 or y = 120. At 0° the
|
||
/// vertical one runs from the canvas's top to its bottom.
|
||
#[test]
|
||
fn canvas_guides_turn_with_the_view() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
|
||
app.session.execute("guide.add", &json!({"vertical": false, "pos": 120})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "guideColor", "value": "#123456"})).unwrap();
|
||
app.ui.view.guides = true;
|
||
let color = Color32::from_rgb(0x12, 0x34, 0x56);
|
||
let ctx = egui::Context::default();
|
||
let lines = |app: &mut VectorcraftApp| -> Vec<[Pos2; 2]> {
|
||
shapes(app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| if let Shape::LineSegment { points, stroke } = s { (stroke.color == color).then_some(points) } else { None })
|
||
.collect()
|
||
};
|
||
let s = lines(&mut app);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let vertical = s.iter().find(|[a, b]| (a.x - b.x).abs() < 1e-3).expect("the vertical guide");
|
||
assert!((vertical[0].y - rect.top()).abs() < 0.5 && (vertical[1].y - rect.bottom()).abs() < 0.5, "0°: {vertical:?} in {rect:?}");
|
||
app.run("view.rotate", json!({"angle": 30})).unwrap();
|
||
let s = lines(&mut app);
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
assert_eq!(s.len(), 2, "{s:?}");
|
||
let on = |v: f64, along_x: bool| {
|
||
s.iter().any(|ends| ends.iter().all(|e| ((if along_x { xf.to_doc(*e).x } else { xf.to_doc(*e).y }) - v).abs() < 1e-3))
|
||
};
|
||
assert!(on(100.0, true), "x = 100: {s:?}");
|
||
assert!(on(120.0, false), "y = 120: {s:?}");
|
||
}
|
||
|
||
/// Selection & Anchor Display › Size (#394): anchors and the bounding box's handles grow.
|
||
#[test]
|
||
fn anchor_size_scales_anchors_and_handles() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// The sizes of the squares drawn (anchors and handles are squares).
|
||
let sizes = |app: &mut VectorcraftApp| {
|
||
let mut v: Vec<f32> = shapes(app, &ctx).iter().filter_map(|s| if let Shape::Rect(r) = s { Some(r.rect.width()) } else { None }).collect();
|
||
v.sort_by(f32::total_cmp);
|
||
v.dedup();
|
||
v
|
||
};
|
||
let small = sizes(&mut app);
|
||
assert!(small.contains(&4.0) && small.contains(&6.0), "anchors 4, handles 6 by default: {small:?}");
|
||
app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap();
|
||
let big = sizes(&mut app);
|
||
assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn mirror_axis_tracks_selection_options_and_view_without_editing_art() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap();
|
||
let id = app.run("object.repeat.mirror", json!({})).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
let axis = |app: &mut VectorcraftApp| {
|
||
let color = Tokens::get(&ctx).selection;
|
||
shapes(app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::LineSegment { points, stroke } if stroke.color == color && stroke.width == 2.0 => Some(points),
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
|
||
let lines = axis(&mut app);
|
||
assert_eq!(lines.len(), 1);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
assert!(lines[0][0].distance(xf.to_screen(Point::new(170.0, 100.0)) - vec2(0.0, 24.0)) < 0.01);
|
||
assert!(lines[0][1].distance(xf.to_screen(Point::new(170.0, 180.0)) + vec2(0.0, 24.0)) < 0.01);
|
||
assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), before);
|
||
app.ui.view.edges = false;
|
||
assert!(axis(&mut app).is_empty());
|
||
app.ui.view.edges = true;
|
||
app.run("select.set", json!({"ids": []})).unwrap();
|
||
assert!(axis(&mut app).is_empty());
|
||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||
assert_eq!(axis(&mut app), lines);
|
||
app.run("object.repeat.options", json!({"angle": 0, "center": [180, 140]})).unwrap();
|
||
let horizontal = axis(&mut app);
|
||
assert_eq!(horizontal.len(), 1);
|
||
assert!((horizontal[0][0].y - horizontal[0][1].y).abs() < 0.01);
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(axis(&mut app), lines);
|
||
app.view_mut().unwrap().rotation = 45.0;
|
||
let rotated = axis(&mut app);
|
||
let d = rotated[0][1] - rotated[0][0];
|
||
assert!((d.x.abs() - d.y.abs()).abs() < 0.01);
|
||
app.run("object.repeat.release", json!({})).unwrap();
|
||
assert!(axis(&mut app).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap();
|
||
app.run("object.repeat.mirror", json!({})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
app.view_mut().unwrap().zoom = 1.5;
|
||
app.view_mut().unwrap().rotation = 30.0;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let axis = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, app.view().unwrap().zoom).unwrap()
|
||
};
|
||
let initial = axis(&app);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let from = xf.to_screen(initial.center);
|
||
let to = from + vec2(40.0, 15.0);
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001, "the mirrored artwork previews during the drag");
|
||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||
assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001);
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(axis(&app).center, initial.center);
|
||
// Both rotation handles remain a usable size at this zoom and view angle.
|
||
let drawn = shapes(&mut app, &ctx);
|
||
for end in initial.ends {
|
||
assert!(drawn.iter().any(|s| matches!(s, Shape::Circle(c)
|
||
if c.center.distance(xf.to_screen(end)) < 0.01 && c.radius == vectorcraft_tools::mirror::HANDLE_RADIUS)));
|
||
}
|
||
}
|
||
|
||
/// A selected grid repeat draws its row handle below the grid and a slider between its first
|
||
/// two columns (none with the bounding box hidden). Dragging the row handle down two tiles in
|
||
/// a zoomed view makes five rows without scaling the source, as one undo step, and the slider
|
||
/// sets the horizontal spacing (#2691).
|
||
#[test]
|
||
fn grid_repeat_handles_set_rows_and_spacing_in_one_undo_step_each() {
|
||
use vectorcraft_tools::repeat_grid::{GridHandles, HANDLE_RADIUS};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 40, "height": 40})).unwrap();
|
||
app.run("object.repeat.grid", json!({"hSpacing": 10, "vSpacing": 10})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let ctx = egui::Context::default();
|
||
// The first frame fits the artboard in the window; then zoom to 1.5 px a point.
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.view_mut().unwrap().zoom = 1.5;
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.view().unwrap().zoom, 1.5);
|
||
// Rows, columns, horizontal spacing and the source's bounds.
|
||
let grid = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
let n = st.doc.node(st.selection.objects[0]).unwrap();
|
||
let vectorcraft_doc::NodeKind::Repeat(spec) = &n.kind else { panic!("a repeat") };
|
||
let vectorcraft_doc::RepeatKind::Grid { rows, cols, h_spacing, .. } = spec.kind else { panic!("a grid repeat") };
|
||
(rows, cols, h_spacing, spec.source_bounds().unwrap())
|
||
};
|
||
let handles = |app: &mut VectorcraftApp| {
|
||
let frame = app.selection_box();
|
||
let st = app.session.active().unwrap();
|
||
GridHandles::of(&st.doc, &st.selection, frame, app.view().unwrap().zoom).unwrap()
|
||
};
|
||
let before = grid(&app);
|
||
assert_eq!((before.0, before.1, before.2), (3, 3, 10.0));
|
||
let g = handles(&mut app);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let drawn = shapes(&mut app, &ctx);
|
||
let circle_at =
|
||
|drawn: &[Shape], at: Pos2| drawn.iter().any(|s| matches!(s, Shape::Circle(c) if c.center.distance(at) < 0.01 && c.radius == HANDLE_RADIUS));
|
||
assert!(circle_at(&drawn, xf.to_screen(g.rows_at)), "the row handle is drawn");
|
||
assert!(circle_at(&drawn, xf.to_screen(g.corner_at)), "the corner handle is drawn");
|
||
app.ui.view.bounding_box = false;
|
||
assert!(!circle_at(&shapes(&mut app, &ctx), xf.to_screen(g.rows_at)), "hidden with the bounding box");
|
||
app.ui.view.bounding_box = true;
|
||
frame(&mut app, &ctx, vec![]);
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, from: Pos2, by: egui::Vec2| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||
for k in 1..=4 {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(from + by * k as f32 / 4.0)]);
|
||
}
|
||
frame(app, &ctx, vec![button(from + by, false)]);
|
||
};
|
||
// Two tiles of 50 pt down, at 1.5 px a point.
|
||
drag(&mut app, xf.to_screen(g.rows_at), vec2(0.0, 150.0));
|
||
let after = grid(&app);
|
||
assert_eq!((after.0, after.1, after.2), (5, 3, 10.0));
|
||
assert_eq!(after.3, before.3, "the source keeps its size");
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(grid(&app).0, 3, "one undo step");
|
||
app.run("edit.redo", json!({})).unwrap();
|
||
assert_eq!(grid(&app).0, 5);
|
||
// The slider between the first two columns, 6 px (4 pt) right: it stays halfway across a
|
||
// gap 8 pt wider, 18 pt of spacing.
|
||
frame(&mut app, &ctx, vec![]);
|
||
let g = handles(&mut app);
|
||
drag(&mut app, xf.to_screen(g.h_slider.unwrap()), vec2(6.0, 0.0));
|
||
assert_eq!(grid(&app).2, 18.0);
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!((grid(&app).0, grid(&app).2), (5, 10.0), "the spacing's own undo step");
|
||
}
|
||
|
||
#[test]
|
||
fn top_bounding_box_handle_scales_a_default_mirror_repeat() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||
app.run("object.repeat.mirror", json!({})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
|
||
let initial = app.selection_bounds().unwrap();
|
||
let bx = app.selection_box().unwrap();
|
||
let top = bx.to_doc() * vectorcraft_tools::bbox::Handle::Top.pos(bx.rect);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let from = xf.to_screen(top);
|
||
let to = from + vec2(0.0, -30.0);
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||
|
||
let scaled = app.selection_bounds().unwrap();
|
||
assert!(scaled.height() > initial.height() + 10.0, "Top handle should scale the repeat: {initial:?} -> {scaled:?}");
|
||
assert!((scaled.width() - initial.width()).abs() < 0.01, "Top handle should not scale width: {initial:?} -> {scaled:?}");
|
||
}
|
||
|
||
/// #1236: Curvature edits points, so existing paths show one circular marker per point and
|
||
/// no direction handles; switching back to Direct Selection keeps its normal handles.
|
||
#[test]
|
||
fn curvature_points_of_an_existing_path_have_no_direction_handles() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = vectorcraft_doc::NodeId(
|
||
app.session
|
||
.execute(
|
||
"path.curvature",
|
||
&json!({"points": [
|
||
{"x": 60, "y": 200}, {"x": 120, "y": 80}, {"x": 210, "y": 160}, {"x": 280, "y": 60}, {"x": 350, "y": 180}
|
||
]}),
|
||
)
|
||
.unwrap()["id"]
|
||
.as_u64()
|
||
.unwrap(),
|
||
);
|
||
let document = app.session.active().unwrap().doc.clone();
|
||
let ctx = egui::Context::default();
|
||
app.select_tool("curvature");
|
||
let drawn = shapes(&mut app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let points: Vec<_> = document.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| xf.to_screen(a.p)).collect();
|
||
let marker = |shape: &Shape| matches!(shape, Shape::Circle(c) if points.iter().any(|p| p.distance(c.center) < 0.01));
|
||
assert_eq!(drawn.iter().filter(|s| marker(s)).count(), 5, "one circular marker at every curve point");
|
||
assert!(
|
||
!drawn.iter().any(|s| matches!(s, Shape::Rect(r) if points.iter().any(|p| p.distance(r.rect.center()) < 0.01))),
|
||
"no square at a curve point"
|
||
);
|
||
let color = c32(document.layer_color(id));
|
||
let handles = |shapes: &[Shape]| shapes.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count();
|
||
assert_eq!(handles(&drawn), 0, "Curvature hides the direction lines");
|
||
app.session.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"})).unwrap();
|
||
let partial = shapes(&mut app, &ctx);
|
||
assert_eq!(partial.iter().filter(|s| marker(s)).count(), 5, "other curve points stay visible");
|
||
assert_eq!(handles(&partial), 0, "a single selected point still shows no direction lines");
|
||
app.ui.view.edges = false;
|
||
assert_eq!(shapes(&mut app, &ctx).iter().filter(|s| marker(s)).count(), 0, "Hide Edges still hides an existing path's markers");
|
||
app.ui.view.edges = true;
|
||
app.select_tool("directSelection");
|
||
assert_eq!(handles(&shapes(&mut app, &ctx)), 2, "Direct Selection keeps the selected point's two handles");
|
||
app.select_tool("pen");
|
||
assert_eq!(handles(&shapes(&mut app, &ctx)), 2, "Pen keeps the selected point's two handles");
|
||
assert_eq!(
|
||
serde_json::to_value(&app.session.active().unwrap().doc).unwrap(),
|
||
serde_json::to_value(&document).unwrap(),
|
||
"tool switches and painting don't change the curve"
|
||
);
|
||
}
|
||
|
||
/// While drawing, Curvature's current-point feedback takes the place of generic selection
|
||
/// markers: no duplicate squares or handles, and the path is unchanged by drawing a frame.
|
||
#[test]
|
||
fn curvature_drawing_shows_its_points_once() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx);
|
||
app.select_tool("curvature");
|
||
for (x, y) in [(60.0, 200.0), (120.0, 80.0), (210.0, 160.0), (280.0, 60.0), (350.0, 180.0)] {
|
||
let view = app.view_info();
|
||
for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] {
|
||
app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, x, y), view).unwrap();
|
||
}
|
||
}
|
||
let st = app.session.active().unwrap();
|
||
let id = st.selection.objects[0];
|
||
let document = st.doc.clone();
|
||
let selection = st.selection.clone();
|
||
let drawn = shapes(&mut app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let points: Vec<_> = document.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| xf.to_screen(a.p)).collect();
|
||
let markers: Vec<_> = drawn
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Circle(c) if points.iter().any(|p| p.distance(c.center) < 0.01) => Some(c),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(markers.len(), 5, "one marker per point, including the current point");
|
||
assert_eq!(markers.iter().filter(|c| c.fill != Color32::WHITE).count(), 1, "only the last touched point is filled");
|
||
assert!(
|
||
!drawn.iter().any(|s| matches!(s, Shape::Rect(r) if points.iter().any(|p| p.distance(r.rect.center()) < 0.01))),
|
||
"neither tool nor selection draws anchor squares"
|
||
);
|
||
let color = c32(document.layer_color(id));
|
||
assert!(!drawn.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)), "no direction lines");
|
||
app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap();
|
||
assert_eq!(
|
||
shapes(&mut app, &ctx)
|
||
.iter()
|
||
.filter(|s| matches!(s, Shape::Circle(c) if c.radius == 5.0 && points.iter().any(|p| p.distance(c.center) < 0.01)))
|
||
.count(),
|
||
5,
|
||
"point markers respect Anchor Size"
|
||
);
|
||
assert_eq!(app.session.active().unwrap().selection, selection);
|
||
assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), serde_json::to_value(&document).unwrap());
|
||
}
|
||
|
||
/// Selection & Anchor Display › Handles and Show handles when multiple anchors are selected
|
||
/// (#394): handle ends drawn solid, hollow or large; with the latter off, a second selected
|
||
/// anchor hides the handles.
|
||
#[test]
|
||
fn handle_style_and_handles_of_multiple_anchors() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
app.session.select_tool("directSelection", app.view_info()).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let select = |app: &mut VectorcraftApp, anchors: serde_json::Value| {
|
||
app.session.execute("select.anchors", &json!({"id": id, "anchors": anchors, "mode": "set"})).unwrap();
|
||
};
|
||
// The handle ends: (radius, filled white).
|
||
let dots = |app: &mut VectorcraftApp| -> Vec<(f32, bool)> {
|
||
shapes(app, &ctx)
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Circle(c) if c.fill != Color32::TRANSPARENT => Some((c.radius, c.fill == Color32::WHITE)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
select(&mut app, json!([[0, 0]]));
|
||
assert_eq!(dots(&mut app), [(2.75, false); 2], "solid by default");
|
||
app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "hollow"})).unwrap();
|
||
assert_eq!(dots(&mut app), [(2.75, true); 2]);
|
||
app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "large"})).unwrap();
|
||
assert_eq!(dots(&mut app), [(4.25, false); 2]);
|
||
select(&mut app, json!([[0, 0], [0, 1]]));
|
||
assert_eq!(dots(&mut app).len(), 4, "both anchors' handles");
|
||
app.session.execute("prefs.set", &json!({"key": "showHandlesMultipleAnchors", "value": false})).unwrap();
|
||
assert!(dots(&mut app).is_empty(), "off: none for two anchors");
|
||
select(&mut app, json!([[0, 1]]));
|
||
assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them");
|
||
}
|
||
|
||
/// #3219: a selected path made into a guide is deselected when guides are hidden, so the
|
||
/// selection overlay cannot reveal the hidden path.
|
||
#[test]
|
||
fn hide_guides_deselects_selected_guide_paths() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("view.guides.make", json!({})).unwrap();
|
||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(id)]);
|
||
|
||
app.run("view.guides", json!({})).unwrap();
|
||
|
||
assert!(app.session.active().unwrap().selection.objects.is_empty());
|
||
}
|
||
|
||
/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer does not
|
||
/// show with View › Snap to Grid or View › Pixel Preview on, even with Smart Guides and the option on.
|
||
/// #3219: guide paths hidden by View > Hide Guides are not hittable and cannot reveal
|
||
/// themselves through Smart Guides object highlighting.
|
||
#[test]
|
||
fn hidden_guide_paths_have_no_hover_highlight() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("view.guides.make", json!({})).unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
app.ui.view.guides = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(100.0, 150.0)))]);
|
||
assert!(drawn_in(&mut app, &ctx, color, 1.5).is_empty(), "a hidden guide has no hover highlight");
|
||
}
|
||
|
||
#[test]
|
||
fn snap_to_grid_and_pixel_preview_hide_the_hover_outline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
|
||
let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))));
|
||
let outlines = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count()
|
||
};
|
||
assert!(app.ui.view.smart_guides && app.session.prefs.object_highlighting);
|
||
assert!(outlines(&mut app) > 0, "highlighted with Snap to Grid and Pixel Preview off");
|
||
app.run("view.snapToGrid", json!({})).unwrap();
|
||
assert!(app.ui.view.snap_to_grid);
|
||
assert_eq!(outlines(&mut app), 0, "Snap to Grid on");
|
||
app.run("view.snapToGrid", json!({})).unwrap();
|
||
assert!(outlines(&mut app) > 0, "Snap to Grid off again");
|
||
app.run("view.pixelPreview", json!({})).unwrap();
|
||
assert!(app.ui.view.pixel_preview);
|
||
assert_eq!(outlines(&mut app), 0, "Pixel Preview on");
|
||
app.run("view.pixelPreview", json!({})).unwrap();
|
||
assert!(outlines(&mut app) > 0, "Pixel Preview off again");
|
||
}
|
||
|
||
/// #2452: while the view scrolls under a resting pointer, the art under it isn't hit-tested:
|
||
/// no hover outline, and the tool's last cursor stays. Once the view has been still for
|
||
/// [`HOVER_STILL_SECS`], the outline comes back without the pointer moving.
|
||
#[test]
|
||
fn a_moving_view_leaves_the_art_under_the_pointer_unhighlighted_until_it_stops() {
|
||
fn flat(s: &Shape, out: &mut Vec<Shape>) {
|
||
match s {
|
||
Shape::Vec(v) => v.iter().for_each(|s| flat(s, out)),
|
||
s => out.push(s.clone()),
|
||
}
|
||
}
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))));
|
||
let ctx = egui::Context::default();
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut time = 0.0;
|
||
let mut outlines = |app: &mut VectorcraftApp, events: Vec<egui::Event>, dt: f64| {
|
||
time += dt;
|
||
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
let mut shapes = vec![];
|
||
out.shapes.iter().for_each(|c| flat(&c.shape, &mut shapes));
|
||
shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count()
|
||
};
|
||
outlines(&mut app, vec![], 0.0);
|
||
let at = app.canvas_rect.unwrap().center();
|
||
assert!(outlines(&mut app, vec![egui::Event::PointerMoved(at)], 0.016) > 0, "the art under the resting pointer is highlighted");
|
||
let wheel = |dy: f32| egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, dy),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: Default::default(),
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
for k in 0..12 {
|
||
let before = app.view().unwrap().center;
|
||
let shown = outlines(&mut app, vec![wheel(if k % 2 == 0 { 4.0 } else { -4.0 })], 0.016);
|
||
assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view");
|
||
assert_eq!(shown, 0, "frame {k}: no hover outline while the view moves");
|
||
}
|
||
assert_eq!(outlines(&mut app, vec![], HOVER_STILL_SECS / 2.0), 0, "the view has only just stopped");
|
||
assert!(outlines(&mut app, vec![], HOVER_STILL_SECS) > 0, "still again: the outline comes back");
|
||
// Selected, the rectangle gives the move cursor near its top edge. Scrolled up past the
|
||
// pointer, it stays the move cursor until the view stops, then the arrow.
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
outlines(&mut app, vec![egui::Event::PointerMoved(at - vec2(0.0, 200.0))], 0.016);
|
||
let cursor = |app: &VectorcraftApp| app.canvas.hover_cursor.map(|(_, _, c)| c);
|
||
assert_eq!(cursor(&app), Some(Cursor::Move));
|
||
for k in 0..12 {
|
||
let before = app.view().unwrap().center;
|
||
outlines(&mut app, vec![wheel(-80.0)], 0.016);
|
||
assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view");
|
||
assert_eq!(cursor(&app), Some(Cursor::Move), "frame {k}: the cursor stays while the view moves");
|
||
}
|
||
// The wheel's smooth scrolling runs on for some frames; then the view rests. A frame of it
|
||
// that scrolls less than half a device pixel leaves the view where it is.
|
||
for _ in 0..200 {
|
||
let before = app.view().unwrap().center;
|
||
outlines(&mut app, vec![], 0.016);
|
||
if app.view().unwrap().center == before && !ctx.input(|i| i.is_scrolling()) {
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(cursor(&app), Some(Cursor::Move), "the view has only just stopped");
|
||
outlines(&mut app, vec![], HOVER_STILL_SECS + 0.01);
|
||
assert_eq!(cursor(&app), Some(Cursor::Arrow), "still again: the pointer at {:?} is past the rectangle", app.hover_doc);
|
||
}
|
||
|
||
/// The wheel, a scroll bar and the Hand tool move the view by whole device pixels at 1.25
|
||
/// device pixels a point, in a zoomed and turned view: every frame's render view is the first
|
||
/// one's panned by whole pixels, which the renderer's cropped effect rasters need to be drawn
|
||
/// again, and the wheel's and the scroll bar's scrolls add up to what they were given within a
|
||
/// device pixel, as does the drag.
|
||
#[test]
|
||
fn scrolls_and_pans_move_the_view_by_whole_device_pixels() {
|
||
const PPP: f32 = 1.25;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut time = 0.0;
|
||
let mut frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||
time += 0.016;
|
||
let mut input = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() };
|
||
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(PPP);
|
||
ctx.run_ui(input, |ui| show(app, ui)).textures_delta.clear();
|
||
};
|
||
frame(&mut app, vec![]);
|
||
{
|
||
let v = app.view_mut().unwrap();
|
||
v.zoom = 4.0;
|
||
v.rotation = 30.0;
|
||
v.center = Point::new(200.3, 150.7);
|
||
}
|
||
frame(&mut app, vec![]);
|
||
// The pan of the canvas's render view (the translation of the view it renders through, less
|
||
// the half of its size), in device pixels.
|
||
let pan = |app: &VectorcraftApp| {
|
||
let v = app.view().unwrap();
|
||
(Affine::rotate(v.rotation.to_radians()) * Affine::scale(v.zoom * f64::from(PPP)) * Affine::translate(-v.center.to_vec2())).translation()
|
||
};
|
||
let wheel = |d: egui::Vec2| egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: d,
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: Default::default(),
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
let whole = |by: vectorcraft_geom::Vec2| (by - vectorcraft_geom::Vec2::new(by.x.round(), by.y.round())).hypot() < 1e-6;
|
||
let rect = app.canvas_rect.unwrap();
|
||
let track = scroll_tracks(rect, screen)[0].center();
|
||
let deltas = [vec2(0.37, 11.13), vec2(-12.71, 0.0), vec2(0.0, 7.45), vec2(21.9, -3.83), vec2(0.21, 0.21)];
|
||
for (name, at) in [("the wheel", rect.center()), ("the horizontal scroll bar", track)] {
|
||
frame(&mut app, vec![egui::Event::PointerMoved(at)]);
|
||
let start = pan(&app);
|
||
let mut given = vectorcraft_geom::Vec2::ZERO;
|
||
for (k, d) in deltas.iter().enumerate() {
|
||
frame(&mut app, vec![wheel(*d)]);
|
||
// Over the scroll bar, the wheel scrolls along it.
|
||
given += if at == track {
|
||
vectorcraft_geom::Vec2::new(f64::from(d.x + d.y), 0.0)
|
||
} else {
|
||
vectorcraft_geom::Vec2::new(f64::from(d.x), f64::from(d.y))
|
||
};
|
||
let by = pan(&app) - start;
|
||
assert!(whole(by), "{name}, scroll {k}: the view moved {by:?} device pixels");
|
||
}
|
||
// The wheel's smooth scrolling runs on for some frames.
|
||
for k in 0..60 {
|
||
frame(&mut app, vec![]);
|
||
let by = pan(&app) - start;
|
||
assert!(whole(by), "{name}, frame {k} after the scrolls: the view moved {by:?} device pixels");
|
||
}
|
||
let by = pan(&app) - start;
|
||
let want = given * f64::from(PPP);
|
||
assert!(by.hypot() > 10.0, "{name}: the view moved {by:?} device pixels");
|
||
assert!((by.x - want.x).abs() <= 1.0 && (by.y - want.y).abs() <= 1.0, "{name}: moved {by:?} device pixels for {want:?}");
|
||
}
|
||
// The horizontal scroll bar's thumb dragged to points between device pixels: the view moves
|
||
// `ratio` points for each point the thumb moves, the other way.
|
||
let (_, span, from) = scroll_bar(&mut app, 0);
|
||
let ratio = f64::from(span.ratio(scroll_tracks(rect, screen)[0].width()));
|
||
assert!(ratio > 1.0, "at 400% the view moves further than the thumb: {ratio}");
|
||
frame(&mut app, vec![egui::Event::PointerMoved(from)]);
|
||
let start = pan(&app);
|
||
frame(&mut app, vec![primary(from, true)]);
|
||
for (k, dx) in [7.37_f32, 12.81, 3.33].into_iter().enumerate() {
|
||
frame(&mut app, vec![egui::Event::PointerMoved(from + vec2(dx, 0.0))]);
|
||
let by = pan(&app) - start;
|
||
assert!(whole(by), "thumb drag {k}: the view moved {by:?} device pixels");
|
||
let want = -f64::from(dx) * ratio * f64::from(PPP);
|
||
assert!((by.x - want).abs() <= 1.0 && by.y.abs() <= 1.0, "thumb drag {k}: moved {by:?} device pixels for {want}");
|
||
}
|
||
frame(&mut app, vec![primary(from + vec2(3.33, 0.0), false)]);
|
||
// A Hand tool drag to points between device pixels.
|
||
app.select_tool("hand");
|
||
let p0 = rect.center() + vec2(0.3, 0.1);
|
||
frame(&mut app, vec![egui::Event::PointerMoved(p0)]);
|
||
let start = pan(&app);
|
||
frame(&mut app, vec![primary(p0, true)]);
|
||
for (k, d) in [vec2(13.37, -7.61), vec2(40.19, 3.33), vec2(-5.55, 22.71)].into_iter().enumerate() {
|
||
frame(&mut app, vec![egui::Event::PointerMoved(p0 + d)]);
|
||
let by = pan(&app) - start;
|
||
assert!(whole(by), "drag {k}: the view moved {by:?} device pixels");
|
||
let want = vectorcraft_geom::Vec2::new(f64::from(d.x), f64::from(d.y)) * f64::from(PPP);
|
||
assert!((by.x - want.x).abs() <= 1.0 && (by.y - want.y).abs() <= 1.0, "drag {k}: moved {by:?} device pixels for {want:?}");
|
||
}
|
||
frame(&mut app, vec![primary(p0 + vec2(-5.55, 22.71), false)]);
|
||
}
|
||
|
||
/// The part of a device pixel a scroll leaves is added to the next scroll of the same view, and
|
||
/// dropped when the view was zoomed, turned or moved in between, when the next scroll is in
|
||
/// another document, or when the device pixels a point changed.
|
||
#[test]
|
||
fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() {
|
||
let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0 };
|
||
let mut v = view;
|
||
let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default());
|
||
assert_eq!(v.center, view.center, "less than half a pixel does not move the view");
|
||
assert!((rest.x - 0.4).abs() < 1e-6 && (rest.y + 0.3).abs() < 1e-6);
|
||
let mut again = v;
|
||
scroll_by(&mut again, 1, 1.0, vec2(0.4, -0.3), rest);
|
||
assert_eq!(again.center, Point::new(99.0, 101.0), "the second scroll adds the first's part");
|
||
let changed = [
|
||
("zoomed", View { zoom: 2.0, ..v }, 1, 1.0),
|
||
("turned", View { rotation: 90.0, ..v }, 1, 1.0),
|
||
("fitted or moved", View { center: Point::new(150.0, 120.0), ..v }, 1, 1.0),
|
||
("another document", v, 2, 1.0),
|
||
("another display scale", v, 1, 1.25),
|
||
];
|
||
for (name, before, doc, ppp) in changed {
|
||
let mut after = before;
|
||
let left = scroll_by(&mut after, doc, ppp, vec2(0.4 / ppp, -0.3 / ppp), rest);
|
||
assert_eq!(after.center, before.center, "{name}: the first scroll's part is dropped");
|
||
assert!((left.x - 0.4).abs() < 1e-6 && (left.y + 0.3).abs() < 1e-6, "{name}: {left:?}");
|
||
}
|
||
}
|
||
|
||
/// View › Snap to Glyph toggles through the View menu command (#3124).
|
||
#[test]
|
||
fn snap_to_glyph_menu_command_toggles_the_view_flag() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||
assert!(!app.ui.view.snap_to_glyph);
|
||
app.run("view.snapToGlyph", json!({})).unwrap();
|
||
assert!(app.ui.view.snap_to_glyph);
|
||
app.run("view.snapToGlyph", json!({})).unwrap();
|
||
assert!(!app.ui.view.snap_to_glyph);
|
||
}
|
||
|
||
/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer
|
||
/// shows with Smart Guides on and the option on, and not otherwise.
|
||
#[test]
|
||
fn object_highlighting_preference_hides_the_hover_outline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
|
||
let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))));
|
||
// The hover outline: 1.5 px lines in the layer colour.
|
||
let outlines = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count()
|
||
};
|
||
assert!(outlines(&mut app) > 0, "highlighted by default");
|
||
app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": false})).unwrap();
|
||
assert_eq!(outlines(&mut app), 0, "the option off");
|
||
app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": true})).unwrap();
|
||
app.ui.view.smart_guides = false;
|
||
assert_eq!(outlines(&mut app), 0, "Smart Guides off");
|
||
}
|
||
|
||
/// Hide Corner Widget for angles greater than (#394): a rectangle's 90° corners lose their
|
||
/// widgets below 90°.
|
||
#[test]
|
||
fn corner_widgets_hide_above_the_preference_angle() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let widgets = |app: &mut VectorcraftApp| {
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count()
|
||
};
|
||
assert_eq!(widgets(&mut app), 4);
|
||
app.session.execute("prefs.set", &json!({"key": "hideCornerWidgetAbove", "value": 80})).unwrap();
|
||
assert_eq!(widgets(&mut app), 0);
|
||
}
|
||
|
||
/// View › Hide Edges hides a live rectangle's corner widgets, and the Selection tool then
|
||
/// neither shows the corner cursor over one's place nor rounds the corners from it. With
|
||
/// edges shown, a drag from the same widget rounds them.
|
||
#[test]
|
||
fn hidden_corner_widgets_do_not_answer_the_pointer() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
let id = vectorcraft_doc::NodeId(id);
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let widget = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap()
|
||
};
|
||
let radius = |app: &VectorcraftApp| {
|
||
let c = vectorcraft_doc::LiveCorners::of(app.session.active().unwrap().doc.node(id).unwrap()).unwrap();
|
||
c.style(&c.all()).0
|
||
};
|
||
let drag = |app: &mut VectorcraftApp, from: Point| {
|
||
let (view, to) = (app.view_info(), from + vectorcraft_geom::Vec2::new(10.0, 10.0));
|
||
for (kind, at) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||
app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap();
|
||
}
|
||
};
|
||
app.ui.view.edges = false;
|
||
let at = widget(&app);
|
||
let view = app.view_info();
|
||
assert_ne!(app.session.cursor(at, Mods::default(), view), Cursor::CornerRadius);
|
||
drag(&mut app, at);
|
||
assert_eq!(radius(&app), Some(0.0), "a press on a hidden widget does not round the corners");
|
||
app.ui.view.edges = true;
|
||
let at = widget(&app);
|
||
let view = app.view_info();
|
||
assert_eq!(app.session.cursor(at, Mods::default(), view), Cursor::CornerRadius);
|
||
drag(&mut app, at);
|
||
assert!(radius(&app).is_some_and(|r| r > 0.0), "{:?}", radius(&app));
|
||
}
|
||
|
||
/// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection
|
||
/// tool then answers neither: a double-click on the widget's place leaves point type as it is,
|
||
/// and a drag from a corner handle's place does not scale. With edges shown, both answer.
|
||
#[test]
|
||
fn a_hidden_bounding_box_and_type_widget_do_not_answer_the_pointer() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap();
|
||
let id = vectorcraft_doc::NodeId(id);
|
||
app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let bx = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
st.doc.oriented_bounds(&st.selection.objects, app.session.prefs.use_preview_bounds).unwrap()
|
||
};
|
||
let widget = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &bx(app), app.view_info().zoom).unwrap()
|
||
};
|
||
let area = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(id).unwrap().kind {
|
||
NodeKind::Text(t) => matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }),
|
||
k => panic!("not type: {k:?}"),
|
||
};
|
||
let double_click = |app: &mut VectorcraftApp, at: Point| {
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
|
||
app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap();
|
||
}
|
||
};
|
||
let width = |app: &VectorcraftApp| bx(app).rect.width();
|
||
let scale = |app: &mut VectorcraftApp| {
|
||
let b = bx(app);
|
||
let corner = b.to_doc() * Point::new(b.rect.x1, b.rect.y1);
|
||
let (view, to) = (app.view_info(), corner + vectorcraft_geom::Vec2::new(30.0, 0.0));
|
||
for (kind, at) in [(PointerKind::Down, corner), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||
app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap();
|
||
}
|
||
app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
};
|
||
let w = widget(&app);
|
||
app.ui.view.edges = false;
|
||
let view = app.view_info();
|
||
assert_ne!(app.session.cursor(w.at, Mods::default(), view), Cursor::TypeWidget);
|
||
double_click(&mut app, w.at);
|
||
assert!(!area(&app), "a double-click on the hidden widget leaves point type");
|
||
app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
let before = width(&app);
|
||
scale(&mut app);
|
||
assert!((width(&app) - before).abs() < 1e-6, "a drag from a hidden handle does not scale: {before} -> {}", width(&app));
|
||
app.ui.view.edges = true;
|
||
let w = widget(&app);
|
||
let view = app.view_info();
|
||
assert_eq!(app.session.cursor(w.at, Mods::default(), view), Cursor::TypeWidget);
|
||
scale(&mut app);
|
||
assert!(width(&app) > before + 1.0, "a drag from the handle scales: {before} -> {}", width(&app));
|
||
let w = widget(&app);
|
||
double_click(&mut app, w.at);
|
||
assert!(area(&app), "a double-click on the widget converts to area type");
|
||
}
|
||
|
||
/// View → Show Live Paint Gaps draws the gaps of each selected Live Paint group in the group's
|
||
/// Gap Preview Color, and nothing for a group or path that is not a selected Live Paint group;
|
||
/// Hide Live Paint Gaps and Hide Edges hide them.
|
||
#[test]
|
||
fn show_live_paint_gaps_draws_the_selected_groups_gaps_in_their_preview_color() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// Open paths round a square, each ending 2 pt short of its start: one Small Gap each.
|
||
let square = |app: &mut VectorcraftApp, x: i64| {
|
||
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(dx, y)| json!({"x": x + dx, "y": y}));
|
||
app.session.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap()
|
||
};
|
||
let mut groups = vec![];
|
||
for (x, color) in [(0, "#123456"), (150, "#654321")] {
|
||
let p = square(&mut app, x);
|
||
app.session.execute("select.set", &json!({"ids": [p]})).unwrap();
|
||
let g = app.session.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("livePaint.gapOptions", &json!({"id": g, "color": color})).unwrap();
|
||
groups.push(g);
|
||
}
|
||
let plain = square(&mut app, 300);
|
||
let ctx = egui::Context::default();
|
||
let gaps = |app: &mut VectorcraftApp, rgb: [u8; 3]| {
|
||
let solid = egui::epaint::ColorMode::Solid(c32(rgb));
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == 2.0)).count()
|
||
};
|
||
let (first, second) = ([0x12, 0x34, 0x56], [0x65, 0x43, 0x21]);
|
||
// The color the plain path's gaps would be drawn in.
|
||
let plain_color = vectorcraft_doc::GapOptions::default().color;
|
||
app.session.execute("select.set", &json!({"ids": [groups[0], plain]})).unwrap();
|
||
assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 0), "off until the item is chosen");
|
||
// View → Show Live Paint Gaps runs the command.
|
||
let tree = crate::menus::menu_tree();
|
||
let (_, view) = tree.iter().find(|(t, _)| *t == "View").unwrap();
|
||
assert!(view.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Show Live Paint Gaps", "view.livePaintGaps", _))), "{view:?}");
|
||
crate::menus::invoke(&mut app, "view.livePaintGaps", json!({}));
|
||
assert!(app.ui.view.live_paint_gaps);
|
||
assert_eq!(
|
||
(gaps(&mut app, first), gaps(&mut app, second), gaps(&mut app, plain_color)),
|
||
(1, 0, 0),
|
||
"the selected group only; the plain path shows none"
|
||
);
|
||
app.session.execute("select.set", &json!({"ids": [groups[1]]})).unwrap();
|
||
assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 1));
|
||
// The lines follow the group's Gap Preview Color and Paint Stops At size.
|
||
app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "color": "#123456"})).unwrap();
|
||
assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (1, 0));
|
||
app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": 1})).unwrap();
|
||
assert_eq!(gaps(&mut app, first), 0, "a 2 pt gap is wider than 1 pt");
|
||
app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": "small"})).unwrap();
|
||
app.ui.view.edges = false;
|
||
assert_eq!(gaps(&mut app, first), 0, "Hide Edges hides them");
|
||
app.ui.view.edges = true;
|
||
assert_eq!(gaps(&mut app, first), 1);
|
||
app.run("view.livePaintGaps", json!({})).unwrap();
|
||
assert!(!app.ui.view.live_paint_gaps);
|
||
assert_eq!(gaps(&mut app, first), 0, "View → Hide Live Paint Gaps");
|
||
// The setting is kept with the interface state; a state saved before it existed loads with it off.
|
||
app.ui.view.live_paint_gaps = true;
|
||
let mut saved = serde_json::to_value(&app.ui).unwrap();
|
||
assert_eq!(saved["view"]["live_paint_gaps"], json!(true));
|
||
saved["view"].as_object_mut().unwrap().remove("live_paint_gaps");
|
||
assert!(!serde_json::from_value::<crate::UiState>(saved).unwrap().view.live_paint_gaps);
|
||
}
|
||
|
||
/// #511: a star shows a widget in each of its ten corners with Direct Selection (none with
|
||
/// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them.
|
||
#[test]
|
||
fn a_star_shows_corner_widgets_with_direct_selection() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.star", &json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let widgets = |app: &mut VectorcraftApp| {
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count()
|
||
};
|
||
app.select_tool("selection");
|
||
assert_eq!(widgets(&mut app), 0);
|
||
app.select_tool("directSelection");
|
||
assert_eq!(widgets(&mut app), 10);
|
||
app.run("view.cornerWidget", json!({})).unwrap();
|
||
assert_eq!(widgets(&mut app), 0);
|
||
}
|
||
|
||
/// The type widget beside selected type's bounding box: hollow (white) on point type, filled
|
||
/// in the layer colour on area type.
|
||
#[test]
|
||
fn the_type_widget_shows_which_kind_the_type_is() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
let layer = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)));
|
||
let ctx = egui::Context::default();
|
||
let widget = |app: &mut VectorcraftApp| -> Vec<Color32> {
|
||
let r = vectorcraft_tools::typewidget::RADIUS_PX;
|
||
shapes(app, &ctx)
|
||
.iter()
|
||
.filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill != Color32::TRANSPARENT).then_some(c.fill) } else { None })
|
||
.collect()
|
||
};
|
||
assert_eq!(widget(&mut app), vec![Color32::WHITE]);
|
||
app.session.execute("type.convertToAreaType", &json!({})).unwrap();
|
||
assert_eq!(widget(&mut app), vec![layer]);
|
||
app.ui.view.bounding_box = false;
|
||
assert!(widget(&mut app).is_empty(), "no bounding box, no widget");
|
||
}
|
||
|
||
/// A selected live ellipse shows its two pie widgets with the Selection tool while the
|
||
/// bounding box shows.
|
||
#[test]
|
||
fn a_selected_ellipse_shows_its_pie_widgets() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap();
|
||
app.session.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let widgets = |app: &mut VectorcraftApp| {
|
||
let r = vectorcraft_tools::pie::RADIUS_PX;
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == r && c.fill == Color32::WHITE)).count()
|
||
};
|
||
app.select_tool("selection");
|
||
assert_eq!(widgets(&mut app), 2);
|
||
app.select_tool("directSelection");
|
||
assert_eq!(widgets(&mut app), 0);
|
||
app.select_tool("selection");
|
||
app.run("view.boundingBox", json!({})).unwrap();
|
||
assert_eq!(widgets(&mut app), 0, "no bounding box, no widgets");
|
||
}
|
||
|
||
/// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`.
|
||
fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec<Shape> {
|
||
let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| hidden_chars_overlay(app, ui.painter(), xf));
|
||
out.textures_delta.clear();
|
||
out.shapes.into_iter().map(|c| c.shape).collect()
|
||
}
|
||
|
||
/// Show Hidden Characters lays out type once: a frame keeps the marks of the frame before, and
|
||
/// after an edit only the object edited is laid out again.
|
||
#[test]
|
||
fn show_hidden_characters_lays_out_only_type_that_changed() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ids: Vec<_> = (0..3)
|
||
.map(|i| app.session.execute("text.create", &json!({"x": 20, "y": 40 + 80 * i, "text": "a b\nc d"})).unwrap()["id"].as_u64().unwrap())
|
||
.map(vectorcraft_doc::NodeId)
|
||
.collect();
|
||
let ctx = egui::Context::default();
|
||
let xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 };
|
||
// `frame` draws one frame and returns each object's marks and whether that frame found the
|
||
// fonts and hyphenation exceptions of the frame before.
|
||
let frame = |app: &mut VectorcraftApp| {
|
||
let inputs = app.canvas.hidden_chars.inputs.clone();
|
||
hidden_chars(app, &ctx, &xf);
|
||
let kept = &app.canvas.hidden_chars;
|
||
(kept.inputs == inputs, ids.iter().map(|id| kept.marks[id].1.clone()).collect::<Vec<_>>())
|
||
};
|
||
// `frames` draws a frame, runs `edit` and draws the next frame. Tests running alongside add
|
||
// fonts and set hyphenation exceptions, which lays all type out again: `frames` draws both
|
||
// frames again until neither the fonts nor the exceptions changed in between.
|
||
let frames = |app: &mut VectorcraftApp, edit: &dyn Fn(&mut VectorcraftApp)| {
|
||
(0..100)
|
||
.find_map(|_| {
|
||
let (_, before) = frame(app);
|
||
edit(app);
|
||
let (same, after) = frame(app);
|
||
same.then_some((before, after))
|
||
})
|
||
.unwrap()
|
||
};
|
||
let same = |a: &std::sync::Arc<HiddenMarks>, b: &std::sync::Arc<HiddenMarks>| std::sync::Arc::ptr_eq(a, b);
|
||
let (before, after) = frames(&mut app, &|_| {});
|
||
assert!(before.iter().zip(&after).all(|(a, b)| same(a, b)), "a frame lays out no type");
|
||
let (before, after) = frames(&mut app, &|app| {
|
||
app.session.execute("text.setText", &json!({"id": ids[1].0, "text": "a b c"})).unwrap();
|
||
});
|
||
assert!(!same(&before[1], &after[1]), "the edited object is laid out again");
|
||
assert!(same(&before[0], &after[0]) && same(&before[2], &after[2]), "the others are not");
|
||
}
|
||
|
||
/// The marks Show Hidden Characters keeps follow the type: after an edit, a zoom and a font
|
||
/// that arrives, a frame draws what a new overlay draws, and the marks moved.
|
||
#[test]
|
||
fn show_hidden_characters_follows_edits_zooms_and_fonts() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// The type is set in Bold of a family that is not installed and whose name starts with a
|
||
// dot; the font menus do not list such names. Source Sans 3 Bold stands in for it until the
|
||
// family arrives below with a Regular face only.
|
||
let text = json!({"x": 20, "y": 60, "text": "one two\nthree four", "font": ".Hidden Marks", "style": "Bold"});
|
||
let id = app.session.execute("text.create", &text).unwrap()["id"].clone();
|
||
let ctx = egui::Context::default();
|
||
let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 };
|
||
// `drawn` draws a frame with what the overlay kept and checks it against a new overlay.
|
||
let drawn = |app: &mut VectorcraftApp, xf: &Xf| {
|
||
let kept = hidden_chars(app, &ctx, xf);
|
||
app.canvas.hidden_chars = Default::default();
|
||
assert_eq!(hidden_chars(app, &ctx, xf), kept, "as a new overlay draws them");
|
||
kept
|
||
};
|
||
let start = drawn(&mut app, &xf);
|
||
assert!(start.len() > 2, "{start:?}");
|
||
app.session.execute("text.setText", &json!({"id": id, "text": "one two three\nfour"})).unwrap();
|
||
let edited = drawn(&mut app, &xf);
|
||
assert_ne!(edited, start, "the edit moved the marks");
|
||
xf.zoom = 2.0;
|
||
let zoomed = drawn(&mut app, &xf);
|
||
assert_ne!(zoomed, edited, "zoomed in");
|
||
assert!(vectorcraft_text::FontDb::global().add_font(vectorcraft_testkit::fonts::renamed(".Hidden Marks")) > 0, "the family is new");
|
||
assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived");
|
||
}
|
||
|
||
/// In a rotated view Show Hidden Characters draws the marks of type whose turned outline reaches
|
||
/// into the canvas, also when the box between two of its corners on screen does not.
|
||
#[test]
|
||
fn show_hidden_characters_draws_type_partly_on_a_rotated_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "a b c d e\nf g h"})).unwrap()["id"].as_u64().unwrap();
|
||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
let ctx = egui::Context::default();
|
||
let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 2.0, center: b.center(), rot: 45f64.to_radians() };
|
||
// The screen moves so that the canvas's left edge falls halfway between the right side of
|
||
// the box and the outline's rightmost corner, level with that corner.
|
||
let q = xf.quad(b);
|
||
let right = q.iter().copied().max_by(|a, c| a.x.total_cmp(&c.x)).unwrap();
|
||
let shift = vec2(-(xf.rect_to_screen(b).right() + right.x) / 2.0, xf.rect.center().y - right.y);
|
||
xf.center -= xf.delta_to_doc(shift);
|
||
assert!(!xf.rect_to_screen(b).intersects(xf.rect), "the box is off the canvas");
|
||
assert!(xf.quad(b).iter().any(|c| xf.rect.contains(*c)), "a corner is on it");
|
||
assert!(!hidden_chars(&mut app, &ctx, &xf).is_empty(), "the marks are drawn");
|
||
}
|
||
|
||
/// The shapes Type → Show Hidden Characters draws in one canvas frame ([`show`]).
|
||
fn hidden_char_marks(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
|
||
let blue = Color32::from_rgb(0x4f, 0x9d, 0xff);
|
||
shapes(app, ctx)
|
||
.into_iter()
|
||
.filter(|s| matches!(s, Shape::Circle(c) if c.fill == blue) || matches!(s, Shape::Text(t) if t.fallback_color == blue))
|
||
.collect()
|
||
}
|
||
|
||
/// Show Hidden Characters puts a dot over each space of a list paragraph and none over its
|
||
/// bullet, which stands for no character.
|
||
#[test]
|
||
fn hidden_characters_mark_no_space_at_a_bullet() {
|
||
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, " Item two", vectorcraft_doc::CharStyle::default());
|
||
let plain = HiddenMarks::of(&t).spaces.len();
|
||
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
|
||
assert_eq!((plain, HiddenMarks::of(&t).spaces.len()), (2, 2));
|
||
}
|
||
|
||
/// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before.
|
||
/// Turning it off drops the marks with their text nodes, and turning it on again draws the
|
||
/// same marks.
|
||
#[test]
|
||
fn show_hidden_characters_off_releases_the_text_nodes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let text = json!({"x": 20, "y": 60, "text": "one two\nthree four"});
|
||
let id = vectorcraft_doc::NodeId(app.session.execute("text.create", &text).unwrap()["id"].as_u64().unwrap());
|
||
let ctx = egui::Context::default();
|
||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||
let on = hidden_char_marks(&mut app, &ctx);
|
||
assert!(on.len() > 2, "{on:?}");
|
||
// Tests running alongside add fonts and set hyphenation exceptions, which lays all type out
|
||
// again: frames are drawn until neither changed between two of them.
|
||
let kept = (0..100).any(|_| {
|
||
let (inputs, marks) = (app.canvas.hidden_chars.inputs.clone(), app.canvas.hidden_chars.marks[&id].1.clone());
|
||
hidden_char_marks(&mut app, &ctx);
|
||
let after = &app.canvas.hidden_chars;
|
||
after.inputs == inputs && std::sync::Arc::ptr_eq(&after.marks[&id].1, &marks)
|
||
});
|
||
assert!(kept, "a frame keeps the marks of the frame before");
|
||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "off");
|
||
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node is kept");
|
||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||
assert_eq!(hidden_char_marks(&mut app, &ctx), on, "on again: the same marks");
|
||
}
|
||
|
||
/// Closing the document drops the marks Show Hidden Characters kept with its text nodes, and
|
||
/// the same type in a new document shows the same marks.
|
||
#[test]
|
||
fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||
let open = |app: &mut VectorcraftApp| {
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "one two\nthree four"})).unwrap();
|
||
hidden_char_marks(app, &ctx)
|
||
};
|
||
let first = open(&mut app);
|
||
assert!(first.len() > 2, "{first:?}");
|
||
assert_eq!(app.canvas.hidden_chars.marks.len(), 1, "the marks are kept with the text object's node");
|
||
app.session.execute("file.close", &json!({})).unwrap();
|
||
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "the Home screen");
|
||
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node of the closed document is kept");
|
||
assert_eq!(open(&mut app), first, "the same type in a new document");
|
||
}
|
||
|
||
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
|
||
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
|
||
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
|
||
/// trackpad pinch zoom about the pointer either way.
|
||
#[test]
|
||
fn zoom_with_mouse_wheel_preference() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// A canvas frame through the host's input hook (it feeds the control channel's input).
|
||
let run = |app: &mut VectorcraftApp| {
|
||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
app.raw_input_hook(&ctx, &mut raw);
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
run(&mut app);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() + vec2(60.0, 40.0);
|
||
// One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how
|
||
// far the point under `at` moved).
|
||
let turn = |app: &mut VectorcraftApp, mods: &str| {
|
||
let before = *app.view().unwrap();
|
||
if mods == "pinch" {
|
||
app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]);
|
||
} else {
|
||
let mut p = json!({"x": at.x, "y": at.y, "dy": 1});
|
||
for m in mods.split('+').filter(|m| !m.is_empty()) {
|
||
p[m] = json!(true);
|
||
}
|
||
crate::tests_synthetic::control(app, &ctx, "ui.wheel", p);
|
||
}
|
||
// egui spreads a notch over a few frames.
|
||
for _ in 0..40 {
|
||
run(app);
|
||
}
|
||
let after = *app.view().unwrap();
|
||
let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at));
|
||
(after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved)
|
||
};
|
||
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6;
|
||
let (zoom, scroll, _) = turn(&mut app, "");
|
||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})");
|
||
assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer");
|
||
assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer");
|
||
assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer");
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||
assert!(zooms_in(turn(&mut app, "")), "on: the wheel zooms in about the pointer");
|
||
assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel");
|
||
assert!(zooms_in(turn(&mut app, "pinch")), "on: and a pinch");
|
||
let (zoom, scroll, _) = turn(&mut app, "shift");
|
||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: Shift-wheel scrolls up ({zoom}, {scroll:?})");
|
||
let (zoom, scroll, _) = turn(&mut app, "cmd");
|
||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
|
||
}
|
||
|
||
/// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a
|
||
/// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom.
|
||
#[test]
|
||
fn wheel_notches_zoom_in_a_glide() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let run = |app: &mut VectorcraftApp| {
|
||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
app.raw_input_hook(&ctx, &mut raw);
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
run(&mut app);
|
||
let at = app.canvas_rect.unwrap().center();
|
||
let before = app.view().unwrap().zoom;
|
||
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1}));
|
||
// The first frame the zoom moves in (the event may land a frame later).
|
||
let mut first = 1.0;
|
||
for _ in 0..3 {
|
||
run(&mut app);
|
||
first = app.view().unwrap().zoom / before;
|
||
if first != 1.0 {
|
||
break;
|
||
}
|
||
}
|
||
for _ in 0..60 {
|
||
run(&mut app);
|
||
}
|
||
let done = app.view().unwrap().zoom / before;
|
||
assert!(done > 1.01, "the notch zooms in ({done})");
|
||
assert!(first > 1.0 && first < done, "the first frame takes only part of it ({first} of {done})");
|
||
run(&mut app);
|
||
assert_eq!(app.view().unwrap().zoom / before, done, "and it stops");
|
||
}
|
||
|
||
/// #4283: with Performance › Animated Zoom off, or on without GPU Performance (which it needs), a
|
||
/// wheel notch zooms the whole step on the frame it arrives, both the plain wheel with Zoom with
|
||
/// Mouse Wheel and Alt-wheel without it.
|
||
#[test]
|
||
fn wheel_notches_zoom_at_once_without_animated_zoom() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let run = |app: &mut VectorcraftApp| {
|
||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
app.raw_input_hook(&ctx, &mut raw);
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
run(&mut app);
|
||
let at = app.canvas_rect.unwrap().center();
|
||
for (animated, gpu, wheel_zooms, alt) in
|
||
[(false, true, true, false), (false, true, false, true), (true, false, true, false), (true, false, false, true)]
|
||
{
|
||
app.session.execute("prefs.set", &json!({"key": "animatedZoom", "value": animated})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "gpuPerformance", "value": gpu})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap();
|
||
// Any glide or spread scroll a case before left behind dies down first.
|
||
for _ in 0..30 {
|
||
run(&mut app);
|
||
}
|
||
let before = app.view().unwrap().zoom;
|
||
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "alt": alt}));
|
||
// One line notch's whole zoom.
|
||
let expected = ctx.options(|o| f64::from(o.input_options.line_scroll_speed) * 0.01).exp();
|
||
// The first frame the zoom moves (the event may land a frame later).
|
||
let mut first = 1.0;
|
||
for _ in 0..3 {
|
||
run(&mut app);
|
||
first = app.view().unwrap().zoom / before;
|
||
if first != 1.0 {
|
||
break;
|
||
}
|
||
}
|
||
assert!(
|
||
(first - expected).abs() < 1e-9,
|
||
"the whole notch arrives at once (animated {animated}, gpu {gpu}, wheel zooms {wheel_zooms}, alt {alt}: expected {expected}, got {first})"
|
||
);
|
||
for _ in 0..30 {
|
||
run(&mut app);
|
||
}
|
||
let done = app.view().unwrap().zoom / before;
|
||
assert!(
|
||
(done - first).abs() < 1e-9,
|
||
"all of it on the first frame (animated {animated}, gpu {gpu}, wheel zooms {wheel_zooms}, alt {alt}: {first} then {done})"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// In each frame where a wheel scroll moves the view, the hover point (read by the rulers'
|
||
/// pointer marks, the Info panel, the hover highlight and the tool cursor) is the document point
|
||
/// under the pointer in the view the canvas draws in that frame.
|
||
#[test]
|
||
fn the_hover_point_follows_a_wheel_scroll_in_the_frame_it_moves() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() + vec2(60.0, 40.0);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at);
|
||
let wheel = egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, -60.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: Default::default(),
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
let mut events = vec![wheel];
|
||
let mut moved = 0;
|
||
for k in 0..30 {
|
||
let before = app.view().unwrap().center;
|
||
frame(&mut app, &ctx, std::mem::take(&mut events));
|
||
moved += usize::from(app.view().unwrap().center != before);
|
||
let hover = app.hover_doc.unwrap();
|
||
assert!(hover.distance(under(&app)) < 1e-9, "frame {k}: {hover:?}, under the pointer {:?}", under(&app));
|
||
}
|
||
assert!(moved > 0, "the wheel scrolls the view");
|
||
}
|
||
|
||
/// While a middle-button pan or a Rotate View drag moves the view, the hover point is the
|
||
/// document point under the pointer in the view the canvas draws in that frame.
|
||
#[test]
|
||
fn the_hover_point_follows_a_view_drag_in_the_frame_it_moves() {
|
||
for (tool, button) in [("rectangle", egui::PointerButton::Middle), ("rotateView", egui::PointerButton::Primary)] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool(tool);
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let press = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() };
|
||
let a = rect.center() + vec2(100.0, 20.0);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]);
|
||
frame(&mut app, &ctx, vec![press(a, true)]);
|
||
let mut p = a;
|
||
for k in 1..=3 {
|
||
p = a + vec2(-20.0, 30.0) * k as f32;
|
||
let before = *app.view().unwrap();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||
let after = *app.view().unwrap();
|
||
assert!(after.center != before.center || after.rotation != before.rotation, "{tool}: the drag moves the view");
|
||
let under = Xf::new(rect, &after).to_doc(p);
|
||
let hover = app.hover_doc.unwrap();
|
||
assert!(hover.distance(under) < 1e-9, "{tool}, step {k}: {hover:?}, under the pointer {under:?}");
|
||
}
|
||
frame(&mut app, &ctx, vec![press(p, false)]);
|
||
}
|
||
}
|
||
|
||
/// A tool drag goes on at the document point under the pointer in the view a wheel scroll moved
|
||
/// to, in the frame the wheel scrolls.
|
||
#[test]
|
||
fn a_tool_drag_follows_a_wheel_scroll_in_the_frame_it_moves() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// With Zoom with Mouse Wheel on, Shift-wheel scrolls up and down in the frame it comes in.
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||
app.ui.view.smart_guides = false;
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let (a, b) = (rect.center() - vec2(80.0, 60.0), rect.center() + vec2(40.0, 30.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]);
|
||
frame(&mut app, &ctx, vec![primary(a, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]);
|
||
let before = *app.view().unwrap();
|
||
let wheel = egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, -40.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: egui::Modifiers::SHIFT,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
frame(&mut app, &ctx, vec![wheel]);
|
||
let after = *app.view().unwrap();
|
||
assert!(after.center != before.center, "Shift-wheel scrolls the view");
|
||
let want = Xf::new(rect, &after).to_doc(b);
|
||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap();
|
||
let r = bounds(&app);
|
||
assert!((r.x1 - want.x).abs() < 1e-6 && ((r.y0 - want.y).abs() < 1e-6 || (r.y1 - want.y).abs() < 1e-6), "{r:?} reaches {want:?}");
|
||
frame(&mut app, &ctx, vec![primary(b, false)]);
|
||
assert_eq!(bounds(&app), r, "the rectangle drawn");
|
||
}
|
||
|
||
/// Two fingers dragged together on a touch screen pan the canvas with them, under either
|
||
/// Zoom with Mouse Wheel setting, and draw nothing (#449).
|
||
#[test]
|
||
fn two_fingers_dragged_together_pan_the_canvas() {
|
||
for wheel_zooms in [false, true] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||
let fingers = |phase, d: egui::Vec2| vec![touch(1, phase, c - vec2(40.0, 0.0) + d), touch(2, phase, c + vec2(40.0, 0.0) + d)];
|
||
let before = *app.view().unwrap();
|
||
// egui-winit moves the pointer with the first finger, as here.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]);
|
||
frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, Vec2::ZERO));
|
||
for k in 1..=3 {
|
||
frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, vec2(10.0, 8.0) * k as f32));
|
||
}
|
||
frame(&mut app, &ctx, fingers(egui::TouchPhase::End, vec2(30.0, 24.0)));
|
||
let after = *app.view().unwrap();
|
||
let moved = (after.center - before.center) * after.zoom;
|
||
assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "no zoom: {wheel_zooms}");
|
||
assert!((moved.x + 30.0).abs() < 0.5 && (moved.y + 24.0).abs() < 0.5, "the content follows the fingers ({wheel_zooms}): {moved:?}");
|
||
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 0, "nothing drawn");
|
||
}
|
||
}
|
||
|
||
/// #4198: egui-winit presses with the first finger as the pointer, in the frame the second lands
|
||
/// or a frame or more before (the Pencil has begun drawing, the Selection tool moving the art
|
||
/// under it). The second finger cancels that press, as it does the press of a finger lifted and
|
||
/// landed again mid-pinch: the pinch zooms, nothing is drawn or moved, nothing enters the undo
|
||
/// history, and lifting the fingers commits nothing. One finger still draws afterwards.
|
||
#[test]
|
||
fn a_second_finger_cancels_the_tool_gesture_the_first_began() {
|
||
for (tool, together) in [("pencil", false), ("selection", false), ("pencil", true), ("selection", true)] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap();
|
||
app.select_tool(tool);
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let (doc, undo, zoom) = {
|
||
let st = app.session.active().unwrap();
|
||
(st.doc.clone(), st.history.undo.len(), app.view().unwrap().zoom)
|
||
};
|
||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let press = |n: u64, pos| vec![touch(n, egui::TouchPhase::Start, pos), egui::Event::PointerMoved(pos), button(pos, true)];
|
||
let lift = |n: u64, pos| vec![touch(n, egui::TouchPhase::End, pos), button(pos, false), egui::Event::PointerGone];
|
||
let apart = |p: Pos2, q: Pos2| vec![touch(1, egui::TouchPhase::Move, p), egui::Event::PointerMoved(p), touch(2, egui::TouchPhase::Move, q)];
|
||
// The first finger presses on the rectangle, and drags a little on its own unless the
|
||
// second lands in the same frame.
|
||
let mut p = xf.to_screen(Point::new(100.0, 100.0));
|
||
let mut q = p + vec2(80.0, 0.0);
|
||
if together {
|
||
frame(&mut app, &ctx, [press(1, p), vec![touch(2, egui::TouchPhase::Start, q)]].concat());
|
||
} else {
|
||
frame(&mut app, &ctx, press(1, p));
|
||
for _ in 0..2 {
|
||
p += vec2(12.0, 6.0);
|
||
frame(&mut app, &ctx, vec![touch(1, egui::TouchPhase::Move, p), egui::Event::PointerMoved(p)]);
|
||
}
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Tool | Drag::Art)), "{tool}: the tool's drag began");
|
||
q = p + vec2(80.0, 0.0);
|
||
frame(&mut app, &ctx, vec![touch(2, egui::TouchPhase::Start, q)]);
|
||
}
|
||
// The two pinch apart; the first lifts, the second moves alone (egui-winit's pointer
|
||
// now), and the first lands again and pinches on.
|
||
for _ in 0..3 {
|
||
(p, q) = (p - vec2(15.0, 0.0), q + vec2(15.0, 0.0));
|
||
frame(&mut app, &ctx, apart(p, q));
|
||
}
|
||
frame(&mut app, &ctx, lift(1, p));
|
||
q += vec2(10.0, 0.0);
|
||
frame(&mut app, &ctx, vec![touch(2, egui::TouchPhase::Move, q), egui::Event::PointerMoved(q)]);
|
||
frame(&mut app, &ctx, press(1, p));
|
||
for _ in 0..3 {
|
||
(p, q) = (p - vec2(10.0, 5.0), q + vec2(10.0, 5.0));
|
||
frame(&mut app, &ctx, apart(p, q));
|
||
}
|
||
frame(&mut app, &ctx, lift(1, p));
|
||
frame(&mut app, &ctx, lift(2, q));
|
||
frame(&mut app, &ctx, vec![]);
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.interaction.is_none(), "{tool}: no tool interaction is left");
|
||
assert_eq!(*st.doc, *doc, "{tool}: nothing drawn or moved");
|
||
assert_eq!(st.history.undo.len(), undo, "{tool}: no undo entry");
|
||
assert!(app.view().unwrap().zoom > zoom * 1.2, "{tool}: the pinch zoomed in");
|
||
if tool == "pencil" {
|
||
// One finger alone still draws.
|
||
let mut p = app.canvas_rect.unwrap().center() - vec2(30.0, 20.0);
|
||
frame(&mut app, &ctx, press(3, p));
|
||
for _ in 0..4 {
|
||
p += vec2(15.0, 10.0);
|
||
frame(&mut app, &ctx, vec![touch(3, egui::TouchPhase::Move, p), egui::Event::PointerMoved(p)]);
|
||
}
|
||
frame(&mut app, &ctx, lift(3, p));
|
||
let children = app.session.active().unwrap().doc.layers[0].children().unwrap().len();
|
||
assert_eq!(children, 2, "one finger draws with the Pencil");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A canvas frame at input time `t` (seconds) with `events`, for the trackpad rotation tests.
|
||
fn timed_frame(app: &mut VectorcraftApp, ctx: &egui::Context, t: f64, events: Vec<egui::Event>) {
|
||
let raw =
|
||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(t), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
/// General › Trackpad Gesture to Rotate View, on by default: a two-finger rotation on a Mac
|
||
/// trackpad (egui's `Event::Rotate`, clockwise radians) turns the view about the pointer, also
|
||
/// in the frame of a pinch, which zooms about the same point. With the preference off, the view
|
||
/// does not turn.
|
||
#[test]
|
||
fn a_trackpad_rotation_turns_the_view_about_the_pointer() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
timed_frame(&mut app, &ctx, 0.0, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() + vec2(60.0, 40.0);
|
||
timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at)]);
|
||
let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at);
|
||
let before = (*app.view().unwrap(), under(&app));
|
||
timed_frame(&mut app, &ctx, 0.2, vec![egui::Event::Rotate(0.25)]);
|
||
let after = *app.view().unwrap();
|
||
assert!((after.rotation - before.0.rotation - 0.25f64.to_degrees()).abs() < 1e-4, "turned by 14.32°: {}", after.rotation);
|
||
assert!((after.zoom - before.0.zoom).abs() < 1e-12, "no zoom");
|
||
assert!(under(&app).distance(before.1) < 1e-6, "the point under the pointer stays: {:?}", under(&app));
|
||
// A pinch and a rotation in one frame, in a new gesture.
|
||
let before = (*app.view().unwrap(), under(&app));
|
||
timed_frame(&mut app, &ctx, 1.0, vec![egui::Event::Zoom(1.25), egui::Event::Rotate(0.25)]);
|
||
let after = *app.view().unwrap();
|
||
assert!((after.zoom / before.0.zoom - 1.25).abs() < 1e-6, "the pinch zooms: {}", after.zoom / before.0.zoom);
|
||
assert!((after.rotation - before.0.rotation - 0.25f64.to_degrees()).abs() < 1e-4, "and the rotation turns: {}", after.rotation);
|
||
assert!(under(&app).distance(before.1) < 1e-6, "about the same point: {:?}", under(&app));
|
||
app.session.execute("prefs.set", &json!({"key": "trackpadRotateView", "value": false})).unwrap();
|
||
let before = *app.view().unwrap();
|
||
timed_frame(&mut app, &ctx, 2.0, vec![egui::Event::Rotate(0.25)]);
|
||
timed_frame(&mut app, &ctx, 2.1, vec![egui::Event::Rotate(0.25)]);
|
||
let after = *app.view().unwrap();
|
||
assert!(after.rotation == before.rotation && after.center == before.center, "off: the view does not turn ({})", after.rotation);
|
||
}
|
||
|
||
/// A trackpad rotation turns the view only once it reaches 5°, so a pinch that twists a little
|
||
/// only zooms. It then turns the view by all of it and follows each later rotation event, until
|
||
/// a quarter of a second passes without one.
|
||
#[test]
|
||
fn a_trackpad_rotation_turns_nothing_until_it_reaches_five_degrees() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
timed_frame(&mut app, &ctx, 0.0, vec![]);
|
||
let at = app.canvas_rect.unwrap().center() + vec2(60.0, 40.0);
|
||
timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at)]);
|
||
let rotate = |deg: f32| egui::Event::Rotate(deg.to_radians());
|
||
let zoom = app.view().unwrap().zoom;
|
||
timed_frame(&mut app, &ctx, 1.0, vec![egui::Event::Zoom(1.25), rotate(1.0)]);
|
||
assert!(app.view().unwrap().zoom > zoom * 1.2, "the pinch zooms");
|
||
timed_frame(&mut app, &ctx, 1.1, vec![egui::Event::Zoom(1.25), rotate(1.0)]);
|
||
timed_frame(&mut app, &ctx, 1.2, vec![egui::Event::Zoom(1.25), rotate(1.0)]);
|
||
assert_eq!(app.view().unwrap().rotation, 0.0, "3° in all does not turn the view");
|
||
timed_frame(&mut app, &ctx, 1.3, vec![rotate(3.0)]);
|
||
assert!((app.view().unwrap().rotation - 6.0).abs() < 1e-4, "6° in all turns by 6°: {}", app.view().unwrap().rotation);
|
||
timed_frame(&mut app, &ctx, 1.4, vec![rotate(-1.0)]);
|
||
assert!((app.view().unwrap().rotation - 5.0).abs() < 1e-4, "then each event turns it: {}", app.view().unwrap().rotation);
|
||
timed_frame(&mut app, &ctx, 2.0, vec![rotate(3.0)]);
|
||
assert!((app.view().unwrap().rotation - 5.0).abs() < 1e-4, "a new rotation starts under 5° again: {}", app.view().unwrap().rotation);
|
||
}
|
||
|
||
/// Rotation events that come while two fingers are on a touch screen (Safari on an iPad sends
|
||
/// them with the touches) do not turn the view.
|
||
#[test]
|
||
fn a_rotation_with_fingers_on_a_touch_screen_turns_nothing() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
timed_frame(&mut app, &ctx, 0.0, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||
timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]);
|
||
let before = app.view().unwrap().rotation;
|
||
let start = vec![touch(1, egui::TouchPhase::Start, c - vec2(40.0, 0.0)), touch(2, egui::TouchPhase::Start, c + vec2(40.0, 0.0))];
|
||
timed_frame(&mut app, &ctx, 0.2, [start, vec![egui::Event::Rotate(0.25)]].concat());
|
||
assert_eq!(app.view().unwrap().rotation, before, "the view does not turn");
|
||
}
|
||
|
||
/// General › Display Print Size at 100% Zoom (#394): `view.actualSize` is one document point per
|
||
/// screen point with it off, and 96 screen points per document inch with it on.
|
||
#[test]
|
||
fn view_actual_size_follows_display_print_size() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||
app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(460.0, 260.0)));
|
||
app.run("view.setZoom", json!({"zoom": 250})).unwrap();
|
||
app.run("view.actualSize", json!({})).unwrap();
|
||
assert!((app.view().unwrap().zoom - 1.0).abs() < 1e-12, "off by default: 100% is 1:1");
|
||
app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap();
|
||
app.run("view.actualSize", json!({})).unwrap();
|
||
assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12, "an inch is 96 screen points");
|
||
}
|
||
|
||
/// Fit Artboard in Window fits the artboard as a rotated view shows it: at 45° every corner is
|
||
/// on the canvas and one is within the 30-point margin of an edge. At 0° the zoom is unchanged.
|
||
#[test]
|
||
fn fit_artboard_fits_the_artboard_turned_with_the_view() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 612, "height": 792})).unwrap();
|
||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0));
|
||
app.canvas_rect = Some(rect);
|
||
app.run("view.fitArtboard", json!({})).unwrap();
|
||
assert!((app.view().unwrap().zoom - (940.0 / 612.0_f64).min(740.0 / 792.0)).abs() < 1e-12, "0°");
|
||
app.run("view.rotate", json!({"angle": 45})).unwrap();
|
||
app.run("view.fitArtboard", json!({})).unwrap();
|
||
let board = app.session.active().unwrap().doc.artboards[0].rect;
|
||
let q = Xf::new(rect, app.view().unwrap()).quad(board);
|
||
assert!(q.iter().all(|c| rect.contains(*c)), "the turned artboard is on the canvas: {q:?}");
|
||
let gap =
|
||
q.iter().map(|c| (c.x - rect.left()).min(rect.right() - c.x).min(c.y - rect.top()).min(rect.bottom() - c.y)).fold(f32::INFINITY, f32::min);
|
||
assert!(gap < 31.0, "and fills it but for the margin: {gap}");
|
||
}
|
||
|
||
/// Performance › Animated Zoom (#394): the Zoom tool dragged sideways zooms about where it was
|
||
/// pressed (right in, left out), held still it zooms on, and a click still steps. Off, or with
|
||
/// GPU Performance off, a drag zooms to the area dragged across.
|
||
#[test]
|
||
fn animated_zoom_scrubs_and_holds_else_the_zoom_tool_zooms_to_an_area() {
|
||
use egui::Event;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("zoom");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() - vec2(100.0, 50.0);
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
// Press at `at`, move by `by` and hold for `frames` frames (a 60th of a second each), let go.
|
||
let press = |app: &mut VectorcraftApp, by: egui::Vec2, frames: usize| {
|
||
frame(app, &ctx, vec![Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(at, true)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(at + by)]);
|
||
for _ in 0..frames {
|
||
frame(app, &ctx, vec![]);
|
||
}
|
||
frame(app, &ctx, vec![button(at + by, false)]);
|
||
};
|
||
let doc_at = |app: &VectorcraftApp, p: Pos2| Xf::new(rect, app.view().unwrap()).to_doc(p);
|
||
let pinned = doc_at(&app, at);
|
||
let z0 = app.view().unwrap().zoom;
|
||
press(&mut app, vec2(100.0, 0.0), 0);
|
||
let z1 = app.view().unwrap().zoom;
|
||
assert!((z1 / z0 - 2.0).abs() < 1e-6, "100 points right doubles the zoom: {z0} → {z1}");
|
||
assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press");
|
||
press(&mut app, vec2(-100.0, 0.0), 0);
|
||
assert!((app.view().unwrap().zoom - z0).abs() < 1e-6, "and back to the left");
|
||
press(&mut app, vec2(0.0, 0.0), 0);
|
||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z0, true), "a click steps");
|
||
let z2 = app.view().unwrap().zoom;
|
||
press(&mut app, vec2(0.0, 0.0), 40);
|
||
assert!(app.view().unwrap().zoom > z2 * 1.2, "held, it zooms on");
|
||
assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press");
|
||
// Off (or without GPU Performance): the area dragged across fills the view.
|
||
for key in ["animatedZoom", "gpuPerformance"] {
|
||
app.run("prefs.reset", json!({})).unwrap();
|
||
app.run("prefs.set", json!({"key": key, "value": false})).unwrap();
|
||
app.view_mut().unwrap().zoom = z0;
|
||
press(&mut app, vec2(100.0, 50.0), 0);
|
||
let z = app.view().unwrap().zoom;
|
||
assert!((z - z0 * (rect.width() / 100.0).min(rect.height() / 50.0) as f64).abs() < 1e-6, "{key} off: {z0} → {z}");
|
||
}
|
||
}
|
||
|
||
/// With Animated Zoom off, the Zoom tool zooms a rotated view to the area dragged across: at 90°
|
||
/// and 180° a 100 × 50-point marquee fills the canvas as it does at 0°, centered on the canvas.
|
||
#[test]
|
||
fn the_zoom_tool_zooms_a_rotated_view_to_the_area_dragged_across() {
|
||
use egui::Event;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap();
|
||
app.select_tool("zoom");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() - vec2(100.0, 50.0);
|
||
let to = at + vec2(100.0, 50.0);
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
for angle in [90.0, 180.0] {
|
||
app.run("view.rotate", json!({ "angle": angle })).unwrap();
|
||
let z0 = app.view().unwrap().zoom;
|
||
let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(50.0, 25.0));
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(at, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||
let v = app.view().unwrap();
|
||
let want = z0 * f64::from((rect.width() / 100.0).min(rect.height() / 50.0));
|
||
assert!((v.zoom - want).abs() < 1e-6, "{angle}°: {z0} → {}, not {want}", v.zoom);
|
||
assert!(v.center.distance(middle) < 1e-6, "{angle}°: the marquee's center goes to the canvas's center");
|
||
app.view_mut().unwrap().zoom = z0;
|
||
}
|
||
}
|
||
|
||
/// Space held while the Zoom tool drags a marquee moves the marquee and keeps its size. When Space
|
||
/// is let go, the drag sizes the marquee again from where it moved to, and the release zooms to the
|
||
/// moved marquee.
|
||
/// Space held since a Cmd+Space press sizes the marquee.
|
||
#[test]
|
||
fn space_moves_the_zoom_marquee_while_dragging() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap();
|
||
app.select_tool("zoom");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() - vec2(100.0, 50.0);
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
let drawn = |app: &mut VectorcraftApp| -> Vec<Pos2> {
|
||
let lines: Vec<Vec<Pos2>> = shapes(app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let [line] = &lines[..] else { panic!("one marquee: {lines:?}") };
|
||
line.clone()
|
||
};
|
||
let z0 = app.view().unwrap().zoom;
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(at, true, Modifiers::NONE)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(100.0, 50.0))]);
|
||
frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::PointerMoved(at + vec2(140.0, 70.0))]);
|
||
let moved = drawn(&mut app);
|
||
assert_eq!(moved[0], at + vec2(40.0, 20.0), "Space moves the marquee: {moved:?}");
|
||
assert_eq!(moved[2], at + vec2(140.0, 70.0), "and keeps its 100 × 50 size: {moved:?}");
|
||
frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::PointerMoved(at + vec2(180.0, 95.0))]);
|
||
let sized = drawn(&mut app);
|
||
assert_eq!((sized[0], sized[2]), (at + vec2(40.0, 20.0), at + vec2(180.0, 95.0)), "let go, the drag sizes it again: {sized:?}");
|
||
let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(110.0, 57.5));
|
||
frame(&mut app, &ctx, vec![button(at + vec2(180.0, 95.0), false, Modifiers::NONE)]);
|
||
let v = *app.view().unwrap();
|
||
let want = z0 * f64::from((rect.width() / 140.0).min(rect.height() / 75.0));
|
||
assert!((v.zoom - want).abs() < 1e-6, "the release zooms to the moved marquee: {z0} → {}, not {want}", v.zoom);
|
||
assert!(v.center.distance(middle) < 1e-6, "centered on it: {:?}, not {middle:?}", v.center);
|
||
// A Cmd+Space press: Space held from the press on sizes the marquee, also once Cmd is let go.
|
||
app.select_tool("selection");
|
||
app.view_mut().unwrap().zoom = z0;
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::COMMAND), space(true, Modifiers::COMMAND), Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(at, true, Modifiers::COMMAND)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(50.0, 25.0))]);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(at + vec2(100.0, 50.0))]);
|
||
let held = drawn(&mut app);
|
||
assert_eq!((held[0], held[2]), (at, at + vec2(100.0, 50.0)), "Space held since the press sizes it: {held:?}");
|
||
frame(&mut app, &ctx, vec![button(at + vec2(100.0, 50.0), false, Modifiers::NONE)]);
|
||
frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]);
|
||
let want = z0 * f64::from((rect.width() / 100.0).min(rect.height() / 50.0));
|
||
assert!((app.view().unwrap().zoom - want).abs() < 1e-6, "{z0} → {}, not {want}", app.view().unwrap().zoom);
|
||
}
|
||
|
||
/// Space pressed after the Zoom tool's press and before the drag moves: the first movement sizes
|
||
/// the marquee, Space then moves it, and the release zooms to it instead of stepping the zoom.
|
||
#[test]
|
||
fn space_before_the_zoom_drag_moves_sizes_the_marquee_first() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap();
|
||
app.select_tool("zoom");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() - vec2(100.0, 50.0);
|
||
let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||
let z0 = app.view().unwrap().zoom;
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(at, true)]);
|
||
frame(&mut app, &ctx, vec![space(true), Event::PointerMoved(at + vec2(120.0, 60.0))]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(140.0, 70.0))]);
|
||
let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(80.0, 40.0));
|
||
frame(&mut app, &ctx, vec![button(at + vec2(140.0, 70.0), false)]);
|
||
frame(&mut app, &ctx, vec![space(false)]);
|
||
let v = *app.view().unwrap();
|
||
let want = z0 * f64::from((rect.width() / 120.0).min(rect.height() / 60.0));
|
||
assert!((v.zoom - want).abs() < 1e-6, "the release zooms to the 120 × 60 marquee: {z0} → {}, not {want}", v.zoom);
|
||
assert!(v.center.distance(middle) < 1e-6, "moved by Space and centered on it: {:?}, not {middle:?}", v.center);
|
||
}
|
||
|
||
/// Space pressed or let go between two pointer moves in one frame: the move before the change and
|
||
/// the move after it each follow the Space state they came with.
|
||
#[test]
|
||
fn the_zoom_marquee_follows_space_and_the_pointer_in_the_order_they_came() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap();
|
||
app.select_tool("zoom");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() - vec2(100.0, 50.0);
|
||
let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||
let corners = |app: &mut VectorcraftApp| -> (Pos2, Pos2) {
|
||
let lines: Vec<Vec<Pos2>> = shapes(app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let [line] = &lines[..] else { panic!("one marquee: {lines:?}") };
|
||
(line[0], line[2])
|
||
};
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(at, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(100.0, 50.0))]);
|
||
frame(&mut app, &ctx, vec![space(true), Event::PointerMoved(at + vec2(140.0, 70.0))]);
|
||
// Moved with Space held, then sized after it was let go.
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(160.0, 80.0)), space(false), Event::PointerMoved(at + vec2(200.0, 100.0))]);
|
||
assert_eq!(corners(&mut app), (at + vec2(60.0, 30.0), at + vec2(200.0, 100.0)), "moved, then sized");
|
||
// Sized before Space was pressed, then moved with it held.
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(210.0, 105.0)), space(true), Event::PointerMoved(at + vec2(230.0, 115.0))]);
|
||
assert_eq!(corners(&mut app), (at + vec2(80.0, 40.0), at + vec2(230.0, 115.0)), "sized, then moved");
|
||
frame(&mut app, &ctx, vec![button(at + vec2(230.0, 115.0), false)]);
|
||
frame(&mut app, &ctx, vec![space(false)]);
|
||
}
|
||
|
||
/// Space pressed while the Zoom tool drags keeps the zoom cursor: the drag does not turn into a
|
||
/// Hand tool pan, and with Shift also held it does not show the rotate cursor.
|
||
#[test]
|
||
fn space_during_a_zoom_drag_keeps_the_zoom_cursor() {
|
||
use egui::{CursorIcon, Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap();
|
||
app.select_tool("zoom");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
// `shown` returns the system cursor the canvas sets over a window without the focus, as in
|
||
// `the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool`.
|
||
let shown = |app: &mut VectorcraftApp, events: Vec<Event>| {
|
||
let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
out.platform_output.cursor_icon
|
||
};
|
||
let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||
assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::ZoomIn);
|
||
shown(&mut app, vec![button(c, true)]);
|
||
shown(&mut app, vec![Event::PointerMoved(c + vec2(40.0, 30.0))]);
|
||
assert_eq!(shown(&mut app, vec![space(true), Event::PointerMoved(c + vec2(60.0, 40.0))]), CursorIcon::ZoomIn, "Space during the drag");
|
||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT)]), CursorIcon::ZoomIn, "Space+Shift during the drag");
|
||
shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]);
|
||
shown(&mut app, vec![button(c + vec2(60.0, 40.0), false)]);
|
||
assert_eq!(shown(&mut app, vec![]), CursorIcon::Grab, "after the drag, Space shows the hand");
|
||
shown(&mut app, vec![space(false)]);
|
||
}
|
||
|
||
/// In a rotated view the Selection tool's marquee is drawn where the tool selects: through the
|
||
/// screen corners of the document rectangle between the press and the pointer.
|
||
#[test]
|
||
fn the_selection_marquee_is_drawn_where_it_selects_in_a_rotated_view() {
|
||
use egui::Event;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.run("view.rotate", json!({"angle": 45})).unwrap();
|
||
let rect = app.canvas_rect.unwrap();
|
||
let (from, to) = (rect.center() - vec2(60.0, 20.0), rect.center() + vec2(60.0, 40.0));
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(from)]);
|
||
frame(
|
||
&mut app,
|
||
&ctx,
|
||
vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }],
|
||
);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let want = xf.quad(Rect::from_points(xf.to_doc(from), xf.to_doc(to)));
|
||
let drawn: Vec<Vec<Pos2>> = shapes(&mut app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let [line] = &drawn[..] else { panic!("one marquee: {drawn:?}") };
|
||
assert_eq!(line.len(), 5, "closed: {line:?}");
|
||
assert!(line.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "{line:?} is not {want:?}");
|
||
assert_eq!(line[4], line[0]);
|
||
}
|
||
|
||
/// User Interface › Large Tabs (#394): the document tabs are taller, so the canvas starts lower.
|
||
#[test]
|
||
fn large_tabs_make_the_document_tabs_taller() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let canvas_top = |app: &mut VectorcraftApp| {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::chrome::doc_tabs(app, ui);
|
||
show(app, ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
app.canvas_rect.unwrap().top()
|
||
};
|
||
let small = canvas_top(&mut app);
|
||
app.run("prefs.set", json!({"key": "largeTabs", "value": true})).unwrap();
|
||
assert_eq!(canvas_top(&mut app) - small, 9.0, "44 points instead of 35");
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_space_zooms_and_space_pans_whatever_the_tool() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let at = app.canvas_rect.unwrap().center();
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
// A drag from `at` to `to` with Space and `held` down.
|
||
let drag = |app: &mut VectorcraftApp, held: Modifiers, to: Pos2| {
|
||
frame(app, &ctx, vec![Event::ModifiersChanged(held), space(true, held), Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(at, true, held)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false, held)]);
|
||
frame(app, &ctx, vec![space(false, held), Event::ModifiersChanged(Modifiers::NONE)]);
|
||
};
|
||
let z0 = app.view().unwrap().zoom;
|
||
drag(&mut app, Modifiers::COMMAND, at);
|
||
let z1 = app.view().unwrap().zoom;
|
||
assert_eq!(z1, crate::state::next_zoom(z0, true), "Cmd+Space click zooms in");
|
||
drag(&mut app, Modifiers::COMMAND | Modifiers::ALT, at);
|
||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z1, false), "Cmd+Alt+Space click zooms out");
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||
// Space alone pans, with the Zoom tool too.
|
||
app.select_tool("zoom");
|
||
let before = *app.view().unwrap();
|
||
drag(&mut app, Modifiers::NONE, at + vec2(30.0, 0.0));
|
||
let after = *app.view().unwrap();
|
||
assert_eq!(after.zoom, before.zoom);
|
||
assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn space_shift_drag_rotates_the_view_whatever_the_tool() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
// `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`.
|
||
let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| {
|
||
let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] };
|
||
frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat());
|
||
frame(app, &ctx, vec![button(from, true, held)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false, held)]);
|
||
frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat());
|
||
};
|
||
// `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right.
|
||
let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin());
|
||
let before = *app.view().unwrap();
|
||
drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0));
|
||
let after = *app.view().unwrap();
|
||
assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}");
|
||
assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center");
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||
// Shift does not round the angle: 90° and 20° more make 110°, not 105°.
|
||
drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0));
|
||
assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view());
|
||
// Space alone still pans, and Cmd+Shift+Space still zooms.
|
||
let v = *app.view().unwrap();
|
||
drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0));
|
||
let panned = *app.view().unwrap();
|
||
assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom));
|
||
assert_ne!(panned.center, v.center, "Space pans");
|
||
drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c);
|
||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in");
|
||
assert_eq!(app.view().unwrap().rotation, v.rotation);
|
||
// The Rotate View tool's Shift still rounds to 15°.
|
||
app.select_tool("rotateView");
|
||
app.view_mut().unwrap().rotation = 0.0;
|
||
drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0));
|
||
assert_eq!(app.view().unwrap().rotation, 15.0);
|
||
drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0));
|
||
assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view());
|
||
}
|
||
|
||
#[test]
|
||
fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() {
|
||
use egui::{CursorIcon, Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
// `shown` returns the system cursor the canvas sets over a window without the focus. On macOS
|
||
// the window paints the glyph only with the focus, and on the other desktop builds (not
|
||
// Wayland, which the tests do not set) the window sets the system cursor next to the bitmap.
|
||
let shown = |app: &mut VectorcraftApp, events: Vec<Event>| {
|
||
let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
out.platform_output.cursor_icon
|
||
};
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT };
|
||
assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias);
|
||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias);
|
||
// Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand.
|
||
shown(&mut app, vec![button(c, true)]);
|
||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias);
|
||
assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab);
|
||
// Cmd+Shift+Space zooms.
|
||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn);
|
||
assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias);
|
||
app.select_tool("rotateView");
|
||
assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool");
|
||
}
|
||
|
||
#[test]
|
||
fn a_rotate_view_drag_shows_a_dial_with_the_angle() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rotateView");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
// `dial` returns, from the shapes of one frame, the center of the dial's circle, the needle's
|
||
// direction in whole degrees clockwise from straight up, and the angle text.
|
||
let dial = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let circle = s.iter().find_map(|s| if let Shape::Circle(k) = s { (k.radius == DIAL_RADIUS).then_some(k.center) } else { None });
|
||
let needle = s.iter().find_map(|s| match s {
|
||
Shape::LineSegment { points: [a, b], stroke } if stroke.width == 2.0 && *a == c => {
|
||
Some((b.x - a.x).atan2(a.y - b.y).to_degrees().round() as i32)
|
||
}
|
||
_ => None,
|
||
});
|
||
let text = s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None });
|
||
(circle, needle, text)
|
||
};
|
||
assert_eq!(dial(&mut app), (None, None, None), "no dial before a drag");
|
||
// The Rotate View tool: a drag from 0° to 30° about the canvas center.
|
||
let (right, at_30, below) = (c + vec2(100.0, 0.0), c + vec2(86.6, 50.0), c + vec2(0.0, 100.0));
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(right)]);
|
||
frame(&mut app, &ctx, vec![button(right, true, Modifiers::NONE)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at_30)]);
|
||
assert_eq!(dial(&mut app), (Some(c), Some(30), Some("30°".to_string())));
|
||
frame(&mut app, &ctx, vec![button(at_30, false, Modifiers::NONE)]);
|
||
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
|
||
assert!((app.view().unwrap().rotation - 30.0).abs() < 1e-2, "{:?}", app.view());
|
||
// A Space+Shift drag with another tool shows it too: 30° and 90° more make 120°.
|
||
app.select_tool("rectangle");
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT), Event::PointerMoved(right)]);
|
||
frame(&mut app, &ctx, vec![button(right, true, Modifiers::SHIFT)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(below)]);
|
||
assert_eq!(dial(&mut app), (Some(c), Some(120), Some("120°".to_string())));
|
||
assert!((app.view().unwrap().rotation - 120.0).abs() < 1e-2, "{:?}", app.view());
|
||
// Presentation Mode turned on during the drag hides the dial.
|
||
let mode = app.ui.screen_mode;
|
||
app.ui.screen_mode = 3;
|
||
assert_eq!(dial(&mut app), (None, None, None), "no dial in Presentation Mode");
|
||
app.ui.screen_mode = mode;
|
||
frame(&mut app, &ctx, vec![button(below, false, Modifiers::SHIFT)]);
|
||
frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]);
|
||
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
|
||
}
|
||
|
||
#[test]
|
||
fn space_pressed_mid_drag_moves_what_the_tool_draws() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.view.smart_guides = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||
// Press at `a`, drag to `b`, then hold Space while moving on by `by` and let go of both.
|
||
let draw = |app: &mut VectorcraftApp, a: Pos2, b: Pos2, by: egui::Vec2| {
|
||
frame(app, &ctx, vec![Event::PointerMoved(a)]);
|
||
frame(app, &ctx, vec![button(a, true)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(b)]);
|
||
frame(app, &ctx, vec![space(true)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(b + by)]);
|
||
frame(app, &ctx, vec![space(false)]);
|
||
frame(app, &ctx, vec![button(b + by, false)]);
|
||
};
|
||
let near = |p: Point, q: Point| p.distance(q) < 1e-3;
|
||
let at = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0);
|
||
let center = app.view().unwrap().center;
|
||
// The rectangle moves at its size instead of growing, and the view doesn't pan.
|
||
app.select_tool("rectangle");
|
||
draw(&mut app, at, at + vec2(80.0, 40.0), vec2(30.0, 20.0));
|
||
assert_eq!(app.view().unwrap().center, center, "Space during a drag doesn't pan");
|
||
let st = app.session.active().unwrap();
|
||
let r = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().bounds().unwrap();
|
||
assert!(
|
||
near(Point::new(r.x0, r.y0), xf.to_doc(at + vec2(30.0, 20.0))) && near(Point::new(r.x1, r.y1), xf.to_doc(at + vec2(110.0, 60.0))),
|
||
"{r:?}"
|
||
);
|
||
// The Pen's anchor being dragged out moves, handles and all.
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("pen");
|
||
let a = at + vec2(0.0, 120.0);
|
||
draw(&mut app, a, a + vec2(40.0, 0.0), vec2(20.0, 10.0));
|
||
let st = app.session.active().unwrap();
|
||
let anchor = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0];
|
||
assert!(near(anchor.p, xf.to_doc(a + vec2(20.0, 10.0))) && near(anchor.h_out, xf.to_doc(a + vec2(60.0, 10.0))), "{anchor:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let timed = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
timed(&mut app, 0.0, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||
NodeKind::Text(t) => t.plain_text(),
|
||
_ => panic!("not text"),
|
||
};
|
||
// Double-click the middle of the text.
|
||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
let at = xf.to_screen(b.center());
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||
timed(&mut app, 1.1, vec![]);
|
||
// The Type tool edits the text, with the caret inside it, where it was clicked: typing goes
|
||
// into the text, not to tool shortcuts (X would swap fill and stroke).
|
||
assert_eq!(app.session.tool_id(), "type");
|
||
assert!(app.session.tool_wants_text());
|
||
let o = app.session.tool_options();
|
||
assert_eq!(o["editing"], json!(id));
|
||
let caret = o["caret"].as_u64().unwrap();
|
||
assert!((1..11).contains(&caret), "caret {caret}");
|
||
app.session.tool_text("X", app.view_info()).unwrap();
|
||
let t = text(&app);
|
||
assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}");
|
||
}
|
||
|
||
/// #2119: type copied with an Alt-drag, double-clicked, all of its text selected and typed over
|
||
/// keeps its font, size and colour: through an IME (the marked text cleared, then committed by
|
||
/// Return, as Windows sends them) and with Backspace then typing. Whole app frames, one input
|
||
/// event per frame.
|
||
fn retype_all_of_a_copied_type(ime: bool) {
|
||
use egui::{Event, Key, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let original = app
|
||
.session
|
||
.execute("text.create", &json!({"x": 60, "y": 100, "text": "Chair 123", "size": 36, "font": "Georgia", "color": "#808080"}))
|
||
.unwrap()["id"]
|
||
.as_u64()
|
||
.unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let time = std::cell::Cell::new(0.0);
|
||
let frame = |app: &mut VectorcraftApp, events: Vec<Event>| {
|
||
time.set(time.get() + 0.03);
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time.get()), events, ..Default::default() }, |ui| {
|
||
app.logic(ui.ctx());
|
||
app.ui(ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
};
|
||
for _ in 0..3 {
|
||
frame(&mut app, vec![]);
|
||
}
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let node = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||
let styles = |app: &VectorcraftApp, id: u64| match &node(app, id).kind {
|
||
NodeKind::Text(t) => {
|
||
t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.size, r.style.fill.clone())).collect::<Vec<_>>()
|
||
}
|
||
_ => panic!("not text"),
|
||
};
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
let key = |key, pressed, modifiers| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers };
|
||
// 1. Alt-drag the type: a copy, selected.
|
||
let from = xf.to_screen(node(&app, original).geometric_bounds().unwrap().center());
|
||
let to = from + vec2(0.0, 120.0 * xf.zoom as f32);
|
||
frame(&mut app, vec![Event::PointerMoved(from), Event::ModifiersChanged(Modifiers::ALT)]);
|
||
frame(&mut app, vec![button(from, true, Modifiers::ALT)]);
|
||
for k in 1..=6 {
|
||
frame(&mut app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]);
|
||
}
|
||
frame(&mut app, vec![button(to, false, Modifiers::ALT)]);
|
||
frame(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]);
|
||
let copy = app.session.active().unwrap().selection.objects[0].0;
|
||
assert_ne!(copy, original);
|
||
let style = styles(&app, original)[0].clone();
|
||
// 2. Double-click it at once: the Type tool edits it.
|
||
let at = xf.to_screen(node(&app, copy).geometric_bounds().unwrap().center());
|
||
frame(&mut app, vec![Event::PointerMoved(at)]);
|
||
for pressed in [true, false, true, false] {
|
||
frame(&mut app, vec![button(at, pressed, Modifiers::NONE)]);
|
||
}
|
||
frame(&mut app, vec![]);
|
||
assert_eq!(app.session.tool_options()["editing"], json!(copy));
|
||
// 3. Select all of its text and type over it.
|
||
frame(&mut app, vec![key(Key::A, true, Modifiers::COMMAND)]);
|
||
frame(&mut app, vec![key(Key::A, false, Modifiers::COMMAND)]);
|
||
if ime {
|
||
frame(&mut app, vec![preedit("t", 1)]);
|
||
frame(&mut app, vec![preedit("table", 5)]);
|
||
let marked = styles(&app, copy);
|
||
assert_eq!(marked.iter().map(|r| (&r.1, r.2, &r.3)).collect::<Vec<_>>(), vec![(&style.1, style.2, &style.3)], "composing: {marked:?}");
|
||
// Return commits the composition.
|
||
frame(&mut app, vec![preedit("", 0), Event::Ime(egui::ImeEvent::Commit("table".into()))]);
|
||
} else {
|
||
frame(&mut app, vec![key(Key::Backspace, true, Modifiers::NONE)]);
|
||
frame(&mut app, vec![key(Key::Backspace, false, Modifiers::NONE)]);
|
||
frame(&mut app, vec![Event::Text("table".into())]);
|
||
frame(&mut app, vec![key(Key::Enter, true, Modifiers::NONE)]);
|
||
frame(&mut app, vec![key(Key::Enter, false, Modifiers::NONE)]);
|
||
}
|
||
frame(&mut app, vec![]);
|
||
let after = styles(&app, copy);
|
||
assert!(after.iter().any(|r| r.0.starts_with("table")), "ime={ime}: typed over: {after:?}");
|
||
assert!(
|
||
after.iter().all(|r| (&r.1, r.2, &r.3) == (&style.1, style.2, &style.3)),
|
||
"ime={ime}: the copy keeps {:?} {} {:?}, got {after:?}",
|
||
style.1,
|
||
style.2,
|
||
style.3
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn retyping_all_of_a_copied_type_through_an_ime_keeps_its_style() {
|
||
retype_all_of_a_copied_type(true);
|
||
}
|
||
|
||
#[test]
|
||
fn retyping_all_of_a_copied_type_after_backspace_keeps_its_style() {
|
||
retype_all_of_a_copied_type(false);
|
||
}
|
||
|
||
#[test]
|
||
fn double_clicking_type_with_the_direct_selection_tool_puts_the_caret_there() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("directSelection");
|
||
let ctx = egui::Context::default();
|
||
let timed = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| {
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
show(app, ui)
|
||
});
|
||
out.textures_delta.clear();
|
||
};
|
||
timed(&mut app, 0.0, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||
NodeKind::Text(t) => t.plain_text(),
|
||
_ => panic!("not text"),
|
||
};
|
||
// Double-click the middle of the text with Direct Selection.
|
||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
let at = xf.to_screen(b.center());
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||
timed(&mut app, 1.1, vec![]);
|
||
// The Type tool edits the text, with the caret inside it, where it was clicked: typing goes
|
||
// into the text, not to tool shortcuts.
|
||
assert_eq!(app.session.tool_id(), "type");
|
||
assert!(app.session.tool_wants_text());
|
||
let o = app.session.tool_options();
|
||
assert_eq!(o["editing"], json!(id));
|
||
let caret = o["caret"].as_u64().unwrap();
|
||
assert!((1..11).contains(&caret), "caret {caret}");
|
||
app.session.tool_text("X", app.view_info()).unwrap();
|
||
let t = text(&app);
|
||
assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}");
|
||
|
||
// Pressing Escape exits text editing and returns to Direct Selection tool.
|
||
let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||
timed(&mut app, 1.2, vec![esc(true), esc(false)]);
|
||
assert_eq!(app.session.tool_id(), "directSelection");
|
||
assert!(!app.session.tool_wants_text());
|
||
}
|
||
|
||
#[test]
|
||
fn escape_while_editing_in_isolation_returns_to_selection_tool_and_keeps_isolation_until_second_escape() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let tid = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Nested hello", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
// Group the text inside a group.
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.session.execute("object.group", &json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let timed = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| {
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
show(app, ui)
|
||
});
|
||
out.textures_delta.clear();
|
||
};
|
||
timed(&mut app, 0.0, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(tid)).unwrap().geometric_bounds().unwrap();
|
||
let at = xf.to_screen(b.center());
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||
|
||
// 1st double-click on group enters isolation mode.
|
||
timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||
timed(&mut app, 1.1, vec![]);
|
||
assert!(app.session.active().unwrap().isolation.is_some(), "should be in isolation mode");
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
|
||
// 2nd double-click on the text inside the isolated group enters text editing.
|
||
timed(&mut app, 2.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||
timed(&mut app, 2.1, vec![]);
|
||
assert_eq!(app.session.tool_id(), "type");
|
||
assert!(app.session.tool_wants_text());
|
||
assert!(app.session.active().unwrap().isolation.is_some(), "isolation mode still active while editing text");
|
||
|
||
// 1st Escape exits text edit mode, returns to Selection tool, but keeps isolation mode.
|
||
timed(&mut app, 3.0, vec![esc(true), esc(false)]);
|
||
timed(&mut app, 3.1, vec![]);
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
assert!(!app.session.tool_wants_text());
|
||
assert!(app.session.active().unwrap().isolation.is_some(), "1st Escape must NOT end isolation mode");
|
||
|
||
// 2nd Escape exits isolation mode.
|
||
timed(&mut app, 4.0, vec![esc(true), esc(false)]);
|
||
timed(&mut app, 4.1, vec![]);
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
assert!(app.session.active().unwrap().isolation.is_none(), "2nd Escape must exit isolation mode");
|
||
}
|
||
|
||
#[test]
|
||
fn a_symbol_dropped_on_the_canvas_is_placed_there() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
|
||
let name = app.session.execute("symbol.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
// Released over empty canvas: an instance centred there.
|
||
let at = xf.to_screen(Point::new(250.0, 180.0));
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Symbol(name.clone()));
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
let st = app.session.active().unwrap();
|
||
let placed = st.selection.objects.first().copied().unwrap();
|
||
let n = st.doc.node(placed).unwrap();
|
||
assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name));
|
||
let c = n.geometric_bounds().unwrap().center();
|
||
assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// Dropped on art too, it is placed, not applied to the art.
|
||
app.session.execute("shape.rectangle", &json!({"x": 200, "y": 150, "width": 100, "height": 60})).unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let at = xf.to_screen(Point::new(250.0, 180.0));
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::LibrarySymbol { library: "basic-shapes".into(), name: "Star".into() });
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.symbols.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Star"]);
|
||
assert_eq!(st.history.undo.len(), undo + 1, "added and placed as one undo step");
|
||
assert_eq!(st.history.undo.last().unwrap().label, "Add to Symbols");
|
||
let placed = st.selection.objects.first().copied().unwrap();
|
||
let n = st.doc.node(placed).unwrap();
|
||
assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Star"));
|
||
// The registration point (the centre) is where it was dropped, in document coordinates
|
||
// (the pointer's screen position, which rounds 250, 180 a little).
|
||
let size = st.doc.unknown.get(vectorcraft_doc::SYMBOL_SIZES).and_then(|m| m.get("Star")).cloned().unwrap();
|
||
let b = n.geometric_bounds().unwrap();
|
||
assert!((b.width() - size[0].as_f64().unwrap()).abs() < 1e-6, "{b:?} {size}");
|
||
assert!((b.center() - xf.to_doc(at)).hypot() < 1e-6 && (b.center() - Point::new(250.0, 180.0)).hypot() < 0.1, "{b:?}");
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.doc.symbols.is_empty() && st.doc.node(placed).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_brush_dropped_on_a_path_is_applied_to_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let brush = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().and_then(|s| s.brush.clone())
|
||
};
|
||
let drop = |app: &mut VectorcraftApp, at: Point| {
|
||
let at = xf.to_screen(at);
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: "Arrow".into(), def: json!({}) });
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
};
|
||
// Off the path: nothing.
|
||
drop(&mut app, Point::new(200.0, 250.0));
|
||
assert_eq!(brush(&app), None);
|
||
// On it: the path's stroke takes the brush.
|
||
drop(&mut app, Point::new(200.0, 150.0));
|
||
assert_eq!(brush(&app).as_deref(), Some("Arrow"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let drop = |app: &mut VectorcraftApp, at: Point| {
|
||
let at = xf.to_screen(at);
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::LibraryBrush { library: "scatter-shapes".into(), name: "Sparkles".into() });
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
};
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
// Off the path: nothing.
|
||
drop(&mut app, Point::new(200.0, 250.0));
|
||
assert!(vectorcraft_brush::find(&app.session.active().unwrap().doc, "Sparkles").is_none());
|
||
// On it: added, with the symbol it scatters, and applied, as one step.
|
||
drop(&mut app, Point::new(200.0, 150.0));
|
||
let st = app.session.active().unwrap();
|
||
assert!(vectorcraft_brush::find(&st.doc, "Sparkles").is_some() && st.doc.symbols.iter().any(|s| s.name == "Sparkle"), "{}", app.ui.status);
|
||
let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap();
|
||
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.clone()).as_deref(), Some("Sparkles"));
|
||
assert_eq!(st.history.undo.len(), undo + 1);
|
||
assert_eq!(st.history.undo.last().unwrap().label, "Add to Brushes");
|
||
}
|
||
|
||
#[test]
|
||
fn middle_drag_pans_the_view_with_any_tool() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// A drawing tool: the middle button must pan, never draw.
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(400.0, 300.0))]);
|
||
let before = *app.view().unwrap();
|
||
frame(&mut app, &ctx, vec![middle(pos2(400.0, 300.0), true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(450.0, 330.0))]);
|
||
frame(&mut app, &ctx, vec![middle(pos2(450.0, 330.0), false)]);
|
||
let after = *app.view().unwrap();
|
||
assert_eq!(after.zoom, before.zoom);
|
||
assert!((after.center.x - (before.center.x - 50.0 / before.zoom)).abs() < 1e-6);
|
||
assert!((after.center.y - (before.center.y - 30.0 / before.zoom)).abs() < 1e-6);
|
||
// Released: moving no longer pans, and nothing was drawn.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(300.0, 200.0))]);
|
||
assert_eq!(app.view().unwrap().center, after.center);
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() {
|
||
use vectorcraft_color::{Color, GradientGeom, GradientPaint, Paint};
|
||
use widgets::{PanelDrag, SwatchRows};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let drop = |app: &mut VectorcraftApp, d: PanelDrag, at: Point| {
|
||
let at = xf.to_screen(at);
|
||
egui::DragAndDrop::set_payload(&ctx, d);
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
};
|
||
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
|
||
let red = Paint::solid(Color::from_hex("#ed1c24").unwrap());
|
||
let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false };
|
||
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows), library: None };
|
||
// Off the art nothing happens.
|
||
drop(&mut app, swatch.clone(), Point::new(300.0, 250.0));
|
||
assert_eq!(fill(&app), Paint::solid(Color::WHITE));
|
||
drop(&mut app, swatch, Point::new(100.0, 100.0));
|
||
assert_eq!(fill(&app), red, "paint.setFill with the hit id");
|
||
assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was");
|
||
// A proxy's paint works the same.
|
||
drop(&mut app, PanelDrag::paint(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), Point::new(60.0, 60.0));
|
||
assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||
// A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on.
|
||
let mut g = GradientPaint::new(Default::default());
|
||
g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None });
|
||
drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0));
|
||
assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() {
|
||
use vectorcraft_doc::NodeId;
|
||
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": 400, "height": 300}));
|
||
let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
|
||
run(&mut app, "effect.apply", json!({"effect": "distort.twist"}));
|
||
run(&mut app, "transparency.set", json!({"opacity": 40}));
|
||
let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
|
||
run(&mut app, "select.set", json!({ "ids": [a] }));
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
// The Appearance panel's thumbnail (its drag payload) released over the second rectangle.
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let drop = |app: &mut VectorcraftApp, at: Point| {
|
||
let at = xf.to_screen(at);
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Appearance(NodeId(a)));
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![up]);
|
||
};
|
||
drop(&mut app, Point::new(250.0, 70.0));
|
||
let doc = &app.session.active().unwrap().doc;
|
||
let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap());
|
||
assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity));
|
||
assert_eq!(nb.appearance.effects[0].id, "distort.twist");
|
||
// Dropped on empty canvas or on the source itself: nothing changes.
|
||
let undo_len = app.session.active().unwrap().history.undo.len();
|
||
drop(&mut app, Point::new(350.0, 250.0));
|
||
drop(&mut app, Point::new(70.0, 70.0));
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len);
|
||
}
|
||
|
||
#[test]
|
||
fn a_graphic_style_dropped_on_art_applies_to_the_object_hit() {
|
||
use vectorcraft_doc::NodeId;
|
||
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": 400, "height": 300}));
|
||
let a = NodeId(run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||
let b = NodeId(run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||
run(&mut app, "select.set", json!({ "ids": [a.0] }));
|
||
let name = app.session.active().unwrap().doc.graphic_styles[2].name.clone();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(250.0, 70.0));
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::GraphicStyle(name.clone()));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![up]);
|
||
let st = app.session.active().unwrap();
|
||
let g = st.doc.graphic_style(&name).unwrap();
|
||
// `graphicStyle.apply` with the hit object's id: linked, the selection left alone.
|
||
assert_eq!(st.doc.node(b).unwrap().extras().graphic_style, Some(g.id));
|
||
assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance);
|
||
assert_eq!(st.doc.node(a).unwrap().extras().graphic_style, None);
|
||
assert_eq!(st.selection.objects, [a]);
|
||
}
|
||
|
||
#[test]
|
||
fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
let id = vectorcraft_doc::NodeId(id);
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let button =
|
||
|pos: Pos2, pressed: bool| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||
let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0;
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
// Moving on the canvas moves the art...
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some());
|
||
// ...over a panel it waits where it was, and the panels get the art.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels");
|
||
assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved");
|
||
assert_eq!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id])));
|
||
// Released over the panel, the move goes back and nothing is recorded.
|
||
frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]);
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.interaction.is_none());
|
||
assert_eq!(*st.doc, *before);
|
||
assert_eq!(st.history.undo.len(), undo);
|
||
}
|
||
|
||
/// #4233: the Direct and Group Selection tools hand art they move whole to the panels too (Asset
|
||
/// Export, Graphic Styles…), as the Selection tool does; the move waits and nothing is recorded.
|
||
#[test]
|
||
fn art_moved_whole_off_the_canvas_with_direct_selection_drops_on_the_panel() {
|
||
for tool in ["directSelection", "groupSelection"] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
let id = vectorcraft_doc::NodeId(id);
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool(tool);
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton {
|
||
pos,
|
||
button: egui::PointerButton::Primary,
|
||
pressed,
|
||
modifiers: Default::default(),
|
||
};
|
||
// Pressed on the fill: the rectangle is selected whole.
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
assert_eq!(app.session.active().unwrap().selection.whole(), [id], "{tool}: selected whole");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some(), "{tool}: the move is live");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some(), "{tool}: the move waits, not cancels");
|
||
assert_eq!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id])), "{tool}");
|
||
frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]);
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.interaction.is_none(), "{tool}");
|
||
assert_eq!(*st.doc, *before, "{tool}: the move goes back");
|
||
assert_eq!(st.history.undo.len(), undo, "{tool}: nothing recorded");
|
||
}
|
||
}
|
||
|
||
/// An anchor point the Direct Selection tool drags off the canvas isn't art for the panels: the
|
||
/// reshape goes on and no art drag starts (#4233).
|
||
#[test]
|
||
fn an_anchor_dragged_off_the_canvas_is_not_handed_to_the_panels() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("directSelection");
|
||
app.session.execute("select.anchors", &json!({"id": id, "anchors": [[0, 0]]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button =
|
||
|pos: Pos2, pressed: bool| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let corner = xf.to_screen(Point::new(50.0, 50.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(corner), button(corner, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(corner + vec2(20.0, 0.0))]);
|
||
assert!(app.session.active().unwrap().selection.partial(vectorcraft_doc::NodeId(id)).is_some(), "an anchor is picked");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(
|
||
!matches!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).as_deref(), Some(widgets::PanelDrag::Art(_))),
|
||
"no art drag for an anchor"
|
||
);
|
||
frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]);
|
||
}
|
||
|
||
/// #2826: a move taken over a panel waits where it left the canvas, and goes on with the
|
||
/// pointer when it comes back — it isn't dropped or put back.
|
||
#[test]
|
||
fn art_moved_off_the_canvas_resumes_when_the_pointer_returns() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let button =
|
||
|pos: Pos2, pressed: bool| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0;
|
||
let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0;
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]);
|
||
assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the move is live");
|
||
// Over a panel the move waits where it last moved, and stays live.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels");
|
||
assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved");
|
||
// Back on the canvas it goes on with the pointer, 60 pt right.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(60.0, 0.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some(), "the move is live again");
|
||
assert!((left(&app) - moved_to(start + vec2(60.0, 0.0))).abs() < 0.01, "the move resumes with the pointer");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(70.0, 0.0))]);
|
||
frame(&mut app, &ctx, vec![button(start + vec2(70.0, 0.0), false)]);
|
||
let st = app.session.active().unwrap();
|
||
assert!((left(&app) - moved_to(start + vec2(70.0, 0.0))).abs() < 0.01, "released where the pointer was");
|
||
assert_eq!(st.history.undo.len(), undo + 1, "one move, one undo step");
|
||
}
|
||
|
||
/// One frame of keyboard handling, canvas and the end-of-pass IME observation, as the app runs
|
||
/// them; returns the IME output.
|
||
fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<egui::output::IMEOutput> {
|
||
VectorcraftApp::install_ime_reset_hooks(ctx);
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::ime::begin_ui(app, ui.ctx());
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
show(app, ui);
|
||
crate::ime::end_of_pass(app, ui.ctx());
|
||
crate::ime::finish_ui(app, ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
out.platform_output.ime
|
||
}
|
||
|
||
fn preedit(t: &str, chars: usize) -> egui::Event {
|
||
egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) })
|
||
}
|
||
|
||
fn plain(app: &VectorcraftApp, id: u64) -> String {
|
||
match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||
NodeKind::Text(t) => t.plain_text(),
|
||
_ => panic!("not text"),
|
||
}
|
||
}
|
||
|
||
/// #2826: the app ends a move released over a panel before the panels draw, so a panel takes
|
||
/// the art as it was, and no move is left open.
|
||
#[test]
|
||
fn a_move_released_over_a_panel_ends_before_the_panels_draw() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let button =
|
||
|pos: Pos2, pressed: bool| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some());
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||
events: vec![button(pos2(850.0, 100.0), false)],
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::canvas::release_over_panel(&mut app, ui.ctx());
|
||
assert!(app.session.active().unwrap().interaction.is_none(), "the move is ended");
|
||
assert_eq!(*app.session.active().unwrap().doc, *before, "the art is back where it was");
|
||
assert_eq!(
|
||
egui::DragAndDrop::payload::<widgets::PanelDrag>(ui.ctx()).as_deref(),
|
||
Some(&widgets::PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])),
|
||
"the panel still gets the art"
|
||
);
|
||
assert!(ui.ctx().data(|d| d.get_temp::<Drag>(drag_id())).is_none(), "no drag is left");
|
||
});
|
||
out.textures_delta.clear();
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||
}
|
||
|
||
#[test]
|
||
fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
// The type placed starts empty (Fill New Type Objects With Placeholder Text off).
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// Not editing: the IME stays off (single-key tool shortcuts keep working).
|
||
assert!(typing_frame(&mut app, &ctx, vec![]).is_none());
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
assert!(app.session.tool_wants_text());
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
// Editing: the IME is allowed, its window at the caret (the text's baseline at y = 100).
|
||
let ime = typing_frame(&mut app, &ctx, vec![]).expect("IME allowed while editing");
|
||
assert!(ime.rect.contains(pos2(ime.rect.center().x, at.y)) && (ime.rect.center().x - at.x).abs() < 2.0, "{:?} vs {at:?}", ime.rect);
|
||
assert!(!ime.should_interrupt_composition);
|
||
// gagaku → がが → 雅楽, then commit (the macOS sequence).
|
||
typing_frame(&mut app, &ctx, vec![preedit("g", 1)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("が", 1), preedit("がg", 2)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("ががく", 3)]);
|
||
assert_eq!(plain(&app, id), "ががく");
|
||
// While composing: plain text events, the clipboard and Undo (the native menu's ⌘Z
|
||
// reaches `menus::invoke`) all wait.
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("x".into()), egui::Event::Paste("y".into())]);
|
||
crate::menus::invoke(&mut app, "edit.undo", json!({}));
|
||
assert!(!crate::menus::enabled(&app, "edit.undo"));
|
||
assert_eq!(plain(&app, id), "ががく");
|
||
let ime2 = typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })])
|
||
.unwrap();
|
||
assert!(ime2.rect.center().x <= ime.rect.center().x + 1.0, "the window stays at the clause being converted");
|
||
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅楽".into()))]);
|
||
assert_eq!(plain(&app, id), "雅楽");
|
||
assert!(!app.session.tool_composing());
|
||
// After the commit, text and Undo work again.
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]);
|
||
assert_eq!(plain(&app, id), "雅楽!");
|
||
crate::menus::invoke(&mut app, "edit.undo", json!({}));
|
||
assert_eq!(plain(&app, id), "");
|
||
}
|
||
|
||
/// Vietnamese Telex via the Windows IME (TSF): the preedit grows digit by digit, then an empty
|
||
/// preedit plus a single Commit lands the amount once, not duplicated.
|
||
#[test]
|
||
fn vietnamese_telex_digits_commit_once() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("3", 1)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("31", 2)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("316", 3)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("3160", 4)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("3160".into()))]);
|
||
assert_eq!(plain(&app, id), "3160");
|
||
assert!(!app.session.tool_composing());
|
||
}
|
||
|
||
/// Keyboard-hook IMEs (UniKey/OpenKey) type Vietnamese by injecting VK_PACKET characters and
|
||
/// Backspaces, which egui surfaces as interleaved Event::Text and Event::Key(Backspace). When a
|
||
/// tone key fires, the Backspaces and the re-injected characters arrive in one frame; the Type
|
||
/// tool must apply them in order to build "tiếng" (captured from a real UniKey session), not
|
||
/// insert every character first and delete afterwards.
|
||
#[test]
|
||
fn hook_ime_backspace_and_reinject_builds_vietnamese() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
let txt = |s: &str| egui::Event::Text(s.into());
|
||
let bs = || egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||
// When a tone key fires, the Backspaces and the re-injected VK_PACKET characters land
|
||
// together in one poll (one egui frame).
|
||
typing_frame(&mut app, &ctx, vec![txt("t")]);
|
||
typing_frame(&mut app, &ctx, vec![txt("i")]);
|
||
typing_frame(&mut app, &ctx, vec![txt("e")]);
|
||
typing_frame(&mut app, &ctx, vec![bs(), txt("ê")]); // 2nd "e" -> ê
|
||
typing_frame(&mut app, &ctx, vec![txt("n")]);
|
||
typing_frame(&mut app, &ctx, vec![txt("g")]);
|
||
typing_frame(&mut app, &ctx, vec![bs(), bs(), bs(), txt("ế"), txt("n"), txt("g")]); // "s" tone
|
||
assert_eq!(plain(&app, id), "tiếng");
|
||
}
|
||
|
||
/// Clear History waits while an IME composes, as Undo does: the marked text and the steps stay.
|
||
#[test]
|
||
fn clear_history_waits_while_the_ime_composes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||
assert!(app.session.tool_composing());
|
||
assert!(!crate::menus::enabled(&app, "edit.clearHistory"));
|
||
crate::menus::invoke(&mut app, "edit.clearHistory", json!({}));
|
||
assert!(app.session.tool_composing(), "the composition goes on");
|
||
assert!(!app.session.active().unwrap().history.undo.is_empty(), "the steps stay");
|
||
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅".into()))]);
|
||
assert_eq!(plain(&app, id), "雅");
|
||
assert!(crate::menus::enabled(&app, "edit.clearHistory"));
|
||
}
|
||
|
||
#[test]
|
||
fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let other = app.session.execute("text.create", &json!({"x": 300, "y": 250, "text": "Other", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(310.0, 245.0)));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(a, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]);
|
||
// macOS sends nothing for a click away; the tool keeps the marked text and the IME is
|
||
// told to drop its composition. A press on other type edits that type at once.
|
||
let ime = typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
|
||
assert_eq!(plain(&app, id), "しょうこ");
|
||
assert!(!app.session.tool_composing());
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]);
|
||
let ime = ime.expect("editing the other text");
|
||
assert!(ime.should_interrupt_composition);
|
||
let request_id = ime.reset_request.expect("the final destination publishes its reset request");
|
||
assert!(app.ime_marked.is_none());
|
||
assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text");
|
||
typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||
// Interrupted once, not every frame.
|
||
assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition);
|
||
assert!(!app.take_ime_discard());
|
||
// A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks
|
||
// for no IME, and the host tells the system IME to drop its composition.
|
||
let blank = xf.to_screen(Point::new(150.0, 200.0));
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::ImeResetCompleted { request_id }, preedit("が", 1)]);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]);
|
||
let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]);
|
||
assert!(plain(&app, other).contains('が'), "{}", plain(&app, other));
|
||
assert!(!app.session.tool_composing() && !app.session.tool_wants_text());
|
||
assert!(ime.is_none());
|
||
assert!(app.ime_marked.is_none());
|
||
assert!(app.take_ime_discard());
|
||
}
|
||
|
||
#[test]
|
||
fn ime_edge_orders_never_eat_committed_text_or_lose_the_composition() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("雅".into())]);
|
||
let backspace =
|
||
|pressed| egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||
// The IME empties its marked text and the Backspace comes in the same frame.
|
||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("", 0), backspace(true), backspace(false)]);
|
||
assert_eq!(plain(&app, id), "雅", "the committed character stays");
|
||
// A bare line-break commit ends the composition with the marked text, no newline.
|
||
typing_frame(&mut app, &ctx, vec![preedit("がく", 2)]);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("\n".into()))]);
|
||
assert!(!app.session.tool_composing());
|
||
assert_eq!(plain(&app, id), "雅がく");
|
||
assert!(app.take_ime_discard());
|
||
// Switching apps mid-composition: the composition goes on when the window comes back.
|
||
typing_frame(&mut app, &ctx, vec![preedit("らく", 2)]);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(false)]);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(true), preedit("らくか", 3)]);
|
||
assert!(app.session.tool_composing());
|
||
assert!(!app.take_ime_discard());
|
||
assert_eq!(plain(&app, id), "雅がくらくか");
|
||
}
|
||
|
||
/// Reset to a blank document, select the Type tool and click document point (100, 100), returning
|
||
/// the screen position used by IME fixtures.
|
||
fn type_at(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 {
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
typing_frame(app, ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(app, ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(app, ctx, vec![click(at, false)]);
|
||
at
|
||
}
|
||
|
||
/// The outgoing descriptor carries the committed logical text and UTF-8 offsets, keeps a
|
||
/// reversed selection reversed, and labels only accepted IME mutations `InputMethod`.
|
||
#[test]
|
||
fn ime_output_carries_text_offsets_and_explicit_cause() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
let empty = typing_frame(&mut app, &ctx, vec![]).expect("the Type tool owns the IME");
|
||
let payload = empty.surrounding_text.available().expect("an empty document is available");
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0));
|
||
assert_eq!(empty.change_cause, egui::output::ImeChangeCause::Other);
|
||
|
||
let typed = typing_frame(&mut app, &ctx, vec![egui::Event::Text("Héllo".into())]).unwrap();
|
||
let payload = typed.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 6, 6));
|
||
assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other, "ordinary typing is application-origin");
|
||
|
||
let shift_left =
|
||
|| egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT };
|
||
// "Héllo" has five characters: four Shift+Lefts stop at the start of its two-byte é,
|
||
// leaving cursor 1 < anchor 6.
|
||
let mut reversed = None;
|
||
for _ in 0..4 {
|
||
reversed = typing_frame(&mut app, &ctx, vec![shift_left()]);
|
||
}
|
||
let reversed = reversed.unwrap();
|
||
let payload = reversed.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 1, 6), "cursor > anchor stays reversed");
|
||
|
||
// One accepted IME deletion (one byte before the selection) then the commit: both receipts
|
||
// chain, and the commit still replaces the preserved selection ("éllo").
|
||
let ime = typing_frame(
|
||
&mut app,
|
||
&ctx,
|
||
vec![
|
||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }),
|
||
egui::Event::Ime(egui::ImeEvent::Commit("Z".into())),
|
||
],
|
||
)
|
||
.unwrap();
|
||
let payload = ime.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Z", 1, 1));
|
||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||
|
||
let ordinary = typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]).unwrap();
|
||
assert_eq!(ordinary.change_cause, egui::output::ImeChangeCause::Other, "typing after a commit is Other");
|
||
}
|
||
|
||
/// A pre-edit is excluded from the outgoing text; committing the composition is the accepted
|
||
/// IME mutation that earns `InputMethod`.
|
||
#[test]
|
||
fn ime_output_excludes_the_preedit_and_marks_the_commit() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]);
|
||
let marked = typing_frame(&mut app, &ctx, vec![preedit("xy", 2)]).unwrap();
|
||
let payload = marked.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("ab", 2, 2), "the marked text is not committed text");
|
||
let committed = typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("xy".into()))]).unwrap();
|
||
let payload = committed.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abxy", 4, 4));
|
||
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||
}
|
||
|
||
/// An application edit anywhere in the frame dominates an IME receipt in it.
|
||
#[test]
|
||
fn ime_output_marks_mixed_application_and_ime_mutations_other() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]);
|
||
let ime = typing_frame(
|
||
&mut app,
|
||
&ctx,
|
||
vec![
|
||
egui::Event::Text("x".into()),
|
||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }),
|
||
egui::Event::Ime(egui::ImeEvent::Commit("y".into())),
|
||
],
|
||
)
|
||
.unwrap();
|
||
let payload = ime.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("aby", 3, 3));
|
||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other, "application edit before the IME dominates");
|
||
|
||
let pasted = typing_frame(&mut app, &ctx, vec![egui::Event::Paste("c".into())]).unwrap();
|
||
let payload = pasted.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abyc", 4, 4));
|
||
assert_eq!(pasted.change_cause, egui::output::ImeChangeCause::Other, "paste is application-origin");
|
||
|
||
// The other order: an accepted IME commit followed by an application edit in the same
|
||
// frame is still `Other`.
|
||
let ime_then_text =
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("y".into())), egui::Event::Text("x".into())]).unwrap();
|
||
let payload = ime_then_text.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abycyx", 6, 6));
|
||
assert_eq!(ime_then_text.change_cause, egui::output::ImeChangeCause::Other, "application edit after the IME dominates");
|
||
}
|
||
|
||
/// Leaving and re-entering the same text object keeps the owner but starts a new generation.
|
||
#[test]
|
||
fn ime_owner_replacement_starts_a_new_generation() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Same", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let center = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().center();
|
||
let at = xf.to_screen(center);
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let first = typing_frame(&mut app, &ctx, vec![]).expect("the text is being edited");
|
||
assert_eq!(first.owner, egui::Id::unique(("vectorcraft-type-ime", app.session.active().unwrap().uid, id)));
|
||
let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![escape(true)]);
|
||
typing_frame(&mut app, &ctx, vec![escape(false)]);
|
||
assert!(typing_frame(&mut app, &ctx, vec![]).is_none(), "Escape ends the edit context");
|
||
app.select_tool("type");
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let second = typing_frame(&mut app, &ctx, vec![]).expect("re-entering starts another context");
|
||
assert_eq!(second.owner, first.owner, "the owner key is the document and text object");
|
||
assert_ne!(second.context_generation, first.context_generation, "a replacement epoch resets once");
|
||
}
|
||
|
||
/// One tracker serves several native viewports: each gets its own generation for the same
|
||
/// editor, and an unchanged second pass in either keeps it.
|
||
#[test]
|
||
fn ime_context_is_scoped_per_viewport() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
let aux = egui::ViewportId::from_hash_of("vectorcraft-ime-aux");
|
||
let frame = |app: &mut VectorcraftApp, viewport: egui::ViewportId| {
|
||
let mut raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||
viewport_id: viewport,
|
||
..Default::default()
|
||
};
|
||
raw.viewports.entry(viewport).or_default();
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
show(app, ui);
|
||
crate::ime::end_of_pass(app, ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
out.platform_output.ime.expect("the Type tool owns the IME in both viewports")
|
||
};
|
||
let root = egui::ViewportId::ROOT;
|
||
let root_first = frame(&mut app, root);
|
||
let aux_first = frame(&mut app, aux);
|
||
assert_ne!(root_first.context_generation, aux_first.context_generation, "viewport state must not collide");
|
||
let root_second = frame(&mut app, root);
|
||
let aux_second = frame(&mut app, aux);
|
||
assert_eq!(root_second.context_generation, root_first.context_generation);
|
||
assert_eq!(aux_second.context_generation, aux_first.context_generation);
|
||
}
|
||
|
||
/// A minimized/occluded viewport has no IME owner: the descriptor disappears, and the next
|
||
/// visible pass starts a new generation instead of inheriting the old context.
|
||
#[test]
|
||
fn hidden_viewport_observes_no_ime_owner() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
let root = egui::ViewportId::ROOT;
|
||
let visible = typing_frame(&mut app, &ctx, vec![]).unwrap();
|
||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
raw.viewports.entry(root).or_default().minimized = Some(true);
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::shortcuts::handle(&mut app, ui.ctx());
|
||
show(&mut app, ui);
|
||
crate::ime::end_of_pass(&mut app, ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(out.platform_output.ime.is_none(), "a hidden viewport has no IME owner");
|
||
let again = typing_frame(&mut app, &ctx, vec![]).expect("the visible editor comes back");
|
||
assert_ne!(again.context_generation, visible.context_generation, "refocus starts a new generation");
|
||
}
|
||
|
||
/// A frame that is discarded and drawn again sees the mutation in both passes, and its
|
||
/// unchanged second pass keeps the first pass's `InputMethod` cause and generation.
|
||
#[test]
|
||
fn ime_observation_runs_in_every_pass_of_a_discarded_frame() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
let mut first = true;
|
||
let mut seen = vec![];
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
if first {
|
||
first = false;
|
||
let view = app.view_info();
|
||
let target = crate::ime::target(&mut app.session, view);
|
||
let before = crate::ime::stamp(&mut app.session, view);
|
||
app.session.tool_text("x", view).unwrap();
|
||
crate::ime::note_ime_apply(&mut app, ui.ctx(), view, target, before);
|
||
ui.ctx().request_discard("ime observation second pass");
|
||
}
|
||
crate::shortcuts::handle(&mut app, ui.ctx());
|
||
show(&mut app, ui);
|
||
crate::ime::end_of_pass(&mut app, ui.ctx());
|
||
seen.push(ui.ctx().output(|o| o.ime.clone()).expect("IME output in this pass"));
|
||
});
|
||
out.textures_delta.clear();
|
||
assert_eq!(seen.len(), 2, "the frame really ran two passes");
|
||
assert_eq!(seen[0].context_generation, seen[1].context_generation, "the discarded pass is not a new context");
|
||
assert_eq!(seen[0].change_cause, egui::output::ImeChangeCause::InputMethod);
|
||
assert_eq!(seen[1].change_cause, egui::output::ImeChangeCause::InputMethod, "an unchanged pass retains the frame cause");
|
||
let payload = seen[1].surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("x", 1, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn tool_drag_moved_without_pointer_delta() {
|
||
let ctx = egui::Context::default();
|
||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
|
||
let v = View::default();
|
||
let xf = Xf::new(rect, &v);
|
||
let a = pos2(100.0, 100.0);
|
||
let b = pos2(150.0, 120.0);
|
||
let mut first = false;
|
||
let mut second = false;
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| {
|
||
let pointer = ui.input(|i| i.pointer.clone());
|
||
first = tool_drag_moved(ui, &xf, a, &pointer);
|
||
second = tool_drag_moved(ui, &xf, a, &pointer);
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(first, "the first drag sample at a position runs");
|
||
assert!(!second, "the same position without a delta is not sent again");
|
||
let mut moved = false;
|
||
out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| {
|
||
let pointer = ui.input(|i| i.pointer.clone());
|
||
moved = tool_drag_moved(ui, &xf, b, &pointer);
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(moved, "a new document position runs even when egui reports no delta");
|
||
}
|
||
|
||
/// #834 review: a press held still is not a drag. Before the press has dragged, the first held
|
||
/// frame without a delta at the press's own position sends nothing, so a click stays a click;
|
||
/// the first move after it does.
|
||
#[test]
|
||
fn stationary_press_is_not_a_drag() {
|
||
let ctx = egui::Context::default();
|
||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
|
||
let v = View::default();
|
||
let xf = Xf::new(rect, &v);
|
||
let a = pos2(100.0, 100.0);
|
||
let b = pos2(150.0, 120.0);
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| {
|
||
ui.data_mut(|d| {
|
||
d.remove::<Point>(tool_drag_doc_id());
|
||
d.insert_temp(tool_press_doc_id(), xf.to_doc(a));
|
||
});
|
||
});
|
||
out.textures_delta.clear();
|
||
let mut held = true;
|
||
out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
|
||
let pointer = ui.input(|i| i.pointer.clone());
|
||
assert_eq!(pointer.delta(), egui::Vec2::ZERO);
|
||
held = tool_drag_moved(ui, &xf, a, &pointer);
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(!held, "a press held still at its own position is not sent as a drag");
|
||
let mut moved = false;
|
||
out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| {
|
||
let pointer = ui.input(|i| i.pointer.clone());
|
||
moved = tool_drag_moved(ui, &xf, b, &pointer);
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(moved, "the first move after the press is a drag");
|
||
}
|
||
|
||
/// #834: a bezier drag's preview follows every position the pointer is seen at. egui's frame
|
||
/// delta is zero in the frame the pointer comes back into the window after leaving it
|
||
/// (`PointerGone` forgets where it was), so a drag gated on it kept the preview where the
|
||
/// pointer left until the next move; a move in the release's own frame was lost the same way,
|
||
/// and the release committed the stale shape. A click's jitter in its release frame stays a
|
||
/// click.
|
||
#[test]
|
||
fn a_bezier_drag_preview_follows_the_pointer_back_into_the_window_and_to_the_release() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||
let near = |p: Point, q: Pos2| p.distance(xf.to_doc(q)) < 1e-3;
|
||
let first_anchor = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0]
|
||
};
|
||
// The Pen pulls a handle out of a new anchor: the pointer leaves the window mid-drag, comes
|
||
// back further on and stops there, then moves once more in the frame it lets go.
|
||
app.select_tool("pen");
|
||
let a = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(a)]);
|
||
frame(&mut app, &ctx, vec![button(a, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(40.0, 0.0))]);
|
||
assert!(near(first_anchor(&app).h_out, a + vec2(40.0, 0.0)), "{:?}", first_anchor(&app));
|
||
frame(&mut app, &ctx, vec![Event::PointerGone]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(80.0, 0.0))]);
|
||
assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "back in the window: {:?}", first_anchor(&app));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "still: {:?}", first_anchor(&app));
|
||
let end = a + vec2(100.0, 20.0);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
|
||
assert!(near(first_anchor(&app).h_out, end), "released: {:?}", first_anchor(&app));
|
||
assert!(app.session.active().unwrap().interaction.is_none(), "committed");
|
||
// Direct Selection moving that anchor: the same path through the engine's previews.
|
||
app.select_tool("directSelection");
|
||
let anchor_at = xf.to_screen(first_anchor(&app).p);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at)]);
|
||
frame(&mut app, &ctx, vec![button(anchor_at, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(30.0, 30.0))]);
|
||
assert!(near(first_anchor(&app).p, anchor_at + vec2(30.0, 30.0)), "{:?}", first_anchor(&app));
|
||
frame(&mut app, &ctx, vec![Event::PointerGone]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(60.0, 30.0))]);
|
||
assert!(near(first_anchor(&app).p, anchor_at + vec2(60.0, 30.0)), "back in the window: {:?}", first_anchor(&app));
|
||
let end = anchor_at + vec2(60.0, 50.0);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
|
||
assert!(near(first_anchor(&app).p, end), "released: {:?}", first_anchor(&app));
|
||
// A click whose release frame brings a little jitter is still a click: the Pen places a
|
||
// corner anchor (no handle pulled out by the jitter) and the anchor stays where pressed.
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("pen");
|
||
let c = app.canvas_rect.unwrap().center() + vec2(120.0, 80.0);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(c)]);
|
||
frame(&mut app, &ctx, vec![button(c, true), Event::PointerMoved(c + vec2(0.6, 0.2)), button(c + vec2(0.6, 0.2), false)]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
// (egui hands the press the frame's last pointer position, so it lands within the jitter.)
|
||
let a = first_anchor(&app);
|
||
assert!(a.p.distance(xf.to_doc(c)) < 1.0 && a.h_out == a.p && a.h_in == a.p, "corner anchor where pressed: {a:?}");
|
||
assert!(app.session.active().unwrap().interaction.is_none(), "committed");
|
||
}
|
||
|
||
#[test]
|
||
fn frame_moves_keeps_touch_feed_and_mouse_samples_with_their_pressure() {
|
||
let ctx = egui::Context::default();
|
||
let a = pos2(10.0, 20.0);
|
||
let b = pos2(20.0, 30.0);
|
||
let c = pos2(30.0, 40.0);
|
||
let events = vec![egui::Event::PointerMoved(a), egui::Event::PointerMoved(b), egui::Event::PointerMoved(c)];
|
||
let mut mouse = vec![];
|
||
let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| mouse = frame_moves(ui, None));
|
||
out.textures_delta.clear();
|
||
// A mouse keeps its moves too, without a pressure.
|
||
assert_eq!(mouse, [(a, None), (b, None)], "the last move follows the normal pointer path");
|
||
// A macOS pen: the feed kept a pressure for each move, the last for the last.
|
||
let feed = StylusFeed::default();
|
||
for p in [0.9, 0.3, 0.0, 0.5] {
|
||
feed.set_move(crate::stylus::PenSample { pressure: p, eraser: false });
|
||
}
|
||
let mut samples = vec![];
|
||
let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed)));
|
||
out.textures_delta.clear();
|
||
assert_eq!(samples, [(a, Some(0.3)), (b, None)], "paired from the end; a lift's 0 is no pressure");
|
||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed)));
|
||
out.textures_delta.clear();
|
||
assert_eq!(samples, [(a, None), (b, None)], "the moves were taken: a mouse's moves again");
|
||
let events = [pen(egui::TouchPhase::Move, a, 0.2), pen(egui::TouchPhase::Move, b, 0.8), pen(egui::TouchPhase::Move, c, 0.6)].concat();
|
||
let mut samples = vec![];
|
||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None));
|
||
out.textures_delta.clear();
|
||
assert_eq!(samples, [(a, Some(0.2)), (b, Some(0.8))], "the last sample follows the normal pointer path");
|
||
let events = (0..200).flat_map(|n| pen(egui::TouchPhase::Move, pos2(n as f32, 0.0), 0.5)).collect();
|
||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None));
|
||
out.textures_delta.clear();
|
||
assert!(samples.len() <= 64, "a stalled frame stays bounded");
|
||
}
|
||
|
||
#[test]
|
||
fn long_touch_drops_a_tool_press_without_committing_art() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width":800,"height":600})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
let tick = |app: &mut VectorcraftApp, time, events| {
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||
time: Some(time),
|
||
events,
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
tick(&mut app, 0.0, vec![]);
|
||
let pos = app.canvas_rect.unwrap().center();
|
||
tick(&mut app, 0.1, pen(egui::TouchPhase::Start, pos, 0.5));
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Tool)));
|
||
tick(&mut app, 1.5, vec![]);
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Gesture)), "long touch drops the tool drag");
|
||
assert!(app.session.active().unwrap().interaction.is_none());
|
||
tick(&mut app, 1.6, pen(egui::TouchPhase::End, pos, 0.5));
|
||
assert!(app.session.active().unwrap().doc.art_bounds().is_none(), "releasing cannot commit the cancelled press");
|
||
}
|
||
|
||
/// #2826: a second finger drops a move waiting over a panel, so its interaction doesn't
|
||
/// outlive the drag that began it.
|
||
#[test]
|
||
fn a_second_finger_drops_a_move_waiting_over_a_panel() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.touch_gestures = true;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
// One finger presses on the art, moves it, and leaves the canvas for a panel.
|
||
frame(&mut app, &ctx, pen(egui::TouchPhase::Start, start, 0.5));
|
||
frame(&mut app, &ctx, pen(egui::TouchPhase::Move, start + vec2(40.0, 0.0), 0.5));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Art)));
|
||
assert!(app.session.active().unwrap().interaction.is_some());
|
||
assert!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).is_some());
|
||
// A second finger lands: the move is dropped, not left live without its pointer.
|
||
let second = egui::Event::Touch {
|
||
device_id: egui::TouchDeviceId(1),
|
||
id: egui::TouchId(2),
|
||
phase: egui::TouchPhase::Start,
|
||
pos: pos2(400.0, 300.0),
|
||
force: Some(0.5),
|
||
};
|
||
frame(&mut app, &ctx, vec![second]);
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Gesture)));
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.interaction.is_none(), "the move is dropped");
|
||
assert_eq!(*st.doc, *before, "the art is back where it was");
|
||
assert!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).is_none(), "no panel drag is left");
|
||
// Lifting the first finger commits nothing.
|
||
frame(&mut app, &ctx, pen(egui::TouchPhase::End, start + vec2(40.0, 0.0), 0.5));
|
||
assert_eq!(*app.session.active().unwrap().doc, *before);
|
||
}
|
||
|
||
/// The events eframe's winit integration sends for a pen (Windows Ink) or a finger: a touch
|
||
/// with its pressure, and the mouse it stands in for (#491).
|
||
fn pen(phase: egui::TouchPhase, pos: Pos2, force: f32) -> Vec<egui::Event> {
|
||
let touch = egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: Some(force) };
|
||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
match phase {
|
||
egui::TouchPhase::Start => vec![touch, egui::Event::PointerMoved(pos), button(true)],
|
||
egui::TouchPhase::Move => vec![touch, egui::Event::PointerMoved(pos)],
|
||
egui::TouchPhase::End | egui::TouchPhase::Cancel => vec![touch, button(false), egui::Event::PointerGone],
|
||
}
|
||
}
|
||
|
||
/// A pen stroke across `pts` (document points), one frame per sample, with its pressures.
|
||
fn pen_stroke(app: &mut VectorcraftApp, ctx: &egui::Context, pts: &[(f64, f64, f32)]) {
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let last = pts.len() - 1;
|
||
for (i, &(x, y, force)) in pts.iter().enumerate() {
|
||
let phase = if i == 0 { egui::TouchPhase::Start } else { egui::TouchPhase::Move };
|
||
frame(app, ctx, pen(phase, xf.to_screen(Point::new(x, y)), force));
|
||
if i == last {
|
||
frame(app, ctx, pen(egui::TouchPhase::End, xf.to_screen(Point::new(x, y)), force));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #491: a pen or a finger draws as the mouse does, and its pressure reaches the tools that
|
||
/// use it; the pointer leaving with the lift doesn't move the stroke's end.
|
||
#[test]
|
||
fn pen_and_touch_input_drive_the_tools_with_their_pressure() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("pencil");
|
||
pen_stroke(&mut app, &ctx, &[(100.0, 100.0, 0.5), (140.0, 120.0, 0.6), (180.0, 150.0, 0.7), (220.0, 200.0, 0.4)]);
|
||
let b = app.session.active().unwrap().doc.art_bounds().expect("the pen drew a path");
|
||
assert!(b.x0 > 90.0 && b.y0 > 90.0 && b.x1 < 230.0 && b.y1 < 210.0 && b.x1 > 200.0, "the stroke stays where the pen went: {b:?}");
|
||
|
||
// Bloat with Use Pressure Pen: each sample carries the pen's pressure.
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap();
|
||
app.select_tool("bloat");
|
||
app.session.set_tool_option("usePressure", &json!(true));
|
||
pen_stroke(&mut app, &ctx, &[(500.0, 350.0, 0.2), (500.0, 400.0, 0.5), (500.0, 420.0, 0.8)]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "object.liquify");
|
||
let pressures: Vec<f64> = p["points"].as_array().unwrap().iter().map(|s| (s[2].as_f64().unwrap() * 100.0).round() / 100.0).collect();
|
||
assert_eq!(pressures.first(), Some(&0.2), "the press's own pressure: {pressures:?}");
|
||
assert!(pressures.contains(&0.5) && pressures.contains(&0.8), "{pressures:?}");
|
||
}
|
||
|
||
/// #764: on Linux under X11 a pen reaches egui as a mouse, and the host reads its pressure
|
||
/// itself (`Services::stylus`): the Paintbrush and Liquify's Use Pressure Pen get that
|
||
/// pressure, and once a mouse moves (`None`) a stroke presses fully.
|
||
#[test]
|
||
fn the_hosts_pen_pressure_drives_the_tools_under_mouse_events() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let feed = StylusFeed::default();
|
||
app.services.stylus = Some(feed.clone());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
// One frame per sample: the reader has set the pressure before the frame's mouse events.
|
||
let stroke = |app: &mut VectorcraftApp, pts: &[(f64, f64, Option<f32>)]| {
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let mut at = Pos2::ZERO;
|
||
for (i, &(x, y, pressure)) in pts.iter().enumerate() {
|
||
feed.set(pressure);
|
||
at = xf.to_screen(Point::new(x, y));
|
||
let mut events = vec![egui::Event::PointerMoved(at)];
|
||
if i == 0 {
|
||
events.push(button(at, true));
|
||
}
|
||
frame(app, &ctx, events);
|
||
}
|
||
frame(app, &ctx, vec![button(at, false)]);
|
||
};
|
||
let pressures = |p: &serde_json::Value| -> Vec<f64> {
|
||
// A point without a third value was pressed fully.
|
||
let pressure = |s: &serde_json::Value| s.get(2).and_then(serde_json::Value::as_f64).map_or(1.0, |f| (f * 100.0).round() / 100.0);
|
||
p["points"].as_array().unwrap().iter().map(pressure).collect()
|
||
};
|
||
|
||
app.select_tool("paintbrush");
|
||
stroke(&mut app, &[(100.0, 100.0, Some(0.3)), (140.0, 120.0, Some(0.6)), (180.0, 150.0, Some(0.9))]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "path.freehand");
|
||
assert_eq!(pressures(&p), [0.3, 0.6, 0.9], "{p}");
|
||
// The lift reaches the feed before the frame of the stroke's last move: that move keeps the
|
||
// pressure the pen last touched with, not the lift's 0.
|
||
stroke(&mut app, &[(100.0, 300.0, Some(0.3)), (140.0, 320.0, Some(0.5)), (180.0, 350.0, Some(0.0))]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "path.freehand");
|
||
assert_eq!(pressures(&p), [0.3, 0.5, 0.5], "{p}");
|
||
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap();
|
||
app.select_tool("bloat");
|
||
app.session.set_tool_option("usePressure", &json!(true));
|
||
stroke(&mut app, &[(500.0, 350.0, Some(0.2)), (500.0, 400.0, Some(0.5)), (500.0, 420.0, Some(0.8))]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "object.liquify");
|
||
let pen = pressures(&p);
|
||
assert_eq!(pen.first(), Some(&0.2), "the press's own pressure: {pen:?}");
|
||
assert!(pen.contains(&0.5) && pen.contains(&0.8), "{pen:?}");
|
||
|
||
// A mouse moved last: no pen pressure.
|
||
stroke(&mut app, &[(350.0, 350.0, None), (350.0, 400.0, None), (350.0, 420.0, None)]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "object.liquify");
|
||
assert!(pressures(&p).iter().all(|f| *f == 1.0), "{p}");
|
||
|
||
// A tap whose touch and lift both reach the feed before the frame that handles its press
|
||
// presses with the tap's pressure, not the lift's 0.
|
||
for p in [0.0, 0.4, 0.7, 0.0] {
|
||
feed.set(Some(p));
|
||
}
|
||
let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(450.0, 400.0));
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]);
|
||
frame(&mut app, &ctx, vec![button(false)]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "object.liquify");
|
||
assert_eq!(pressures(&p).first(), Some(&0.7), "{p}");
|
||
}
|
||
|
||
/// An app with an 800 × 600 document whose host reads the pen itself (`Services::stylus`),
|
||
/// after its first frame, with the pointer in the window.
|
||
fn pen_app() -> (VectorcraftApp, StylusFeed, egui::Context) {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let feed = StylusFeed::default();
|
||
app.services.stylus = Some(feed.clone());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))]);
|
||
(app, feed, ctx)
|
||
}
|
||
|
||
/// The pen turned to its eraser end, with `pressure`.
|
||
fn eraser_end(pressure: f32) -> Option<crate::stylus::PenSample> {
|
||
Some(crate::stylus::PenSample { pressure, eraser: true })
|
||
}
|
||
|
||
/// #764: turning the pen to its eraser end selects the Eraser, and turning it back to the tip
|
||
/// selects the tool before it, as the reference app's help describes ("Erase parts of a path
|
||
/// using a stylus pen"). Each turn acts once.
|
||
#[test]
|
||
fn the_eraser_end_selects_the_eraser_and_the_tip_brings_back_the_tool() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "the tip hovers");
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "the eraser end hovers");
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "the tip brings back the tool before");
|
||
assert_eq!(app.ui.group_tool.get(vectorcraft_tools::catalog::group_of("paintbrush").unwrap()).map(String::as_str), Some("paintbrush"));
|
||
|
||
// A turn acts once: a tool that becomes active while the eraser end stays in stays, and
|
||
// the tip doesn't replace a tool other than the Eraser.
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
app.session.select_tool("pencil", app.view_info()).unwrap();
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "pencil");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "pencil");
|
||
}
|
||
|
||
/// #764: the eraser end erases what it crosses, also when it comes in and presses in one
|
||
/// frame; the tool before it does not paint a stroke.
|
||
#[test]
|
||
fn the_eraser_end_erases() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 100, "height": 100})).unwrap();
|
||
app.select_tool("paintbrush");
|
||
let steps = app.session.journal.len();
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let [a, b, c] = [280.0, 350.0, 420.0].map(|x| xf.to_screen(Point::new(x, 250.0)));
|
||
feed.set_sample(eraser_end(0.6));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c)]);
|
||
frame(&mut app, &ctx, vec![button(c, false)]);
|
||
let cmds: Vec<&str> = app.session.journal.since(steps).iter().map(|(c, _)| c.as_str()).collect();
|
||
assert_eq!(cmds, ["path.eraseRegion"], "one Eraser step, no Paintbrush stroke");
|
||
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 2, "the rectangle cut in two");
|
||
}
|
||
|
||
/// #764: a turn of the pen while a press is held takes effect after the release.
|
||
#[test]
|
||
fn a_flip_during_a_press_waits_for_the_release() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let [a, b, c] = [100.0, 150.0, 200.0].map(|x| xf.to_screen(Point::new(x, 100.0)));
|
||
feed.set(Some(0.5));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]);
|
||
feed.set_sample(eraser_end(0.5));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c), button(c, false)]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush");
|
||
assert_eq!(app.session.journal.last().unwrap().0, "path.freehand", "the stroke ends with the Paintbrush");
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "the frame after the release");
|
||
}
|
||
|
||
/// #764: a tool chosen while the eraser end stands in (the toolbar and shortcuts call
|
||
/// `select_tool`; menus and MCP run `tool.select`) stays when the tip comes back.
|
||
#[test]
|
||
fn a_tool_chosen_while_the_eraser_end_stands_in_stays() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
app.select_tool("rectangle");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "rectangle");
|
||
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
app.run("tool.select", json!({"tool": "eraser"})).unwrap();
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "the Eraser chosen again stays too");
|
||
}
|
||
|
||
/// #764: a mouse moving while the eraser end stands in does not change the tool, and an Eraser
|
||
/// chosen before the eraser end comes in stays when the tip comes back.
|
||
#[test]
|
||
fn a_mouse_and_an_explicit_eraser_change_nothing() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
feed.set(None);
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "a mouse");
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
feed.set(None);
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "a mouse between the turns");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush");
|
||
|
||
app.select_tool("eraser");
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "chosen before the eraser end came in");
|
||
}
|
||
|
||
/// #764: the host reads the pen over every window. A turn made while the pointer is outside
|
||
/// this window takes effect when the pointer comes in, and a turn there and back does not
|
||
/// change the tool.
|
||
#[test]
|
||
fn a_turn_over_another_window_waits_for_the_pointer() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
let inside = egui::Event::PointerMoved(Pos2::new(400.0, 300.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerGone]);
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "the pointer is in another window");
|
||
frame(&mut app, &ctx, vec![inside.clone()]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "the pointer came in");
|
||
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerGone]);
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
frame(&mut app, &ctx, vec![inside.clone()]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush");
|
||
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerGone]);
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![inside]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "turned there and back outside");
|
||
}
|
||
|
||
/// #764: the tip brings back the Perspective Grid tool as it was, with a grid hidden by Hide
|
||
/// Grid still hidden (choosing the tool afresh shows the grid).
|
||
#[test]
|
||
fn the_tip_brings_back_the_perspective_grid_tool_without_showing_the_grid() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
let visible =
|
||
|app: &VectorcraftApp| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&app.session.active().unwrap().doc).visible;
|
||
app.select_tool("perspectiveGrid");
|
||
assert!(visible(&app));
|
||
app.session.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "perspectiveGrid");
|
||
assert!(!visible(&app), "the grid stays hidden");
|
||
}
|
||
|
||
/// #3328: every position a mouse reports within one frame reaches the Pencil, not only the
|
||
/// frame's last one, so a fast stroke keeps the hand's movement.
|
||
#[test]
|
||
fn a_mouse_stroke_keeps_every_move_of_a_frame() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("pencil");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y));
|
||
let button = |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||
let start = at(100.0, 100.0);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
// One frame of a fast stroke: a zigzag of 20 moves, 6 pt apart.
|
||
let zigzag: Vec<_> = (1..=20).map(|i| at(100.0 + 6.0 * f64::from(i), if i % 2 == 0 { 100.0 } else { 112.0 })).collect();
|
||
frame(&mut app, &ctx, zigzag.iter().map(|p| egui::Event::PointerMoved(*p)).collect());
|
||
let end = *zigzag.last().unwrap();
|
||
frame(&mut app, &ctx, vec![button(end, false)]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "path.freehand");
|
||
let pts = p["points"].as_array().unwrap();
|
||
let expected: Vec<_> = std::iter::once(start)
|
||
.chain(zigzag.iter().copied())
|
||
.map(|p| {
|
||
let p = xf.to_doc(p);
|
||
json!([p.x, p.y])
|
||
})
|
||
.collect();
|
||
assert_eq!(pts, expected.as_slice(), "the stroke keeps every move of the frame, in order");
|
||
}
|
||
|
||
/// A frame whose moves end where the last frame's did still reaches the Pencil: the stroke went
|
||
/// out and came back in between.
|
||
#[test]
|
||
fn a_mouse_stroke_keeps_a_frame_that_ends_where_it_started() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("pencil");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y));
|
||
let button = |p, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||
let (start, a) = (at(100.0, 100.0), at(160.0, 100.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]);
|
||
// Out to a bump and back to `a`, all in one frame.
|
||
let bump = [at(170.0, 80.0), at(180.0, 70.0), at(170.0, 80.0), a];
|
||
frame(&mut app, &ctx, bump.iter().map(|p| egui::Event::PointerMoved(*p)).collect());
|
||
frame(&mut app, &ctx, vec![button(a, false)]);
|
||
let (_, p) = app.session.journal.last().unwrap().clone();
|
||
let n = p["points"].as_array().unwrap().len();
|
||
assert!(n >= 5, "the bump's moves are kept: {n} points");
|
||
}
|
||
|
||
/// Only a tool that builds a stroke from the drag's samples gets every move of a frame (#4227):
|
||
/// the Selection tool and the other tools get one drag a frame, at the pointer's position, so a
|
||
/// costly drag preview runs once.
|
||
#[test]
|
||
fn only_the_freehand_tools_get_every_move_of_a_frame() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let events: Vec<_> = (0..20).map(|i| egui::Event::PointerMoved(pos2(100.0 + i as f32 * 5.0, 100.0))).collect();
|
||
for (tool, every) in
|
||
[("pencil", true), ("paintbrush", true), ("blobBrush", true), ("selection", false), ("directSelection", false), ("width", false)]
|
||
{
|
||
app.select_tool(tool);
|
||
let mut moves = vec![];
|
||
let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| moves = tool_frame_moves(ui, &app));
|
||
out.textures_delta.clear();
|
||
assert_eq!(moves.len(), if every { 19 } else { 0 }, "{tool}");
|
||
}
|
||
}
|
||
|
||
/// #491: a pen tap whose press and lift come in one frame selects what it tapped, and the
|
||
/// lift (the pointer gone with it) doesn't drag the selection to the canvas's centre.
|
||
#[test]
|
||
fn a_pen_tap_within_one_frame_clicks_where_it_tapped() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let before = app.session.active().unwrap().doc.art_bounds();
|
||
let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
|
||
let mut tap = pen(egui::TouchPhase::Start, at, 0.5);
|
||
tap.extend(pen(egui::TouchPhase::End, at, 0.5));
|
||
frame(&mut app, &ctx, tap);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let doc = app.session.active().unwrap();
|
||
assert_eq!(doc.selection.objects, vec![vectorcraft_doc::NodeId(id)]);
|
||
assert_eq!(doc.doc.art_bounds(), before, "the tap moved nothing");
|
||
}
|
||
|
||
/// The Contextual Task Bar shows a Stroke chip beside the Fill chip, drawn with the selection's
|
||
/// stroke paint. A click on it brings the Stroke proxy forward and opens Swatches, as a click on
|
||
/// the Fill chip does for the Fill proxy.
|
||
#[test]
|
||
fn the_task_bar_shows_a_stroke_chip_beside_fill() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||
app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap();
|
||
app.session.execute("paint.setStroke", &json!({"ids": [id], "color": "#2244cc"})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
// Past the bar's fade-in, so its chips draw at full opacity.
|
||
for _ in 0..30 {
|
||
frame(&mut app, &ctx, vec![]);
|
||
}
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui));
|
||
out.textures_delta.clear();
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
// Right to left: the frame's margin, More Options, Lock, Recolor, then Stroke and Fill.
|
||
let stroke_x = bar.right() - 8.0 - 3.0 * (30.0 + 6.0) - 13.0;
|
||
let fill_x = stroke_x - 26.0 - 6.0;
|
||
let blue = Color32::from_rgb(0x22, 0x44, 0xcc);
|
||
let chip_with = |x: f32| {
|
||
out.shapes.iter().any(|c| match &c.shape {
|
||
egui::Shape::Rect(r) => r.fill == blue && bar.contains(r.rect.center()) && (r.rect.center().x - x).abs() < 4.0,
|
||
_ => false,
|
||
})
|
||
};
|
||
assert!(chip_with(stroke_x), "the Stroke chip is drawn with the stroke color");
|
||
assert!(!chip_with(fill_x), "the Fill chip is not");
|
||
|
||
let click = |app: &mut VectorcraftApp, x: f32| {
|
||
let at = pos2(x, bar.center().y);
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
app.session.fill_active = true;
|
||
click(&mut app, stroke_x);
|
||
assert!(!app.session.fill_active, "the Stroke proxy is in front");
|
||
assert_eq!(app.ui.open_panel.as_deref(), Some("swatches"));
|
||
app.ui.open_panel = None;
|
||
click(&mut app, fill_x);
|
||
assert!(app.session.fill_active, "the Fill proxy is in front");
|
||
assert_eq!(app.ui.open_panel.as_deref(), Some("swatches"));
|
||
}
|
||
|
||
/// The Contextual Task Bar's Fill and Stroke chips show the paints the proxies show: a group's
|
||
/// painted art, and "?" when the selected objects' paints differ. A click on a chip shows the
|
||
/// Swatches panel wherever it is, closed included.
|
||
#[test]
|
||
fn the_task_bar_chips_follow_the_proxies_and_show_swatches_anywhere() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let mut square = |x: f64, stroke: &str| {
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": x, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||
app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap();
|
||
app.session.execute("paint.setStroke", &json!({"ids": [id], "color": stroke})).unwrap();
|
||
id
|
||
};
|
||
square(160.0, "#2244cc");
|
||
let second = square(220.0, "#22cc44");
|
||
app.select_tool("selection");
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let shapes = |app: &mut VectorcraftApp| {
|
||
for _ in 0..30 {
|
||
frame(app, &ctx, vec![]);
|
||
}
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
out.shapes
|
||
};
|
||
// Right to left: the frame's margin, More Options, Lock, Recolor, then Stroke and Fill.
|
||
let stroke_x = |bar: egui::Rect| bar.right() - 8.0 - 3.0 * (30.0 + 6.0) - 13.0;
|
||
let chip_with = |shapes: &[egui::epaint::ClippedShape], bar: egui::Rect, x: f32, color: Color32| {
|
||
shapes.iter().any(|c| match &c.shape {
|
||
egui::Shape::Rect(r) => r.fill == color && bar.contains(r.rect.center()) && (r.rect.center().x - x).abs() < 4.0,
|
||
_ => false,
|
||
})
|
||
};
|
||
let (blue, green) = (Color32::from_rgb(0x22, 0x44, 0xcc), Color32::from_rgb(0x22, 0xcc, 0x44));
|
||
|
||
// Two strokes that differ: "?" on white, as the proxy shows them.
|
||
let out = shapes(&mut app);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
assert!(chip_with(&out, bar, stroke_x(bar), Color32::WHITE), "the Stroke chip shows the strokes differ");
|
||
assert!(!chip_with(&out, bar, stroke_x(bar), blue), "the Stroke chip does not show the first object's stroke");
|
||
|
||
// A group shows its painted art's stroke, as the proxy does.
|
||
app.session.execute("paint.setStroke", &json!({"ids": [second], "color": "#2244cc"})).unwrap();
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.session.execute("object.group", &json!({})).unwrap();
|
||
let out = shapes(&mut app);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
assert!(chip_with(&out, bar, stroke_x(bar), blue), "the Stroke chip shows the group's art's stroke");
|
||
assert!(!chip_with(&out, bar, stroke_x(bar), green));
|
||
|
||
// Swatches closed: a click on the Stroke chip shows it.
|
||
app.run("window.panel.close", json!({"panel": "swatches"})).unwrap();
|
||
assert!(!crate::panel_docking::showing(&app.ui, "swatches"));
|
||
let at = pos2(stroke_x(bar), bar.center().y);
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(true)]);
|
||
frame(&mut app, &ctx, vec![button(false)]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert!(!app.session.fill_active, "the Stroke proxy is in front");
|
||
assert!(crate::panel_docking::showing(&app.ui, "swatches"), "the Swatches panel shows");
|
||
}
|
||
|
||
/// The Contextual Task Bar shows Recolor Artwork (a palette) when the selection has colours,
|
||
/// and it opens the Recolor Artwork dialog; art painted with None has no button.
|
||
#[test]
|
||
fn the_task_bar_recolors_coloured_art() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||
app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let click = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![]);
|
||
frame(app, &ctx, vec![]);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
// Right to left: the frame's margin, More Options, Lock, then Recolor.
|
||
let at = pos2(bar.right() - 8.0 - 30.0 - 6.0 - 30.0 - 6.0 - 15.0, bar.center().y);
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
let st = app.session.active().unwrap();
|
||
assert!(has_colors(&st.doc, &st.selection.objects));
|
||
click(&mut app);
|
||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::recolor::KIND));
|
||
|
||
app.ui.dialog = None;
|
||
app.session.execute("paint.setFill", &json!({"ids": [id], "none": true})).unwrap();
|
||
app.session.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
assert!(!has_colors(&st.doc, &st.selection.objects), "None paints have no colours");
|
||
click(&mut app);
|
||
assert!(app.ui.dialog.is_none(), "no Recolor button without colours");
|
||
}
|
||
|
||
/// The Contextual Task Bar's Lock button is off in isolation mode, as Object › Lock › Selection
|
||
/// is: a click locks nothing and shows no error. Outside isolation mode it locks the selection.
|
||
#[test]
|
||
fn the_task_bar_lock_button_is_off_in_isolation_mode() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let a = app.session.execute("shape.rectangle", &json!({"x": 150, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||
let b = app.session.execute("shape.rectangle", &json!({"x": 210, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||
app.session.execute("select.set", &json!({"ids": [a.clone(), b]})).unwrap();
|
||
let g = app.session.execute("object.group", &json!({})).unwrap()["id"].clone();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let click_lock = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![]);
|
||
frame(app, &ctx, vec![]);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
// Right to left: the frame's margin, More Options, then Lock.
|
||
let at = pos2(bar.right() - 8.0 - 30.0 - 6.0 - 15.0, bar.center().y);
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
let locked = |app: &VectorcraftApp, id: &serde_json::Value| {
|
||
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().locked
|
||
};
|
||
app.session.execute("object.isolate", &json!({"id": g.clone()})).unwrap();
|
||
app.session.execute("select.set", &json!({"ids": [a.clone()]})).unwrap();
|
||
click_lock(&mut app);
|
||
assert!(!locked(&app, &a), "nothing is locked in isolation mode");
|
||
assert_eq!(app.ui.status, "", "no error");
|
||
app.session.execute("object.exitIsolation", &json!({})).unwrap();
|
||
app.session.execute("select.set", &json!({"ids": [g.clone()]})).unwrap();
|
||
click_lock(&mut app);
|
||
assert!(locked(&app, &g), "the group is locked outside isolation mode");
|
||
}
|
||
|
||
/// The Contextual Task Bar shows Image Trace first for a selected image: it traces the image
|
||
/// with the Default preset and leaves the Image Trace panel as it was. For the Image Trace
|
||
/// object it made, the first button is Expand, which keeps only the traced paths.
|
||
#[test]
|
||
fn the_task_bar_traces_an_image_then_expands_the_trace() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&png);
|
||
app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let click_first = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![]);
|
||
frame(app, &ctx, vec![]);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
// Left to right: the frame's margin, the handle and its spacing, then the first button.
|
||
let at = pos2(bar.left() + 8.0 + 4.0 + 6.0 + 12.0, bar.center().y);
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
let first = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().unwrap()
|
||
};
|
||
assert!(matches!(first(&app).kind, NodeKind::Image(_)));
|
||
click_first(&mut app);
|
||
assert_eq!(first(&app).name.as_deref(), Some("Image Trace"), "the image was traced");
|
||
assert_eq!(app.ui.open_panel, None, "the Image Trace panel is left as it was");
|
||
|
||
click_first(&mut app);
|
||
let expanded = first(&app);
|
||
assert_ne!(expanded.name.as_deref(), Some("Image Trace"), "the trace was expanded");
|
||
let mut images = 0;
|
||
expanded.walk(&mut |n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
|
||
assert_eq!(images, 0, "only the traced paths are kept");
|
||
}
|
||
|
||
/// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with
|
||
/// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as
|
||
/// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up.
|
||
fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> {
|
||
egui_extras::install_image_loaders(ctx);
|
||
frame(app, ctx, vec![]);
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
let Some(bar) = task_bar_rect(ctx) else { return vec![] };
|
||
out.shapes
|
||
.iter()
|
||
.filter_map(|c| match &c.shape {
|
||
egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => Some((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// With the Artboard tool, the Contextual Task Bar sits under the selected artboards and offers
|
||
/// Resize, Duplicate, Rearrange All (with two or more artboards) and Export instead of the
|
||
/// selected objects' actions. With the Selection tool it offers the objects' actions again, and
|
||
/// with nothing selected it doesn't show.
|
||
#[test]
|
||
fn the_task_bar_offers_the_artboard_tools_actions() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
|
||
assert!(names(&mut app).is_empty() && task_bar_rect(&ctx).is_none(), "nothing selected: no bar");
|
||
|
||
app.select_tool("artboard");
|
||
crate::panels::artboards::select(&mut app, 1);
|
||
// Artboard 2 in the middle of the window, at 100%.
|
||
let b = app.session.active().unwrap().doc.artboards[1].rect;
|
||
let view = app.view_mut().unwrap();
|
||
(view.center, view.zoom) = (b.center(), 1.0);
|
||
assert_eq!(names(&mut app), ["Resize", "Duplicate", "Rearrange All", "Export"]);
|
||
// Under artboard 2.
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let bar = task_bar_rect(&ctx).unwrap();
|
||
let center = xf.to_screen(b.center());
|
||
assert!(bar.top() > xf.to_screen(Point::new(b.x0, b.y1)).y && bar.x_range().contains(center.x), "{bar:?} under {b:?}");
|
||
|
||
// A selected object with the Selection tool: its own actions, none of the artboards'.
|
||
app.select_tool("selection");
|
||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
|
||
assert_eq!(names(&mut app), ["Edit Path", "Offset Path", "Simplify", "Duplicate"]);
|
||
|
||
// One artboard: nothing to rearrange.
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||
app.select_tool("artboard");
|
||
assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]);
|
||
}
|
||
|
||
/// A selected group's Contextual Task Bar offers Ungroup and Isolate. While an opacity mask is
|
||
/// edited, when Enter Isolation Mode is off, a group in the mask art gets Ungroup without Isolate.
|
||
#[test]
|
||
fn the_task_bar_leaves_isolate_out_while_an_opacity_mask_is_edited() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
|
||
let rect = |app: &mut VectorcraftApp, x: f64| {
|
||
app.session.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 40, "height": 40})).unwrap()["id"].clone()
|
||
};
|
||
let group = |app: &mut VectorcraftApp, x: f64| {
|
||
let (a, b) = (rect(app, x), rect(app, x + 50.0));
|
||
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||
app.session.execute("object.group", &json!({})).unwrap();
|
||
};
|
||
group(&mut app, 20.0);
|
||
assert_eq!(names(&mut app), ["Ungroup", "Isolate", "Duplicate"]);
|
||
let (obj, m) = (rect(&mut app, 200.0), rect(&mut app, 200.0));
|
||
app.session.execute("transparency.makeOpacityMask", &json!({"ids": [obj, m]})).unwrap();
|
||
app.session.execute("transparency.editOpacityMask", &json!({"id": obj})).unwrap();
|
||
group(&mut app, 200.0);
|
||
assert_eq!(names(&mut app), ["Ungroup", "Duplicate"]);
|
||
}
|
||
|
||
/// The Artboard tool's Contextual Task Bar buttons act on its selected artboards: Resize opens
|
||
/// Artboard Options for the active one, Rearrange All opens Rearrange All Artboards, Export opens
|
||
/// Export for Screens with the selected artboards checked, and Duplicate copies each selected
|
||
/// artboard in one undo step and selects the copies.
|
||
#[test]
|
||
fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
app.select_tool("artboard");
|
||
crate::panels::artboards::select(&mut app, 2);
|
||
app.session.set_tool_option("selected", &json!([2, 0]));
|
||
let ctx = egui::Context::default();
|
||
let press = |app: &mut VectorcraftApp, label: &str| {
|
||
let labels = task_bar_labels(app, &ctx);
|
||
let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center();
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
app.ui.dialog.take()
|
||
};
|
||
let d = press(&mut app, "Resize").expect("Artboard Options");
|
||
assert_eq!((d.kind.as_str(), d.f64("index", 9.0)), ("artboardOptions", 2.0));
|
||
let d = press(&mut app, "Rearrange All").expect("Rearrange All Artboards");
|
||
assert_eq!(d.kind, crate::dialogs::rearrange_artboards::KIND);
|
||
let d = press(&mut app, "Export").expect("Export for Screens");
|
||
assert_eq!((d.kind.as_str(), &d.fields["boards"]), (crate::dialogs::EXPORT_FOR_SCREENS, &json!([true, false, true])));
|
||
|
||
let boards =
|
||
|app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| (a.name.clone(), a.rect.size())).collect::<Vec<_>>();
|
||
let before = boards(&app);
|
||
assert!(press(&mut app, "Duplicate").is_none());
|
||
let after = boards(&app);
|
||
assert_eq!(after[..3], before[..]);
|
||
assert_eq!(after[3..], [("Artboard 1 copy".to_string(), before[0].1), ("Artboard 3 copy".to_string(), before[2].1)]);
|
||
assert_eq!(app.session.tool_options()["selected"], json!([4, 3]), "the copies are selected, artboard 3's copy active");
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(boards(&app), before, "one undo step");
|
||
}
|
||
|
||
/// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect,
|
||
/// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens
|
||
/// the path there and Connect closes it again, as the Control bar's anchor buttons do.
|
||
#[test]
|
||
fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("directSelection");
|
||
app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
|
||
assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]);
|
||
let press = |app: &mut VectorcraftApp, label: &str| {
|
||
let labels = task_bar_labels(app, &ctx);
|
||
let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center();
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
let subpath =
|
||
|app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone();
|
||
let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone();
|
||
press(&mut app, "Smooth");
|
||
let a = subpath(&app).anchors[1];
|
||
assert!(a.has_in() && a.has_out(), "smooth: {a:?}");
|
||
assert_eq!(last_undo(&app), "Convert Anchor Points");
|
||
press(&mut app, "Corner");
|
||
let a = subpath(&app).anchors[1];
|
||
assert!(!a.has_in() && !a.has_out(), "corner: {a:?}");
|
||
press(&mut app, "Cut Path");
|
||
assert_eq!(last_undo(&app), "Cut Path");
|
||
let sp = subpath(&app);
|
||
assert!(!sp.closed && sp.anchors.len() == 5, "the rectangle opens at the anchor: {sp:?}");
|
||
press(&mut app, "Connect");
|
||
assert_eq!(last_undo(&app), "Join");
|
||
let sp = subpath(&app);
|
||
assert!(sp.closed && sp.anchors.len() == 4, "the ends join again: {sp:?}");
|
||
}
|
||
|
||
/// The Contextual Task Bar's handle drags it: unpinned it keeps that offset as it follows the
|
||
/// selection, pinned it stays put when the selection changes, unpinned again it follows from
|
||
/// there, and Reset Bar Position puts it back under the selection. It never leaves the canvas,
|
||
/// also when the window shrinks.
|
||
#[test]
|
||
fn the_task_bar_is_dragged_by_its_handle_pinned_and_reset() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let mut square =
|
||
|x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||
let (a, b) = (square(140.0, 40.0), square(160.0, 150.0));
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let bar = || task_bar_rect(&ctx).expect("the task bar shows");
|
||
let select = |app: &mut VectorcraftApp, id: &serde_json::Value| {
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
frame(app, &ctx, vec![]);
|
||
frame(app, &ctx, vec![]);
|
||
bar().min
|
||
};
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, by: Vec2| {
|
||
let grip = pos2(bar().left() + 11.0, bar().center().y);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(grip)]);
|
||
frame(app, &ctx, vec![button(grip, true)]);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(grip + by)]);
|
||
frame(app, &ctx, vec![button(grip + by, false)]);
|
||
frame(app, &ctx, vec![]);
|
||
bar().min
|
||
};
|
||
let near = |p: Pos2, q: Pos2| assert!((p - q).length() < 1.5, "{p:?} is not at {q:?}");
|
||
let (under_a, under_b) = (select(&mut app, &a), select(&mut app, &b));
|
||
assert!(under_b.y > under_a.y + 100.0, "each bar sits under its selection");
|
||
|
||
// Dragged unpinned: it keeps its offset from the selection.
|
||
select(&mut app, &a);
|
||
let by = vec2(40.0, -30.0);
|
||
near(drag(&mut app, by), under_a + by);
|
||
near(select(&mut app, &b), under_b + by);
|
||
assert!(!app.ui.task_bar_place.pinned);
|
||
|
||
// Pinned: it stays where it is across selection changes, and the handle still moves it.
|
||
assert_eq!(app.run("window.taskBar.pin", json!({})).unwrap(), json!(true));
|
||
assert_eq!(crate::menus::checked(&app, "window.taskBar.pin", &json!({})), Some(true));
|
||
near(select(&mut app, &a), under_b + by);
|
||
let spot = drag(&mut app, vec2(-20.0, 10.0));
|
||
near(spot, under_b + by + vec2(-20.0, 10.0));
|
||
near(select(&mut app, &b), spot);
|
||
|
||
// Unpinned: it follows the selection from where it is, kept on the canvas.
|
||
assert_eq!(app.run("window.taskBar.pin", json!({"pinned": false})).unwrap(), json!(false));
|
||
near(select(&mut app, &a), spot);
|
||
let canvas = app.canvas_rect.unwrap();
|
||
let last = canvas.max - vec2(8.0, 8.0) - bar().size();
|
||
near(select(&mut app, &b), (under_b + (spot - under_a)).min(last));
|
||
assert!(app.run("window.taskBar.pin", json!({"pinned": "yes"})).is_err());
|
||
|
||
// Kept on the canvas: dragged far off, then pinned in the corner of a shrinking window.
|
||
near(drag(&mut app, vec2(-2000.0, -2000.0)), canvas.min + vec2(8.0, 8.0));
|
||
app.run("window.taskBar.pin", json!({"pinned": true})).unwrap();
|
||
drag(&mut app, vec2(2000.0, 2000.0));
|
||
near(bar().max, canvas.max - vec2(8.0, 8.0));
|
||
let small = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(640.0, 480.0))), ..Default::default() };
|
||
for _ in 0..2 {
|
||
let mut out = ctx.run_ui(small.clone(), |ui| show(&mut app, ui));
|
||
out.textures_delta.clear();
|
||
}
|
||
let canvas = app.canvas_rect.unwrap();
|
||
assert!(canvas.width() < 700.0, "the canvas shrank: {canvas:?}");
|
||
near(bar().max, canvas.max - vec2(8.0, 8.0));
|
||
|
||
// Reset: back under the selection, unpinned.
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.run("window.taskBar.reset", json!({})).unwrap();
|
||
assert!(!app.ui.task_bar_place.pinned);
|
||
near(select(&mut app, &a), under_a);
|
||
near(select(&mut app, &b), under_b);
|
||
}
|
||
|
||
/// In a view turned 180° the Contextual Task Bar shows under the selection's lowest corner on
|
||
/// screen, not over the selection.
|
||
#[test]
|
||
fn the_task_bar_stays_under_the_selection_in_a_rotated_view() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 170, "y": 90, "width": 60, "height": 120})).unwrap()["id"].clone();
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
app.select_tool("selection");
|
||
app.run("view.rotate", json!({"angle": 180})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let lowest = xf.quad(app.selection_bounds().unwrap()).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
|
||
assert!(bar.top() > lowest, "the bar {bar:?} starts below {lowest}");
|
||
}
|
||
|
||
fn ruler_guide_origin_ui(alt: bool, cancel: bool) {
|
||
use egui::{Event, Modifiers};
|
||
for (origin, preview, release) in [
|
||
(Point::new(17.0, 23.0), Point::new(222.0, 238.0), Point::new(232.0, 243.0)),
|
||
(Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(233.0, 242.0)),
|
||
(Point::ZERO, Point::new(225.0, 235.0), Point::new(230.0, 245.0)),
|
||
] {
|
||
for source_vertical in [false, true] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("document.setRulerOrigin", json!({"x": origin.x, "y": origin.y})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap();
|
||
let redo = app.session.active().unwrap().doc.clone();
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
app.session.active_mut().unwrap().mark_saved();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
app.ui.view.snap_to_grid = false;
|
||
app.ui.view.snap_to_pixel = false;
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let view = app.view_mut().unwrap();
|
||
view.zoom = 1.0;
|
||
view.rotation = 0.0;
|
||
view.center = Point::new(200.0, 150.0);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let from =
|
||
if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) };
|
||
let to = xf.to_screen(Point::new(223.0, 237.0));
|
||
let end = xf.to_screen(Point::new(231.0, 244.0));
|
||
assert!(rect.contains(to) && rect.contains(end));
|
||
let mods = if alt { Modifiers::SHIFT | Modifiers::ALT } else { Modifiers::SHIFT };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: mods };
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let selection = app.session.active().unwrap().selection.clone();
|
||
let history = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||
};
|
||
let original_history = history(&app);
|
||
assert_eq!(original_history.1, 1);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods), Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||
let check = |app: &VectorcraftApp, switched: bool, expected: Point| {
|
||
let guides = &app.session.active().unwrap().doc.guides;
|
||
assert_eq!(guides.len(), 1);
|
||
let vertical = source_vertical != switched;
|
||
assert_eq!(guides[0].vertical, vertical);
|
||
let want = if vertical { expected.x } else { expected.y };
|
||
assert!((guides[0].pos - want).abs() < 1e-4, "origin={origin:?} vertical={vertical}: {:?}, want {want}", guides[0]);
|
||
};
|
||
assert_eq!(history(&app).0, original_history.0, "preview has no undo entry");
|
||
if cancel {
|
||
// Cancellation is a baseline positive control, independent of snap correctness.
|
||
assert_eq!(app.session.active().unwrap().doc.guides.len(), 1);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]);
|
||
assert_eq!(app.session.active().unwrap().doc, before);
|
||
assert_eq!(app.session.active().unwrap().selection, selection);
|
||
assert_eq!(history(&app), original_history);
|
||
app.run("edit.redo", json!({})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().doc, redo);
|
||
continue;
|
||
}
|
||
check(&app, alt, preview);
|
||
if alt {
|
||
// Used only by the private #1782 compatibility regression, not standalone RED.
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]);
|
||
check(&app, false, preview);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods)]);
|
||
check(&app, true, preview);
|
||
}
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
|
||
check(&app, alt, release);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.ruler_origin_for(0), origin);
|
||
assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork");
|
||
assert_eq!(st.selection, selection);
|
||
assert_eq!(history(&app), (original_history.0 + 1, 0, true));
|
||
assert_eq!(st.history.undo.last().unwrap().label, "New Guide");
|
||
let after = st.doc.clone();
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().doc, before);
|
||
assert_eq!(history(&app), (original_history.0, 1, false));
|
||
app.run("edit.redo", json!({})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().doc, after);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guide_shift_origin_preview_release_and_history() {
|
||
ruler_guide_origin_ui(false, false);
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guide_shift_origin_cancel_preserves_redo() {
|
||
ruler_guide_origin_ui(false, true);
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guide_origin_display_fallback_accepts_only_bounded_finite_pairs() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({})).unwrap();
|
||
// The global zero point as stored (artboard rulers check their own, see `ruler_origin_for`).
|
||
app.run("view.globalRulers", json!({"global": true})).unwrap();
|
||
let max = vectorcraft_engine::MAX_COORD;
|
||
for (origin, expected) in [
|
||
(Point::ZERO, Point::ZERO),
|
||
(Point::new(17.0, -23.0), Point::new(17.0, -23.0)),
|
||
(Point::new(max, -max), Point::new(max, -max)),
|
||
(Point::new(f64::NAN, 23.0), Point::ZERO),
|
||
(Point::new(17.0, f64::INFINITY), Point::ZERO),
|
||
(Point::new(max + 1.0, 23.0), Point::ZERO),
|
||
(Point::new(17.0, -max - 1.0), Point::ZERO),
|
||
] {
|
||
std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).ruler_origin = origin;
|
||
assert_eq!(app.ruler_origin(), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guide_origin_tooltip_matches_selected_guide_fields() {
|
||
use egui::Event;
|
||
for (vertical, target, shown) in [(true, 222.0, 205.0), (false, 238.0, 215.0)] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("document.setRulerOrigin", json!({"x": 17, "y": 23})).unwrap();
|
||
app.run("guide.add", json!({"vertical": vertical, "pos": 100})).unwrap();
|
||
app.ui.view.guides = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
app.ui.view.snap_to_grid = false;
|
||
app.ui.view.snap_to_pixel = false;
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let view = app.view_mut().unwrap();
|
||
view.zoom = 1.0;
|
||
view.rotation = 0.0;
|
||
view.center = Point::new(200.0, 150.0);
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.session.active_mut().unwrap().mark_saved();
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let from = xf.to_screen(if vertical { Point::new(100.0, 50.0) } else { Point::new(50.0, 100.0) });
|
||
let to = xf.to_screen(if vertical { Point::new(target, 50.0) } else { Point::new(50.0, target) });
|
||
assert!(rect.contains(from) && rect.contains(to));
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||
assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4);
|
||
assert_eq!(app.session.active().unwrap().selection.guides, vec![0]);
|
||
// Observe the real selected-guide field; no expected strings are inserted into UI state.
|
||
let fields = egui::Context::default();
|
||
crate::theme::install_fonts(&fields);
|
||
let mut texts = vec![];
|
||
for _ in 0..3 {
|
||
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() };
|
||
let mut out = fields.run_ui(input, |ui| crate::panels::transform::show(&mut app, ui));
|
||
out.textures_delta.clear();
|
||
fn collect(shape: &egui::Shape, texts: &mut Vec<String>) {
|
||
match shape {
|
||
egui::Shape::Text(t) => texts.push(t.galley.text().to_string()),
|
||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, texts)),
|
||
_ => {}
|
||
}
|
||
}
|
||
texts.clear();
|
||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut texts));
|
||
}
|
||
assert!(texts.iter().any(|t| t == &Unit::Points.format(shown)), "{texts:?}");
|
||
let view = app.view_info();
|
||
let overlays = app.session.overlays(view);
|
||
let want = format!("{}: {shown:.2} pt", if vertical { "X" } else { "Y" });
|
||
assert!(
|
||
overlays.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Measure { text, .. } if text == &want)),
|
||
"{overlays:?}, field={shown}"
|
||
);
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||
assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute");
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().doc, before);
|
||
}
|
||
}
|
||
|
||
/// Distance Guides on a canvas at 100%: a 400 × 300 pt document with a 50 pt square at (50, 50),
|
||
/// selected, and with `other` another 100 pt to its right, the Selection tool and Smart Guides
|
||
/// on → the app, its context, the view, the screen point over the middle of where the other
|
||
/// square is (or would be) and the selected square's id.
|
||
fn distance_scene(other: bool) -> (VectorcraftApp, egui::Context, Xf, Pos2, serde_json::Value) {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let a = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||
if other {
|
||
app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap();
|
||
}
|
||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||
app.ui.view.smart_guides = true;
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let view = app.view_mut().unwrap();
|
||
view.zoom = 1.0;
|
||
view.rotation = 0.0;
|
||
view.center = Point::new(200.0, 150.0);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let over = xf.to_screen(Point::new(225.0, 75.0));
|
||
(app, ctx, xf, over, a)
|
||
}
|
||
|
||
/// One frame with `events` → the shapes drawn, flattened. The modifiers held stay from one
|
||
/// frame to the next until an `Event::ModifiersChanged`.
|
||
fn distance_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<Shape> {
|
||
distance_frame_covered(app, ctx, events, None)
|
||
}
|
||
|
||
/// What opens over the canvas in [`distance_frame_covered`].
|
||
#[derive(Clone, Copy)]
|
||
enum Cover {
|
||
/// A floating window (a floating panel, a menu, a popup) over this rectangle.
|
||
Panel(egui::Rect),
|
||
/// A modal dialog, with the backdrop that keeps the rest of the app from the pointer.
|
||
Dialog,
|
||
}
|
||
|
||
/// [`distance_frame`] with `cover` drawn over the canvas.
|
||
fn distance_frame_covered(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, cover: Option<Cover>) -> Vec<Shape> {
|
||
let raw = egui::RawInput { screen_rect: Some(WINDOW), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
show(app, ui);
|
||
match cover {
|
||
Some(Cover::Panel(r)) => {
|
||
egui::Area::new(egui::Id::unique("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| {
|
||
ui.allocate_space(r.size());
|
||
});
|
||
}
|
||
Some(Cover::Dialog) => {
|
||
crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::unique("test-dialog"), 0.0, 8, |ui| ui.label("A dialog"));
|
||
}
|
||
None => {}
|
||
}
|
||
});
|
||
out.textures_delta.clear();
|
||
fn flat(s: Shape, out: &mut Vec<Shape>) {
|
||
match s {
|
||
Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)),
|
||
s => out.push(s),
|
||
}
|
||
}
|
||
let mut v = vec![];
|
||
out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v));
|
||
v
|
||
}
|
||
|
||
/// The texts drawn in `s`.
|
||
fn texts(s: &[Shape]) -> Vec<String> {
|
||
s.iter().filter_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()) } else { None }).collect()
|
||
}
|
||
|
||
/// The label "100.00 pt" drawn in `s` and the pill of the smart guide colour it sits in, if any.
|
||
fn gap_label(s: &[Shape]) -> Option<(egui::Rect, Option<egui::Rect>)> {
|
||
let magenta = c32(vectorcraft_tools::guides::MAGENTA);
|
||
let text =
|
||
s.iter().find_map(|s| if let Shape::Text(t) = s { (t.galley.text() == "100.00 pt").then(|| t.visual_bounding_rect()) } else { None })?;
|
||
let pill =
|
||
s.iter().find_map(|s| if let Shape::Rect(r) = s { (r.fill == magenta && r.rect.contains(text.center())).then_some(r.rect) } else { None });
|
||
Some((text, pill))
|
||
}
|
||
|
||
/// Distance Guides on screen: with a square selected and Alt held over another to its right,
|
||
/// the frame draws the measured line between their facing edges with the gap's length in a
|
||
/// pill of the smart guide colour on its middle; without Alt, or with Alt let go, nothing.
|
||
#[test]
|
||
fn alt_over_another_object_draws_the_distance_guide_label() {
|
||
use egui::{Event, Modifiers};
|
||
let (mut app, ctx, xf, over, _) = distance_scene(true);
|
||
let mut run = |events| distance_frame(&mut app, &ctx, events);
|
||
run(vec![Event::PointerMoved(over)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "without Alt");
|
||
run(vec![Event::ModifiersChanged(Modifiers::ALT)]);
|
||
let s = run(vec![]);
|
||
let (text, pill) = gap_label(&s).expect("the gap's length is drawn");
|
||
let pill = pill.expect("in a pill of the smart guide colour");
|
||
let mid = xf.to_screen(Point::new(150.0, 75.0));
|
||
assert!(pill.center().distance(mid) < 1.0 && pill.contains_rect(text), "{pill:?} on {mid:?}");
|
||
let (from, to) = (xf.to_screen(Point::new(100.0, 75.0)), xf.to_screen(Point::new(200.0, 75.0)));
|
||
let magenta = c32(vectorcraft_tools::guides::MAGENTA);
|
||
let measured = s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if *points == [from, to] && stroke.color == magenta));
|
||
assert!(measured, "the measured line between the facing edges");
|
||
run(vec![Event::ModifiersChanged(Modifiers::NONE)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "Alt let go");
|
||
}
|
||
|
||
/// The Distance Guides go when the pointer stops hovering for the tool with Alt still held: it
|
||
/// leaves the canvas for a panel or out of the window, or a press pans (Alt+Space) and takes it
|
||
/// over the panels; they come back when it hovers the other square again.
|
||
#[test]
|
||
fn distance_guides_go_when_the_hover_ends() {
|
||
use egui::{Event, Modifiers};
|
||
let (mut app, ctx, xf, over, _) = distance_scene(true);
|
||
let outside = pos2(xf.rect.max.x + 10.0, xf.rect.center().y);
|
||
assert!(WINDOW.contains(outside) && !xf.rect.contains(outside), "a panel beside the canvas");
|
||
let mut run = |events| distance_frame(&mut app, &ctx, events);
|
||
run(vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]);
|
||
assert!(gap_label(&run(vec![])).is_some(), "shown over the other square");
|
||
run(vec![Event::PointerMoved(outside)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "gone over a panel, Alt still held");
|
||
run(vec![Event::PointerMoved(over)]);
|
||
assert!(gap_label(&run(vec![])).is_some(), "back over the other square");
|
||
run(vec![Event::PointerGone]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "gone out of the window");
|
||
// Back over the square, Space held with Alt pans: they go at the press, and stay gone while
|
||
// the pan takes the pointer out over a panel.
|
||
let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::ALT };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT };
|
||
run(vec![Event::PointerMoved(over)]);
|
||
run(vec![space(true)]);
|
||
assert!(gap_label(&run(vec![])).is_some(), "Space held: still shown");
|
||
run(vec![button(over, true)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "gone at the pan's press");
|
||
run(vec![Event::PointerMoved(over + vec2(20.0, 0.0))]);
|
||
run(vec![Event::PointerMoved(outside)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "still gone after panning out over a panel");
|
||
run(vec![button(outside, false), space(false)]);
|
||
}
|
||
|
||
/// With Alt held and the pointer still, the Distance Guides follow what lies under it: they go
|
||
/// under a floating panel or a modal dialog and come back when it closes; scrolled so that
|
||
/// empty artboard lies under the pointer, they measure to the artboard; when an edit puts an
|
||
/// object under the pointer, they measure to it; and the Selection tool switched to shows them
|
||
/// at once.
|
||
#[test]
|
||
fn distance_guides_follow_what_lies_under_a_still_pointer() {
|
||
use egui::{Event, Modifiers};
|
||
let (mut app, ctx, _, over, _) = distance_scene(true);
|
||
let run = |app: &mut VectorcraftApp, events, cover| distance_frame_covered(app, &ctx, events, cover);
|
||
run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)], None);
|
||
assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "shown over the other square");
|
||
// A floating panel, then a modal dialog, opens over the still pointer.
|
||
for cover in [Cover::Panel(egui::Rect::from_center_size(over, vec2(120.0, 120.0))), Cover::Dialog] {
|
||
run(&mut app, vec![], Some(cover));
|
||
run(&mut app, vec![], Some(cover));
|
||
assert_eq!(gap_label(&run(&mut app, vec![], Some(cover))), None, "gone under it");
|
||
// A frame for egui to drop the closed window, one for the hover to come back.
|
||
run(&mut app, vec![], None);
|
||
run(&mut app, vec![], None);
|
||
assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "back once it closes");
|
||
}
|
||
// Another tool and back to the Selection tool (its shortcut, the toolbar): a new Selection
|
||
// tool, hovered at once.
|
||
app.select_tool("rectangle");
|
||
run(&mut app, vec![], None);
|
||
assert_eq!(gap_label(&run(&mut app, vec![], None)), None, "the Rectangle tool shows none");
|
||
app.select_tool("selection");
|
||
run(&mut app, vec![], None);
|
||
assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "the Selection tool again");
|
||
// The view scrolls 150 pt (the wheel, a gesture, the keys): empty artboard is under the
|
||
// pointer, and the selected square is 300 pt from the artboard's right edge.
|
||
app.view_mut().unwrap().center.x += 150.0;
|
||
run(&mut app, vec![], None);
|
||
let s = run(&mut app, vec![], None);
|
||
assert_eq!(gap_label(&s), None, "no longer over the other square");
|
||
assert!(texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s));
|
||
|
||
// A square made under the still pointer, the first selected again (an edit, an undo).
|
||
let (mut app, ctx, _, over, a) = distance_scene(false);
|
||
let run = |app: &mut VectorcraftApp, events| distance_frame(app, &ctx, events);
|
||
run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]);
|
||
let s = run(&mut app, vec![]);
|
||
assert!(gap_label(&s).is_none() && texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s));
|
||
app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap();
|
||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||
run(&mut app, vec![]);
|
||
assert!(gap_label(&run(&mut app, vec![])).is_some(), "to the new square");
|
||
// Another document (a new tab) under the still pointer: nothing selected there, none.
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
run(&mut app, vec![]);
|
||
assert_eq!(gap_label(&run(&mut app, vec![])), None, "another document");
|
||
}
|
||
|
||
/// #4518: a floating panel or a modal dialog over the canvas hides the pointer from it, so Object
|
||
/// Highlighting, the rulers and the Info panel don't follow a pointer that is over the dialog.
|
||
#[test]
|
||
fn a_panel_or_dialog_over_the_canvas_hides_the_pointer_from_it() {
|
||
let (mut app, ctx, _, over, _) = distance_scene(true);
|
||
let run = |app: &mut VectorcraftApp, events, cover| distance_frame_covered(app, &ctx, events, cover);
|
||
run(&mut app, vec![egui::Event::PointerMoved(over)], None);
|
||
run(&mut app, vec![], None);
|
||
assert!(app.hover_doc.is_some(), "over the canvas");
|
||
for cover in [Cover::Panel(egui::Rect::from_center_size(over, vec2(120.0, 120.0))), Cover::Dialog] {
|
||
// A new window takes a sizing pass before it covers anything, as in the test above.
|
||
for _ in 0..3 {
|
||
run(&mut app, vec![], Some(cover));
|
||
}
|
||
assert_eq!(app.hover_doc, None, "under it");
|
||
for _ in 0..3 {
|
||
run(&mut app, vec![], None);
|
||
}
|
||
assert!(app.hover_doc.is_some(), "back once it closes");
|
||
}
|
||
}
|
||
|
||
/// The 800 × 600 window [`frame`] draws the canvas in.
|
||
const WINDOW: egui::Rect = egui::Rect { min: Pos2::ZERO, max: Pos2 { x: 800.0, y: 600.0 } };
|
||
|
||
/// A 400 × 300 pt document at 400%, turned `rotation` degrees, with the Rectangle tool: a press
|
||
/// that reached the tool would draw.
|
||
fn zoomed_in(rotation: f64) -> (VectorcraftApp, egui::Context) {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let v = app.view_mut().unwrap();
|
||
v.zoom = 4.0;
|
||
v.rotation = rotation;
|
||
frame(&mut app, &ctx, vec![]);
|
||
(app, ctx)
|
||
}
|
||
|
||
/// Where the document's point `p` is on screen.
|
||
fn on_screen(app: &VectorcraftApp, p: Point) -> Pos2 {
|
||
Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(p)
|
||
}
|
||
|
||
/// Scroll bar `k` (0 horizontal, 1 vertical): its track, its span and the thumb's centre.
|
||
fn scroll_bar(app: &mut VectorcraftApp, k: usize) -> (egui::Rect, Span, Pos2) {
|
||
let rect = app.canvas_rect.unwrap();
|
||
let track = scroll_tracks(rect, WINDOW)[k];
|
||
let span = scroll_spans(&Xf::new(rect, app.view().unwrap()), app.content_bounds())[k];
|
||
let len = if k == 0 { track.width() } else { track.height() };
|
||
let (at, size) = span.thumb(len);
|
||
let c = if k == 0 { pos2(track.left() + at + size / 2.0, track.center().y) } else { pos2(track.center().x, track.top() + at + size / 2.0) };
|
||
(track, span, c)
|
||
}
|
||
|
||
fn primary(pos: Pos2, pressed: bool) -> egui::Event {
|
||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||
}
|
||
|
||
/// #2322: the Normal and Full Screen with Menu Bar modes keep the scroll bars along the right
|
||
/// and bottom edges, beside the rulers; Full Screen and Presentation Mode give the canvas the
|
||
/// whole window.
|
||
#[test]
|
||
fn scroll_bars_take_the_right_and_bottom_edges_in_the_windowed_screen_modes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
let ctx = egui::Context::default();
|
||
for (mode, max) in
|
||
[(0, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (1, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (2, WINDOW.max), (3, WINDOW.max)]
|
||
{
|
||
app.ui.screen_mode = mode;
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.canvas_rect.unwrap().max, max, "screen mode {mode}");
|
||
}
|
||
}
|
||
|
||
/// #2322: the thumb is as long, against its track, as the view against the art and the
|
||
/// artboards with half a window around them; zoomed in it is shorter.
|
||
#[test]
|
||
fn a_scroll_bar_thumb_is_as_long_as_the_view_is_against_the_content() {
|
||
assert_eq!(Span { lo: 0.0, hi: 100.0, v0: 0.0, v1: 100.0 }.thumb(50.0), (0.0, 50.0), "all in view: the whole track");
|
||
assert_eq!(Span { lo: 0.0, hi: 400.0, v0: 150.0, v1: 250.0 }.thumb(100.0), (37.5, 25.0));
|
||
assert_eq!(Span { lo: 0.0, hi: 1000.0, v0: 450.0, v1: 550.0 }.thumb(100.0), (40.0, 20.0), "never shorter than MIN_THUMB");
|
||
let (mut app, ctx) = zoomed_in(0.0);
|
||
let len = |app: &mut VectorcraftApp| {
|
||
let (track, span, _) = scroll_bar(app, 0);
|
||
span.thumb(track.width()).1
|
||
};
|
||
let at_400 = len(&mut app);
|
||
app.view_mut().unwrap().zoom = 8.0;
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert!(len(&mut app) < at_400, "zoomed in, the thumb is shorter");
|
||
}
|
||
|
||
/// #2322: dragging a thumb scrolls the view along the screen's axis, in a turned view too, by
|
||
/// as much as the thumb's travel stands for; the drag never reaches the tool under the bar.
|
||
#[test]
|
||
fn dragging_a_scroll_bar_thumb_scrolls_the_view_and_draws_nothing() {
|
||
for rotation in [0.0, 90.0, 30.0] {
|
||
for k in 0..2 {
|
||
let (mut app, ctx) = zoomed_in(rotation);
|
||
let p = Point::new(200.0, 150.0);
|
||
let before = on_screen(&app, p);
|
||
let (track, span, from) = scroll_bar(&mut app, k);
|
||
let len = if k == 0 { track.width() } else { track.height() };
|
||
let step = if k == 0 { vec2(40.0, 0.0) } else { vec2(0.0, 40.0) };
|
||
let moved = (40.0 * span.ratio(len)).clamp(span.lo - span.v0, span.hi - span.v1);
|
||
assert!(moved > 40.0, "at 400% the view moves further than the thumb: {moved}");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![primary(from, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step / 2.0)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step)]);
|
||
frame(&mut app, &ctx, vec![primary(from + step, false)]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool under the bar drew nothing");
|
||
let after = on_screen(&app, p);
|
||
let want = before - step.normalized() * moved;
|
||
assert!((after - want).length() < 0.5, "rotation {rotation}, bar {k}: {before:?} -> {after:?}, want {want:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #2322: a click on the track beyond the thumb scrolls a page (most of the window) towards it.
|
||
#[test]
|
||
fn a_click_on_a_scroll_bar_track_scrolls_a_page() {
|
||
let (mut app, ctx) = zoomed_in(0.0);
|
||
let p = Point::new(200.0, 150.0);
|
||
let before = on_screen(&app, p);
|
||
let (track, _, _) = scroll_bar(&mut app, 0);
|
||
let page = 0.9 * app.canvas_rect.unwrap().width();
|
||
let at = pos2(track.right() - 3.0, track.center().y);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![primary(at, true)]);
|
||
frame(&mut app, &ctx, vec![primary(at, false)]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let after = on_screen(&app, p);
|
||
assert!((after.x - (before.x - page)).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "{before:?} -> {after:?}, page {page}");
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||
}
|
||
|
||
/// #2322: the wheel over the horizontal bar scrolls sideways, over the vertical one up and
|
||
/// down (over the canvas it scrolls up and down).
|
||
#[test]
|
||
fn the_wheel_over_a_scroll_bar_scrolls_along_it() {
|
||
for k in 0..2 {
|
||
let (mut app, ctx) = zoomed_in(0.0);
|
||
let p = Point::new(200.0, 150.0);
|
||
let before = on_screen(&app, p);
|
||
let (track, _, _) = scroll_bar(&mut app, k);
|
||
let at = track.center();
|
||
let wheel = egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, -30.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: Default::default(),
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![wheel]);
|
||
for _ in 0..30 {
|
||
frame(&mut app, &ctx, vec![]);
|
||
}
|
||
let after = on_screen(&app, p);
|
||
let want = if k == 0 { before - vec2(30.0, 0.0) } else { before - vec2(0.0, 30.0) };
|
||
assert!((after - want).length() < 0.5, "bar {k}: {before:?} -> {after:?}, want {want:?}");
|
||
}
|
||
}
|
||
#[test]
|
||
fn live_trace_selection_highlight_excludes_the_retained_source_rectangle() {
|
||
use std::sync::Arc;
|
||
use vectorcraft_doc::{Appearance, ImageObject, NodeId, TraceView};
|
||
let mut source = Node::new(
|
||
NodeId(2),
|
||
NodeKind::Image(Box::new(ImageObject {
|
||
key: "synthetic".into(),
|
||
width: 200,
|
||
height: 200,
|
||
xf: Affine::IDENTITY,
|
||
link: None,
|
||
placement: Default::default(),
|
||
pixels: None,
|
||
})),
|
||
);
|
||
source.visible = false;
|
||
let art = Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)), Appearance::default());
|
||
let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art)]);
|
||
let doc = vectorcraft_doc::Document::new(300.0, 300.0);
|
||
let symbols = doc.symbol_boxes();
|
||
for view in TraceView::ALL {
|
||
group.extras_mut().trace = Some(Box::new(json!({"view":view.id()})));
|
||
let mut visited = vec![];
|
||
walk_drawn(&group, &mut |n| visited.push(n.id));
|
||
assert_eq!(visited.contains(&NodeId(2)), view.shows_source());
|
||
let want = if view.shows_source() {
|
||
vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0)
|
||
} else {
|
||
vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)
|
||
};
|
||
assert_eq!(node_outline(&group, &symbols).bounding_box(), want);
|
||
}
|
||
group.extras_mut().trace = None;
|
||
assert_eq!(node_outline(&group, &symbols).bounding_box(), vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0));
|
||
}
|
||
|
||
/// Hovering over point type shows only its baseline (not the transform bounding box), while
|
||
/// selecting it shows both the bounding box with handles and the baseline.
|
||
#[test]
|
||
fn point_type_hover_shows_baseline_only_and_selection_shows_both_box_and_baseline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let made = app.run("text.create", json!({"x": 200, "y": 150, "text": "Lorem ipsum", "size": 36})).unwrap();
|
||
let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap());
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(id));
|
||
let layer_mode = egui::epaint::ColorMode::Solid(color);
|
||
|
||
// Hover over the text: hover highlight shows the baseline (1.5 px stroke), NOT a bounding box.
|
||
let over = xf.to_screen(Point::new(220.0, 140.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||
let hover_shapes = shapes(&mut app, &ctx);
|
||
|
||
// There should be a 1.5px path outline for the hover highlight.
|
||
let hover_outlines: Vec<_> =
|
||
hover_shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer_mode)).collect();
|
||
assert!(!hover_outlines.is_empty(), "hover highlight shows baseline");
|
||
|
||
let is_handle = |s: &Shape| matches!(s, Shape::Rect(r) if (r.rect.width() - 6.0).abs() < 1.0 && (r.rect.height() - 6.0).abs() < 1.0 && r.fill == Color32::WHITE);
|
||
|
||
// There should be NO bounding box resize handles (size 6x6 white squares) while just hovering.
|
||
let handles: Vec<_> = hover_shapes.iter().filter(|s| is_handle(s)).collect();
|
||
assert!(handles.is_empty(), "hovering does not show bounding box handles: {handles:?}");
|
||
|
||
// Now select the text (click): both the bounding box (with handles) and the baseline are shown.
|
||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let sel_shapes = shapes(&mut app, &ctx);
|
||
|
||
// Bounding box handles (8 handles around the text) should now be present.
|
||
let handles: Vec<_> = sel_shapes.iter().filter(|s| is_handle(s)).collect();
|
||
assert_eq!(handles.len(), 8, "selected text shows 8 bounding box handles");
|
||
|
||
// Baseline (Shape::LineSegment at y=150) should also be present.
|
||
let y = xf.to_screen(Point::new(0.0, 150.0)).y;
|
||
let baselines: Vec<_> = sel_shapes
|
||
.iter()
|
||
.filter(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == color && (points[0].y - y).abs() < 0.5))
|
||
.collect();
|
||
assert!(!baselines.is_empty(), "selected text shows baseline alongside bounding box");
|
||
}
|
||
|
||
/// #2572: Show Text Threads links a thread's frames from the out port of one to the in port of
|
||
/// the next, where the tools draw those ports, each with the arrow of a threaded port; nothing
|
||
/// is drawn while no frame of the thread is selected.
|
||
#[test]
|
||
fn text_threads_run_from_each_out_port_to_the_next_in_port() {
|
||
use vectorcraft_tools::text::{Port, port_centre};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||
let text = "Words that fill more than the frame holds. ".repeat(10);
|
||
let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone();
|
||
let b = app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap()["id"].clone();
|
||
let frame = |app: &VectorcraftApp, id: &serde_json::Value| match &app
|
||
.session
|
||
.active()
|
||
.unwrap()
|
||
.doc
|
||
.node(vectorcraft_doc::NodeId(id.as_u64().unwrap()))
|
||
.unwrap()
|
||
.kind
|
||
{
|
||
NodeKind::Text(t) => (**t).clone(),
|
||
_ => panic!("not text"),
|
||
};
|
||
let zoom = 2.0;
|
||
let (out, input) = (port_centre(&frame(&app, &a), Port::Out, 0.5).unwrap(), port_centre(&frame(&app, &b), Port::In, 0.5).unwrap());
|
||
let o = thread_overlays(&app, zoom);
|
||
assert!(o.iter().any(|x| matches!(x, Overlay::Line { a, b, .. } if *a == out && *b == input)), "the thread line: {o:?}");
|
||
let boxes: Vec<Point> =
|
||
o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect();
|
||
assert_eq!(boxes.len(), 2);
|
||
assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}");
|
||
// Two arrows: three strokes each, beside the thread line.
|
||
assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7);
|
||
app.run("select.none", json!({})).unwrap();
|
||
assert!(thread_overlays(&app, zoom).is_empty());
|
||
}
|
||
|
||
/// A double-click with the Selection tool on the out port of a threaded frame breaks the
|
||
/// thread there: no new frame is threaded on, and the story flows back into the first frame.
|
||
#[test]
|
||
fn a_double_click_on_a_threaded_out_port_breaks_the_thread() {
|
||
use vectorcraft_tools::text::{Port, port_centre};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||
let text = "Words that fill more than the frame holds. ".repeat(10);
|
||
let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap();
|
||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||
app.select_tool("selection");
|
||
let doc = |app: &VectorcraftApp| app.session.active().unwrap().doc.clone();
|
||
let before = doc(&app);
|
||
let NodeKind::Text(t) = &before.node(vectorcraft_doc::NodeId(a)).unwrap().kind else { panic!("not text") };
|
||
let view = app.view_info();
|
||
let at = port_centre(t, Port::Out, 1.0 / view.zoom).unwrap();
|
||
let count = doc(&app).layers[0].children().map_or(0, |c| c.len());
|
||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
|
||
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, at.x, at.y), view);
|
||
}
|
||
let d = doc(&app);
|
||
assert!(d.text_threads.is_empty(), "the thread is broken");
|
||
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), count, "no new frame");
|
||
let NodeKind::Text(t) = &d.node(vectorcraft_doc::NodeId(a)).unwrap().kind else { panic!("not text") };
|
||
assert_eq!(t.plain_text(), text, "the story flows into the first frame");
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
}
|
||
|
||
/// With more than `PORTS_MAX` type objects selected the tools draw no ports, and Show Text
|
||
/// Threads draws only its lines, no port boxes.
|
||
#[test]
|
||
fn text_threads_of_a_huge_selection_have_lines_but_no_ports() {
|
||
use vectorcraft_tools::text::PORTS_MAX;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||
let text = "Words that fill more than the frame holds. ".repeat(10);
|
||
let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone();
|
||
app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap();
|
||
app.run("text.create", json!({"x": 20, "y": 300, "text": "x", "area": {"width": 50, "height": 20}})).unwrap();
|
||
while app.session.active().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) <= PORTS_MAX {
|
||
app.run("select.all", json!({})).unwrap();
|
||
app.run("edit.duplicate", json!({})).unwrap();
|
||
}
|
||
app.run("select.all", json!({})).unwrap();
|
||
let o = thread_overlays(&app, 1.0);
|
||
assert!(o.iter().any(|x| matches!(x, Overlay::Line { .. })), "the thread lines");
|
||
assert!(!o.iter().any(|x| matches!(x, Overlay::Highlight { .. } | Overlay::Path { .. })), "no port boxes");
|
||
}
|
||
|
||
/// A frame of a thread in perspective has no ports: Show Text Threads draws no port box on it
|
||
/// and no line to or from it; the links between frames with ports keep theirs.
|
||
#[test]
|
||
fn text_threads_skip_the_links_of_a_frame_in_perspective() {
|
||
use vectorcraft_tools::text::{Port, port_centre};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||
let text = "Words that fill more than the frame holds. ".repeat(20);
|
||
let id = |v: serde_json::Value| vectorcraft_doc::NodeId(v["id"].as_u64().unwrap());
|
||
let a = id(app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap());
|
||
let b = id(app.run("text.thread.add", json!({"from": a.0, "at": [200, 20]})).unwrap());
|
||
let c = id(app.run("text.thread.add", json!({"from": b.0, "at": [20, 200]})).unwrap());
|
||
let d = id(app.run("text.thread.add", json!({"from": c.0, "at": [200, 200]})).unwrap());
|
||
app.session
|
||
.edit("Perspective", |doc, _| {
|
||
if let Some(n) = doc.node_mut(b) {
|
||
n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment::new("left", 0.0)));
|
||
}
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
app.run("select.set", json!({"ids": [a.0, b.0, c.0, d.0]})).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.text_threads, [vec![a, b, c, d]], "b is still threaded");
|
||
let frame = |id| match &st.doc.node(id).unwrap().kind {
|
||
NodeKind::Text(t) => (**t).clone(),
|
||
_ => panic!("not text"),
|
||
};
|
||
let (out, input) = (port_centre(&frame(c), Port::Out, 1.0).unwrap(), port_centre(&frame(d), Port::In, 1.0).unwrap());
|
||
let o = thread_overlays(&app, 1.0);
|
||
let boxes: Vec<Point> =
|
||
o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect();
|
||
assert_eq!(boxes.len(), 2, "only c's out port and d's in port: {boxes:?}");
|
||
assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}");
|
||
// One thread line (c to d) and its two arrows of three strokes each.
|
||
assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7);
|
||
}
|