mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
refactor(ui-egui): move panels/transform.rs tests to panels/transform/tests.rs
This commit is contained in:
@@ -523,474 +523,4 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One headless frame of the panel (or the Align panel's menu): the texts drawn.
|
||||
fn texts(app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<String> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
|
||||
app.run("stroke.set", json!({"weight": 10})).unwrap();
|
||||
assert!(texts(&mut app, show).iter().any(|t| t == "100 pt"));
|
||||
assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == " Use Preview Bounds"));
|
||||
set_pref(&mut app, "usePreviewBounds", true);
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "110 pt") && shown.iter().any(|t| t == "60 pt"), "{shown:?}");
|
||||
assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_preview_bounds_shows_the_size_after_geometry_effects() {
|
||||
// A 100 × 50 rectangle without a stroke, with Offset Path 10: 120 × 70 as drawn.
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 50})).unwrap();
|
||||
app.run("paint.setStroke", json!({"none": true})).unwrap();
|
||||
app.run("effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": null})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}");
|
||||
set_pref(&mut app, "usePreviewBounds", true);
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "70 pt"), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_field_shows_the_angle_the_box_keeps() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
|
||||
app.run("object.rotate", json!({"angle": 45, "absolute": true})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
// The angle stays, and W/H are the rectangle's own sides.
|
||||
assert!(shown.iter().any(|t| t == "45°"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}");
|
||||
app.run("object.resetBoundingBox", json!({})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}");
|
||||
}
|
||||
|
||||
/// #812: a selected live polygon shows its Polygon Properties in the Transform panel and the
|
||||
/// Properties panel's Transform section, and Make Sides Equal evens it out after a stretch.
|
||||
#[test]
|
||||
fn a_polygon_shows_its_polygon_properties() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.polygon", json!({"cx": 150, "cy": 150, "radius": 50, "sides": 6})).unwrap();
|
||||
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
||||
let shown = texts(&mut app, draw);
|
||||
// A hexagon's side is as long as its radius.
|
||||
for t in ["Sides:", "6", "Angle:", "0°", "Corner:", "Round", "Corner Radius:", "Radius:", "Side Length:", "50 pt", "Make Sides Equal"] {
|
||||
assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}");
|
||||
}
|
||||
}
|
||||
assert!(texts(&mut app, show).iter().any(|s| s == "Polygon Properties:"));
|
||||
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let live = |app: &VectorcraftApp| match &first_selected(app).unwrap().kind {
|
||||
NodeKind::Path { live: Some(l), .. } => l.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(!live(&app).polygon_sides_equal());
|
||||
app.run("object.setLiveShape", json!({"makeSidesEqual": true})).unwrap();
|
||||
assert!(live(&app).polygon_sides_equal());
|
||||
}
|
||||
|
||||
/// #3451: a live ellipse shows pie start/end angles and Invert Pie in the Transform panel and
|
||||
/// the Properties panel's Transform section; typed angles update the shape.
|
||||
#[test]
|
||||
fn an_ellipse_shows_its_pie_properties() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap();
|
||||
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
||||
let shown = texts(&mut app, draw);
|
||||
for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] {
|
||||
assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}");
|
||||
}
|
||||
}
|
||||
app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|s| s == "30°") && shown.iter().any(|s| s == "120°"), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_artboard_tool_shows_the_active_artboard() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
||||
assert!(texts(&mut app, show).iter().any(|t| t == "40 pt"), "the selection first");
|
||||
app.run("tool.select", json!({"tool": "artboard"})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
// Centre reference point: X 150, Y 100, W 300, H 200; no rotate/shear for artboards.
|
||||
for v in ["150 pt", "100 pt", "300 pt", "200 pt"] {
|
||||
assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}");
|
||||
}
|
||||
assert!(!shown.iter().any(|t| t == "40 pt" || t == "0°"), "{shown:?}");
|
||||
}
|
||||
|
||||
/// #1597: with only ruler guides selected the panel shows their X or Y, and nothing of the art.
|
||||
#[test]
|
||||
fn selected_guides_show_their_position() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap();
|
||||
app.run("guide.add", json!({"vertical": false, "pos": 45})).unwrap();
|
||||
app.run("guide.select", json!({"indexes": [0]})).unwrap();
|
||||
assert_eq!(selected_guides(&app), Some((Some(120.0), None)));
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "120 pt") && !shown.iter().any(|t| t == "W:" || t == "45 pt"), "{shown:?}");
|
||||
app.run("guide.select", json!({"indexes": [0, 1]})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "45 pt"), "{shown:?}");
|
||||
// The field's edit: the vertical guide moves, the horizontal one stays.
|
||||
app.run("guide.move", json!({"dx": 30})).unwrap();
|
||||
assert_eq!(selected_guides(&app), Some((Some(150.0), Some(45.0))));
|
||||
// Art selected with them: the art's box, as before.
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
||||
assert_eq!(selected_guides(&app), None);
|
||||
}
|
||||
|
||||
/// #1824: with an anchor selected with Direct Selection the panel shows that anchor's position,
|
||||
/// and a new X moves it alone.
|
||||
#[test]
|
||||
fn an_anchor_selection_shows_and_moves_its_own_box() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
let square = json!({"anchors": [{"x": 20, "y": 30}, {"x": 120, "y": 30}, {"x": 120, "y": 130}, {"x": 20, "y": 130}], "closed": true});
|
||||
let id = app.run("path.create", square).unwrap()["id"].as_u64().unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "70 pt") && shown.iter().any(|t| t == "100 pt"), "the whole path: {shown:?}");
|
||||
app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
for v in ["120 pt", "30 pt", "0 pt"] {
|
||||
assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}");
|
||||
}
|
||||
assert!(!shown.iter().any(|t| t == "70 pt" || t == "100 pt"), "{shown:?}");
|
||||
app.run("object.setBounds", json!({"x": 150, "reference": 4})).unwrap();
|
||||
let n = first_selected(&app).unwrap();
|
||||
let Some(path) = n.path_data() else { panic!("a path") };
|
||||
let xs: Vec<f64> = path.subpaths[0].anchors.iter().map(|a| a.p.x).collect();
|
||||
assert_eq!(xs, [20.0, 150.0, 120.0, 20.0]);
|
||||
}
|
||||
|
||||
/// #2398: the panel menu's Flip Horizontal and Flip Vertical mirror the selection across the
|
||||
/// reference point, as the panel's flip buttons do, not across its centre.
|
||||
#[test]
|
||||
fn menu_flips_keep_the_reference_point() {
|
||||
use crate::panels::tests_appearance::{click, frame_events};
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// Top-right reference point: (120, 30).
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("refpt"), 2usize));
|
||||
let flip = |app: &mut VectorcraftApp, item: &str| {
|
||||
let texts = frame_events(&ctx, app, vec![], menu);
|
||||
let r = texts.iter().find(|(t, _)| t.trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1;
|
||||
click(&ctx, app, r.center(), menu);
|
||||
app.selection_box().unwrap().rect
|
||||
};
|
||||
assert_eq!(flip(&mut app, "Flip Horizontal"), Rect::new(120.0, 30.0, 220.0, 80.0));
|
||||
assert_eq!(flip(&mut app, "Flip Vertical"), Rect::new(120.0, -20.0, 220.0, 30.0));
|
||||
}
|
||||
|
||||
/// Clicks the item of the panel menu labelled `item` (with or without its check mark).
|
||||
fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, item: &str) {
|
||||
use crate::panels::tests_appearance::{click, frame_events};
|
||||
let texts = frame_events(ctx, app, vec![], menu);
|
||||
let r = texts.iter().find(|(t, _)| t.trim_start_matches('✓').trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1;
|
||||
click(ctx, app, r.center(), menu);
|
||||
}
|
||||
|
||||
/// The panel menu's items as drawn, check marks included.
|
||||
fn menu_texts(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec<String> {
|
||||
crate::panels::tests_appearance::frame_events(ctx, app, vec![], menu).into_iter().map(|(t, _)| t).collect()
|
||||
}
|
||||
|
||||
/// Types `value` into the field of `draw` that shows `shown`, and presses Enter.
|
||||
fn type_into(ctx: &egui::Context, app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui), shown: &str, value: &str) {
|
||||
use crate::panels::tests_appearance::{click, frame_events, text_rect};
|
||||
let texts = frame_events(ctx, app, vec![], draw);
|
||||
click(ctx, app, text_rect(&texts, shown).center(), draw);
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
frame_events(ctx, app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw);
|
||||
frame_events(ctx, app, vec![egui::Event::Text(value.into())], draw);
|
||||
frame_events(ctx, app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw);
|
||||
frame_events(ctx, app, vec![], draw);
|
||||
}
|
||||
|
||||
/// The panel menu's Transform Object Only, Transform Pattern Only and Transform Both, one
|
||||
/// checked: a new X typed in the panel (or the Properties panel's Transform section) moves the
|
||||
/// pattern-filled rectangle and leaves its tiles, moves its tiles alone, or moves both. Until
|
||||
/// one is chosen the panels do as General › Transform Pattern Tiles says, as they did before
|
||||
/// the menu acted, and the menu checks Transform Both while it is on, Transform Object Only
|
||||
/// while it is off; a choice overrides it, and resetting the preferences returns to it.
|
||||
#[test]
|
||||
fn the_menu_chooses_whether_the_object_its_pattern_or_both_transform() {
|
||||
let tiles = |app: &VectorcraftApp, id: u64| {
|
||||
let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("a pattern fill") };
|
||||
xf.as_coeffs()
|
||||
};
|
||||
let labels = ["Transform Object Only", "Transform Pattern Only", "Transform Both"];
|
||||
let modes = ["object", "pattern", "both"];
|
||||
let checked = |shown: &[String]| -> Vec<usize> { (0..3).filter(|i| shown.contains(&format!("✓ {}", labels[*i]))).collect() };
|
||||
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
||||
for general in [false, true] {
|
||||
for pick in [None, Some(0), Some(1), Some(2)] {
|
||||
let case = format!("General › Transform Pattern Tiles {general}, menu choice {pick:?}");
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("prefs.set", json!({"key": "transformPatternTiles", "value": general})).unwrap();
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
app.run("object.pattern.done", json!({})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 40, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let following = if general { 2 } else { 0 };
|
||||
assert_eq!(checked(&menu_texts(&ctx, &mut app)), [following], "{case}: before a choice");
|
||||
if let Some(item) = pick {
|
||||
menu_click(&ctx, &mut app, labels[item]);
|
||||
}
|
||||
let item = pick.unwrap_or(following);
|
||||
assert_eq!(app.session.prefs.transform_panel_patterns, pick.map_or("default", |i| modes[i]), "{case}");
|
||||
assert_eq!(checked(&menu_texts(&ctx, &mut app)), [item], "{case}");
|
||||
// The centre reference point: X 150 becomes 170.
|
||||
type_into(&ctx, &mut app, draw, "150 pt", "170");
|
||||
let x0 = app.selection_box().unwrap().rect.x0;
|
||||
assert_eq!(x0, if item == 1 { 100.0 } else { 120.0 }, "{case}: the object");
|
||||
assert_eq!(tiles(&app, id), [1.0, 0.0, 0.0, 1.0, if item == 0 { 0.0 } else { 20.0 }, 0.0], "{case}: the tiles");
|
||||
// Reset (the General category): the menu follows General › Transform Pattern
|
||||
// Tiles again.
|
||||
app.run("prefs.reset", json!({"category": "General"})).unwrap();
|
||||
assert_eq!(app.session.prefs.transform_panel_patterns, "default", "{case}: reset");
|
||||
let following = if app.session.prefs.transform_pattern_tiles { 2 } else { 0 };
|
||||
assert_eq!(checked(&menu_texts(&ctx, &mut app)), [following], "{case}: reset");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform Object Only, Transform Pattern Only and Transform Both act on the rotation field
|
||||
/// of both panels and on the Transform panel's shear field as they do on X: the pattern-filled
|
||||
/// rectangle turns or shears with its tiles left as they are, its tiles alone, or both.
|
||||
#[test]
|
||||
fn the_menu_choice_also_applies_to_rotation_and_shear() {
|
||||
let tiles = |app: &VectorcraftApp, id: u64| {
|
||||
let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("a pattern fill") };
|
||||
xf.as_coeffs()
|
||||
};
|
||||
let labels = ["Transform Object Only", "Transform Pattern Only", "Transform Both"];
|
||||
// (panel, which "0°" field: 0 the rotation, 1 the shear)
|
||||
let fields = [(show as fn(&mut VectorcraftApp, &mut Ui), 0), (show, 1), (crate::panels::properties::transform_section, 0)];
|
||||
for (draw, field) in fields {
|
||||
for (item, label) in labels.iter().enumerate() {
|
||||
let case = format!("{label}, {}", if field == 0 { "rotation" } else { "shear" });
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
app.run("object.pattern.done", json!({})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 40, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
menu_click(&ctx, &mut app, label);
|
||||
let before = (app.selection_box().unwrap(), tiles(&app, id));
|
||||
// Click the field showing "0°" and type 30 over it.
|
||||
use crate::panels::tests_appearance::{click, frame_events};
|
||||
let texts = frame_events(&ctx, &mut app, vec![], draw);
|
||||
let at = texts.iter().filter(|(t, _)| t == "0°").nth(field).unwrap_or_else(|| panic!("{case}: {texts:?}")).1.center();
|
||||
click(&ctx, &mut app, at, draw);
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw);
|
||||
frame_events(&ctx, &mut app, vec![egui::Event::Text("30".into())], draw);
|
||||
frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw);
|
||||
frame_events(&ctx, &mut app, vec![], draw);
|
||||
let after = (app.selection_box().unwrap(), tiles(&app, id));
|
||||
assert_eq!(after.0 != before.0, item != 1, "{case}: the object {:?} → {:?}", before.0, after.0);
|
||||
// Turned or sheared tiles change their matrix's linear part; moved ones only its offset.
|
||||
assert_eq!(after.1[..4] != before.1[..4], item != 0, "{case}: the tiles {:?} → {:?}", before.1, after.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use Registration Point for Symbol: with one symbol instance selected, the panel and the
|
||||
/// Properties panel's Transform section show and set X and Y at its symbol's registration point
|
||||
/// (here the top-left one) instead of the reference point (the centre).
|
||||
#[test]
|
||||
fn use_registration_point_for_symbol_measures_an_instance_from_its_registration_point() {
|
||||
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap();
|
||||
let id = app.run("symbol.new", json!({"name": "Corner", "registration": 0})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let shown = |app: &mut VectorcraftApp| {
|
||||
crate::panels::tests_appearance::frame_events(&ctx, app, vec![], draw).into_iter().map(|(t, _)| t).collect::<Vec<_>>()
|
||||
};
|
||||
let off = shown(&mut app);
|
||||
assert!(off.iter().any(|t| t == "140 pt") && off.iter().any(|t| t == "70 pt"), "the centre: {off:?}");
|
||||
assert!(menu_texts(&ctx, &mut app).contains(&" Use Registration Point for Symbol".to_string()));
|
||||
menu_click(&ctx, &mut app, "Use Registration Point for Symbol");
|
||||
assert!(app.session.prefs.use_registration_point_for_symbol);
|
||||
assert!(menu_texts(&ctx, &mut app).contains(&"✓ Use Registration Point for Symbol".to_string()));
|
||||
let on = shown(&mut app);
|
||||
assert!(on.iter().any(|t| t == "100 pt") && on.iter().any(|t| t == "50 pt") && !on.iter().any(|t| t == "140 pt"), "the top-left: {on:?}");
|
||||
type_into(&ctx, &mut app, draw, "100 pt", "30");
|
||||
assert_eq!(app.selection_box().unwrap().rect, Rect::new(30.0, 50.0, 110.0, 90.0));
|
||||
// Any other selection: the reference point.
|
||||
app.run("shape.rectangle", json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap();
|
||||
let rect = shown(&mut app);
|
||||
assert!(rect.iter().any(|t| t == "210 pt") && !rect.iter().any(|t| t == "200 pt"), "the centre: {rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Use Registration Point for Symbol on a reflected, a rotated and a sheared symbol instance:
|
||||
/// the panel and the Properties panel's Transform section show X and Y at the registration
|
||||
/// point where the instance's transform put it (not at the box's point of the same index), a
|
||||
/// new X moves it there, and their rotation field and flip buttons, and the panel menu's
|
||||
/// flips, turn and mirror the instance about it.
|
||||
#[test]
|
||||
fn use_registration_point_for_symbol_follows_a_transformed_instance() {
|
||||
let panels = [
|
||||
(show as fn(&mut VectorcraftApp, &mut Ui), "Flip Horizontal", "Flip Vertical"),
|
||||
(crate::panels::properties::transform_section, "Flip Along Horizontal Axis", "Flip Along Vertical Axis"),
|
||||
];
|
||||
// How each instance is transformed after it is made: (command, params).
|
||||
let shapes = [
|
||||
("object.reflect", json!({"axis": "vertical"})),
|
||||
("object.rotate", json!({"angle": 90})),
|
||||
// About the registration point (the top-left corner), which then lies on the box's
|
||||
// top edge but on none of its nine reference points.
|
||||
("object.shear", json!({"angle": 30, "axis": "horizontal", "origin": [100, 50]})),
|
||||
];
|
||||
// The registration point (the art's top-left) where the instance's transform puts it.
|
||||
let registration = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => *xf * vectorcraft_geom::Point::new(-vectorcraft_doc::SYMBOL_HALF, -vectorcraft_doc::SYMBOL_HALF),
|
||||
k => panic!("not an instance: {k:?}"),
|
||||
};
|
||||
let pt = |v: f64| format!("{} pt", (v * 1000.0).round() / 1000.0);
|
||||
let near = |a: vectorcraft_geom::Point, b: vectorcraft_geom::Point| (a - b).hypot() < 1e-6;
|
||||
for (draw, flip_h, flip_v) in panels {
|
||||
for (cmd, params) in &shapes {
|
||||
let case = format!("{cmd} {params}");
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap();
|
||||
let id = app.run("symbol.new", json!({"name": "Corner", "registration": 0})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run(cmd, params.clone()).unwrap();
|
||||
app.run("prefs.set", json!({"key": "useRegistrationPointForSymbol", "value": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.enable_accesskit();
|
||||
let q = registration(&app, id);
|
||||
let shown: Vec<String> =
|
||||
crate::panels::tests_appearance::frame_events(&ctx, &mut app, vec![], draw).into_iter().map(|(t, _)| t).collect();
|
||||
assert!(shown.contains(&pt(q.x)) && shown.contains(&pt(q.y)), "{case}: X/Y at {q:?} in {shown:?}");
|
||||
// A new X moves the registration point there.
|
||||
type_into(&ctx, &mut app, draw, &pt(q.x), &format!("{}", q.x - 40.0));
|
||||
let q = vectorcraft_geom::Point::new(q.x - 40.0, q.y);
|
||||
assert!(near(registration(&app, id), q), "{case}: moved to {q:?}, now {:?}", registration(&app, id));
|
||||
// The rotation field turns it about the registration point.
|
||||
let angle = app.selection_box().unwrap().angle;
|
||||
let texts = crate::panels::tests_appearance::frame_events(&ctx, &mut app, vec![], draw);
|
||||
let degrees = texts.iter().find(|(t, _)| t.ends_with('°')).unwrap_or_else(|| panic!("{case}: no angle in {texts:?}")).0.clone();
|
||||
let before = app.session.active().unwrap().revision;
|
||||
type_into(&ctx, &mut app, draw, °rees, &format!("{}", angle + 30.0));
|
||||
assert_ne!(app.session.active().unwrap().revision, before, "{case}: turned");
|
||||
assert!(near(registration(&app, id), q), "{case}: rotated about {q:?}, now {:?}", registration(&app, id));
|
||||
// The flip buttons mirror it about the registration point.
|
||||
for label in [flip_h, flip_v] {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
|
||||
let (_, n) = nodes
|
||||
.iter()
|
||||
.find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button)
|
||||
.unwrap_or_else(|| panic!("no button named {label:?}"));
|
||||
let b = n.bounds().expect("bounds");
|
||||
let at = egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5);
|
||||
let before = app.session.active().unwrap().revision;
|
||||
crate::panels::tests_appearance::click(&ctx, &mut app, at, draw);
|
||||
assert_ne!(app.session.active().unwrap().revision, before, "{case}: {label} flipped");
|
||||
assert!(near(registration(&app, id), q), "{case}: {label} about {q:?}, now {:?}", registration(&app, id));
|
||||
}
|
||||
// So do the panel menu's flips.
|
||||
for item in ["Flip Horizontal", "Flip Vertical"] {
|
||||
menu_click(&ctx, &mut app, item);
|
||||
assert!(near(registration(&app, id), q), "{case}: menu {item} about {q:?}, now {:?}", registration(&app, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_fields_keep_the_reference_point() {
|
||||
let r = Rect::new(0.0, 0.0, 300.0, 200.0);
|
||||
// Centre: a new X centres it there, a new W grows it both ways.
|
||||
assert_eq!(artboard_rect(r, 4, Some(200.0), None, None, None, false), Rect::new(50.0, 0.0, 350.0, 200.0));
|
||||
assert_eq!(artboard_rect(r, 4, None, None, Some(400.0), None, false), Rect::new(-50.0, 0.0, 350.0, 200.0));
|
||||
// Top-left: a new W keeps the left edge; linked, H follows.
|
||||
assert_eq!(artboard_rect(r, 0, None, None, Some(600.0), None, true), Rect::new(0.0, 0.0, 600.0, 400.0));
|
||||
// Bottom-right: a new Y puts the bottom edge there.
|
||||
assert_eq!(artboard_rect(r, 8, None, Some(500.0), None, None, false), Rect::new(0.0, 300.0, 300.0, 500.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_proportions() {
|
||||
assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0));
|
||||
assert_eq!(constrained(100.0, 50.0, None, Some(25.0)), (50.0, 25.0));
|
||||
assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0));
|
||||
}
|
||||
|
||||
/// The Transform panel names its Rotate and Shear inputs.
|
||||
#[test]
|
||||
fn rotate_and_shear_fields_are_named() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap();
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(280.0, 500.0)).with_accessibility_check(false).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Rotate");
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Shear");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
use super::*;
|
||||
|
||||
/// One headless frame of the panel (or the Align panel's menu): the texts drawn.
|
||||
fn texts(app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<String> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
|
||||
app.run("stroke.set", json!({"weight": 10})).unwrap();
|
||||
assert!(texts(&mut app, show).iter().any(|t| t == "100 pt"));
|
||||
assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == " Use Preview Bounds"));
|
||||
set_pref(&mut app, "usePreviewBounds", true);
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "110 pt") && shown.iter().any(|t| t == "60 pt"), "{shown:?}");
|
||||
assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_preview_bounds_shows_the_size_after_geometry_effects() {
|
||||
// A 100 × 50 rectangle without a stroke, with Offset Path 10: 120 × 70 as drawn.
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 50})).unwrap();
|
||||
app.run("paint.setStroke", json!({"none": true})).unwrap();
|
||||
app.run("effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": null})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}");
|
||||
set_pref(&mut app, "usePreviewBounds", true);
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "70 pt"), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_field_shows_the_angle_the_box_keeps() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
|
||||
app.run("object.rotate", json!({"angle": 45, "absolute": true})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
// The angle stays, and W/H are the rectangle's own sides.
|
||||
assert!(shown.iter().any(|t| t == "45°"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}");
|
||||
app.run("object.resetBoundingBox", json!({})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}");
|
||||
}
|
||||
|
||||
/// #812: a selected live polygon shows its Polygon Properties in the Transform panel and the
|
||||
/// Properties panel's Transform section, and Make Sides Equal evens it out after a stretch.
|
||||
#[test]
|
||||
fn a_polygon_shows_its_polygon_properties() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.polygon", json!({"cx": 150, "cy": 150, "radius": 50, "sides": 6})).unwrap();
|
||||
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
||||
let shown = texts(&mut app, draw);
|
||||
// A hexagon's side is as long as its radius.
|
||||
for t in ["Sides:", "6", "Angle:", "0°", "Corner:", "Round", "Corner Radius:", "Radius:", "Side Length:", "50 pt", "Make Sides Equal"] {
|
||||
assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}");
|
||||
}
|
||||
}
|
||||
assert!(texts(&mut app, show).iter().any(|s| s == "Polygon Properties:"));
|
||||
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let live = |app: &VectorcraftApp| match &first_selected(app).unwrap().kind {
|
||||
NodeKind::Path { live: Some(l), .. } => l.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(!live(&app).polygon_sides_equal());
|
||||
app.run("object.setLiveShape", json!({"makeSidesEqual": true})).unwrap();
|
||||
assert!(live(&app).polygon_sides_equal());
|
||||
}
|
||||
|
||||
/// #3451: a live ellipse shows pie start/end angles and Invert Pie in the Transform panel and
|
||||
/// the Properties panel's Transform section; typed angles update the shape.
|
||||
#[test]
|
||||
fn an_ellipse_shows_its_pie_properties() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 80})).unwrap();
|
||||
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
||||
let shown = texts(&mut app, draw);
|
||||
for t in ["Ellipse Properties:", "Pie Start Angle:", "0°", "Pie End Angle:", "360°", "Invert Pie"] {
|
||||
assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}");
|
||||
}
|
||||
}
|
||||
app.run("object.setLiveShape", json!({"pieStart": 30, "pieEnd": 120})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|s| s == "30°") && shown.iter().any(|s| s == "120°"), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_artboard_tool_shows_the_active_artboard() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
||||
assert!(texts(&mut app, show).iter().any(|t| t == "40 pt"), "the selection first");
|
||||
app.run("tool.select", json!({"tool": "artboard"})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
// Centre reference point: X 150, Y 100, W 300, H 200; no rotate/shear for artboards.
|
||||
for v in ["150 pt", "100 pt", "300 pt", "200 pt"] {
|
||||
assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}");
|
||||
}
|
||||
assert!(!shown.iter().any(|t| t == "40 pt" || t == "0°"), "{shown:?}");
|
||||
}
|
||||
|
||||
/// #1597: with only ruler guides selected the panel shows their X or Y, and nothing of the art.
|
||||
#[test]
|
||||
fn selected_guides_show_their_position() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap();
|
||||
app.run("guide.add", json!({"vertical": false, "pos": 45})).unwrap();
|
||||
app.run("guide.select", json!({"indexes": [0]})).unwrap();
|
||||
assert_eq!(selected_guides(&app), Some((Some(120.0), None)));
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "120 pt") && !shown.iter().any(|t| t == "W:" || t == "45 pt"), "{shown:?}");
|
||||
app.run("guide.select", json!({"indexes": [0, 1]})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "45 pt"), "{shown:?}");
|
||||
// The field's edit: the vertical guide moves, the horizontal one stays.
|
||||
app.run("guide.move", json!({"dx": 30})).unwrap();
|
||||
assert_eq!(selected_guides(&app), Some((Some(150.0), Some(45.0))));
|
||||
// Art selected with them: the art's box, as before.
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
||||
assert_eq!(selected_guides(&app), None);
|
||||
}
|
||||
|
||||
/// #1824: with an anchor selected with Direct Selection the panel shows that anchor's position,
|
||||
/// and a new X moves it alone.
|
||||
#[test]
|
||||
fn an_anchor_selection_shows_and_moves_its_own_box() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
let square = json!({"anchors": [{"x": 20, "y": 30}, {"x": 120, "y": 30}, {"x": 120, "y": 130}, {"x": 20, "y": 130}], "closed": true});
|
||||
let id = app.run("path.create", square).unwrap()["id"].as_u64().unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
assert!(shown.iter().any(|t| t == "70 pt") && shown.iter().any(|t| t == "100 pt"), "the whole path: {shown:?}");
|
||||
app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap();
|
||||
let shown = texts(&mut app, show);
|
||||
for v in ["120 pt", "30 pt", "0 pt"] {
|
||||
assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}");
|
||||
}
|
||||
assert!(!shown.iter().any(|t| t == "70 pt" || t == "100 pt"), "{shown:?}");
|
||||
app.run("object.setBounds", json!({"x": 150, "reference": 4})).unwrap();
|
||||
let n = first_selected(&app).unwrap();
|
||||
let Some(path) = n.path_data() else { panic!("a path") };
|
||||
let xs: Vec<f64> = path.subpaths[0].anchors.iter().map(|a| a.p.x).collect();
|
||||
assert_eq!(xs, [20.0, 150.0, 120.0, 20.0]);
|
||||
}
|
||||
|
||||
/// #2398: the panel menu's Flip Horizontal and Flip Vertical mirror the selection across the
|
||||
/// reference point, as the panel's flip buttons do, not across its centre.
|
||||
#[test]
|
||||
fn menu_flips_keep_the_reference_point() {
|
||||
use crate::panels::tests_appearance::{click, frame_events};
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// Top-right reference point: (120, 30).
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("refpt"), 2usize));
|
||||
let flip = |app: &mut VectorcraftApp, item: &str| {
|
||||
let texts = frame_events(&ctx, app, vec![], menu);
|
||||
let r = texts.iter().find(|(t, _)| t.trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1;
|
||||
click(&ctx, app, r.center(), menu);
|
||||
app.selection_box().unwrap().rect
|
||||
};
|
||||
assert_eq!(flip(&mut app, "Flip Horizontal"), Rect::new(120.0, 30.0, 220.0, 80.0));
|
||||
assert_eq!(flip(&mut app, "Flip Vertical"), Rect::new(120.0, -20.0, 220.0, 30.0));
|
||||
}
|
||||
|
||||
/// Clicks the item of the panel menu labelled `item` (with or without its check mark).
|
||||
fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, item: &str) {
|
||||
use crate::panels::tests_appearance::{click, frame_events};
|
||||
let texts = frame_events(ctx, app, vec![], menu);
|
||||
let r = texts.iter().find(|(t, _)| t.trim_start_matches('✓').trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1;
|
||||
click(ctx, app, r.center(), menu);
|
||||
}
|
||||
|
||||
/// The panel menu's items as drawn, check marks included.
|
||||
fn menu_texts(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec<String> {
|
||||
crate::panels::tests_appearance::frame_events(ctx, app, vec![], menu).into_iter().map(|(t, _)| t).collect()
|
||||
}
|
||||
|
||||
/// Types `value` into the field of `draw` that shows `shown`, and presses Enter.
|
||||
fn type_into(ctx: &egui::Context, app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui), shown: &str, value: &str) {
|
||||
use crate::panels::tests_appearance::{click, frame_events, text_rect};
|
||||
let texts = frame_events(ctx, app, vec![], draw);
|
||||
click(ctx, app, text_rect(&texts, shown).center(), draw);
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
frame_events(ctx, app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw);
|
||||
frame_events(ctx, app, vec![egui::Event::Text(value.into())], draw);
|
||||
frame_events(ctx, app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw);
|
||||
frame_events(ctx, app, vec![], draw);
|
||||
}
|
||||
|
||||
/// The panel menu's Transform Object Only, Transform Pattern Only and Transform Both, one
|
||||
/// checked: a new X typed in the panel (or the Properties panel's Transform section) moves the
|
||||
/// pattern-filled rectangle and leaves its tiles, moves its tiles alone, or moves both. Until
|
||||
/// one is chosen the panels do as General › Transform Pattern Tiles says, as they did before
|
||||
/// the menu acted, and the menu checks Transform Both while it is on, Transform Object Only
|
||||
/// while it is off; a choice overrides it, and resetting the preferences returns to it.
|
||||
#[test]
|
||||
fn the_menu_chooses_whether_the_object_its_pattern_or_both_transform() {
|
||||
let tiles = |app: &VectorcraftApp, id: u64| {
|
||||
let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("a pattern fill") };
|
||||
xf.as_coeffs()
|
||||
};
|
||||
let labels = ["Transform Object Only", "Transform Pattern Only", "Transform Both"];
|
||||
let modes = ["object", "pattern", "both"];
|
||||
let checked = |shown: &[String]| -> Vec<usize> { (0..3).filter(|i| shown.contains(&format!("✓ {}", labels[*i]))).collect() };
|
||||
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
||||
for general in [false, true] {
|
||||
for pick in [None, Some(0), Some(1), Some(2)] {
|
||||
let case = format!("General › Transform Pattern Tiles {general}, menu choice {pick:?}");
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("prefs.set", json!({"key": "transformPatternTiles", "value": general})).unwrap();
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
app.run("object.pattern.done", json!({})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 40, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let following = if general { 2 } else { 0 };
|
||||
assert_eq!(checked(&menu_texts(&ctx, &mut app)), [following], "{case}: before a choice");
|
||||
if let Some(item) = pick {
|
||||
menu_click(&ctx, &mut app, labels[item]);
|
||||
}
|
||||
let item = pick.unwrap_or(following);
|
||||
assert_eq!(app.session.prefs.transform_panel_patterns, pick.map_or("default", |i| modes[i]), "{case}");
|
||||
assert_eq!(checked(&menu_texts(&ctx, &mut app)), [item], "{case}");
|
||||
// The centre reference point: X 150 becomes 170.
|
||||
type_into(&ctx, &mut app, draw, "150 pt", "170");
|
||||
let x0 = app.selection_box().unwrap().rect.x0;
|
||||
assert_eq!(x0, if item == 1 { 100.0 } else { 120.0 }, "{case}: the object");
|
||||
assert_eq!(tiles(&app, id), [1.0, 0.0, 0.0, 1.0, if item == 0 { 0.0 } else { 20.0 }, 0.0], "{case}: the tiles");
|
||||
// Reset (the General category): the menu follows General › Transform Pattern
|
||||
// Tiles again.
|
||||
app.run("prefs.reset", json!({"category": "General"})).unwrap();
|
||||
assert_eq!(app.session.prefs.transform_panel_patterns, "default", "{case}: reset");
|
||||
let following = if app.session.prefs.transform_pattern_tiles { 2 } else { 0 };
|
||||
assert_eq!(checked(&menu_texts(&ctx, &mut app)), [following], "{case}: reset");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform Object Only, Transform Pattern Only and Transform Both act on the rotation field
|
||||
/// of both panels and on the Transform panel's shear field as they do on X: the pattern-filled
|
||||
/// rectangle turns or shears with its tiles left as they are, its tiles alone, or both.
|
||||
#[test]
|
||||
fn the_menu_choice_also_applies_to_rotation_and_shear() {
|
||||
let tiles = |app: &VectorcraftApp, id: u64| {
|
||||
let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("a pattern fill") };
|
||||
xf.as_coeffs()
|
||||
};
|
||||
let labels = ["Transform Object Only", "Transform Pattern Only", "Transform Both"];
|
||||
// (panel, which "0°" field: 0 the rotation, 1 the shear)
|
||||
let fields = [(show as fn(&mut VectorcraftApp, &mut Ui), 0), (show, 1), (crate::panels::properties::transform_section, 0)];
|
||||
for (draw, field) in fields {
|
||||
for (item, label) in labels.iter().enumerate() {
|
||||
let case = format!("{label}, {}", if field == 0 { "rotation" } else { "shear" });
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
app.run("object.pattern.done", json!({})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 40, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
menu_click(&ctx, &mut app, label);
|
||||
let before = (app.selection_box().unwrap(), tiles(&app, id));
|
||||
// Click the field showing "0°" and type 30 over it.
|
||||
use crate::panels::tests_appearance::{click, frame_events};
|
||||
let texts = frame_events(&ctx, &mut app, vec![], draw);
|
||||
let at = texts.iter().filter(|(t, _)| t == "0°").nth(field).unwrap_or_else(|| panic!("{case}: {texts:?}")).1.center();
|
||||
click(&ctx, &mut app, at, draw);
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], draw);
|
||||
frame_events(&ctx, &mut app, vec![egui::Event::Text("30".into())], draw);
|
||||
frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], draw);
|
||||
frame_events(&ctx, &mut app, vec![], draw);
|
||||
let after = (app.selection_box().unwrap(), tiles(&app, id));
|
||||
assert_eq!(after.0 != before.0, item != 1, "{case}: the object {:?} → {:?}", before.0, after.0);
|
||||
// Turned or sheared tiles change their matrix's linear part; moved ones only its offset.
|
||||
assert_eq!(after.1[..4] != before.1[..4], item != 0, "{case}: the tiles {:?} → {:?}", before.1, after.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use Registration Point for Symbol: with one symbol instance selected, the panel and the
|
||||
/// Properties panel's Transform section show and set X and Y at its symbol's registration point
|
||||
/// (here the top-left one) instead of the reference point (the centre).
|
||||
#[test]
|
||||
fn use_registration_point_for_symbol_measures_an_instance_from_its_registration_point() {
|
||||
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap();
|
||||
let id = app.run("symbol.new", json!({"name": "Corner", "registration": 0})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let shown = |app: &mut VectorcraftApp| {
|
||||
crate::panels::tests_appearance::frame_events(&ctx, app, vec![], draw).into_iter().map(|(t, _)| t).collect::<Vec<_>>()
|
||||
};
|
||||
let off = shown(&mut app);
|
||||
assert!(off.iter().any(|t| t == "140 pt") && off.iter().any(|t| t == "70 pt"), "the centre: {off:?}");
|
||||
assert!(menu_texts(&ctx, &mut app).contains(&" Use Registration Point for Symbol".to_string()));
|
||||
menu_click(&ctx, &mut app, "Use Registration Point for Symbol");
|
||||
assert!(app.session.prefs.use_registration_point_for_symbol);
|
||||
assert!(menu_texts(&ctx, &mut app).contains(&"✓ Use Registration Point for Symbol".to_string()));
|
||||
let on = shown(&mut app);
|
||||
assert!(on.iter().any(|t| t == "100 pt") && on.iter().any(|t| t == "50 pt") && !on.iter().any(|t| t == "140 pt"), "the top-left: {on:?}");
|
||||
type_into(&ctx, &mut app, draw, "100 pt", "30");
|
||||
assert_eq!(app.selection_box().unwrap().rect, Rect::new(30.0, 50.0, 110.0, 90.0));
|
||||
// Any other selection: the reference point.
|
||||
app.run("shape.rectangle", json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap();
|
||||
let rect = shown(&mut app);
|
||||
assert!(rect.iter().any(|t| t == "210 pt") && !rect.iter().any(|t| t == "200 pt"), "the centre: {rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Use Registration Point for Symbol on a reflected, a rotated and a sheared symbol instance:
|
||||
/// the panel and the Properties panel's Transform section show X and Y at the registration
|
||||
/// point where the instance's transform put it (not at the box's point of the same index), a
|
||||
/// new X moves it there, and their rotation field and flip buttons, and the panel menu's
|
||||
/// flips, turn and mirror the instance about it.
|
||||
#[test]
|
||||
fn use_registration_point_for_symbol_follows_a_transformed_instance() {
|
||||
let panels = [
|
||||
(show as fn(&mut VectorcraftApp, &mut Ui), "Flip Horizontal", "Flip Vertical"),
|
||||
(crate::panels::properties::transform_section, "Flip Along Horizontal Axis", "Flip Along Vertical Axis"),
|
||||
];
|
||||
// How each instance is transformed after it is made: (command, params).
|
||||
let shapes = [
|
||||
("object.reflect", json!({"axis": "vertical"})),
|
||||
("object.rotate", json!({"angle": 90})),
|
||||
// About the registration point (the top-left corner), which then lies on the box's
|
||||
// top edge but on none of its nine reference points.
|
||||
("object.shear", json!({"angle": 30, "axis": "horizontal", "origin": [100, 50]})),
|
||||
];
|
||||
// The registration point (the art's top-left) where the instance's transform puts it.
|
||||
let registration = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => *xf * vectorcraft_geom::Point::new(-vectorcraft_doc::SYMBOL_HALF, -vectorcraft_doc::SYMBOL_HALF),
|
||||
k => panic!("not an instance: {k:?}"),
|
||||
};
|
||||
let pt = |v: f64| format!("{} pt", (v * 1000.0).round() / 1000.0);
|
||||
let near = |a: vectorcraft_geom::Point, b: vectorcraft_geom::Point| (a - b).hypot() < 1e-6;
|
||||
for (draw, flip_h, flip_v) in panels {
|
||||
for (cmd, params) in &shapes {
|
||||
let case = format!("{cmd} {params}");
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40})).unwrap();
|
||||
let id = app.run("symbol.new", json!({"name": "Corner", "registration": 0})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run(cmd, params.clone()).unwrap();
|
||||
app.run("prefs.set", json!({"key": "useRegistrationPointForSymbol", "value": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.enable_accesskit();
|
||||
let q = registration(&app, id);
|
||||
let shown: Vec<String> =
|
||||
crate::panels::tests_appearance::frame_events(&ctx, &mut app, vec![], draw).into_iter().map(|(t, _)| t).collect();
|
||||
assert!(shown.contains(&pt(q.x)) && shown.contains(&pt(q.y)), "{case}: X/Y at {q:?} in {shown:?}");
|
||||
// A new X moves the registration point there.
|
||||
type_into(&ctx, &mut app, draw, &pt(q.x), &format!("{}", q.x - 40.0));
|
||||
let q = vectorcraft_geom::Point::new(q.x - 40.0, q.y);
|
||||
assert!(near(registration(&app, id), q), "{case}: moved to {q:?}, now {:?}", registration(&app, id));
|
||||
// The rotation field turns it about the registration point.
|
||||
let angle = app.selection_box().unwrap().angle;
|
||||
let texts = crate::panels::tests_appearance::frame_events(&ctx, &mut app, vec![], draw);
|
||||
let degrees = texts.iter().find(|(t, _)| t.ends_with('°')).unwrap_or_else(|| panic!("{case}: no angle in {texts:?}")).0.clone();
|
||||
let before = app.session.active().unwrap().revision;
|
||||
type_into(&ctx, &mut app, draw, °rees, &format!("{}", angle + 30.0));
|
||||
assert_ne!(app.session.active().unwrap().revision, before, "{case}: turned");
|
||||
assert!(near(registration(&app, id), q), "{case}: rotated about {q:?}, now {:?}", registration(&app, id));
|
||||
// The flip buttons mirror it about the registration point.
|
||||
for label in [flip_h, flip_v] {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
|
||||
let (_, n) = nodes
|
||||
.iter()
|
||||
.find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button)
|
||||
.unwrap_or_else(|| panic!("no button named {label:?}"));
|
||||
let b = n.bounds().expect("bounds");
|
||||
let at = egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5);
|
||||
let before = app.session.active().unwrap().revision;
|
||||
crate::panels::tests_appearance::click(&ctx, &mut app, at, draw);
|
||||
assert_ne!(app.session.active().unwrap().revision, before, "{case}: {label} flipped");
|
||||
assert!(near(registration(&app, id), q), "{case}: {label} about {q:?}, now {:?}", registration(&app, id));
|
||||
}
|
||||
// So do the panel menu's flips.
|
||||
for item in ["Flip Horizontal", "Flip Vertical"] {
|
||||
menu_click(&ctx, &mut app, item);
|
||||
assert!(near(registration(&app, id), q), "{case}: menu {item} about {q:?}, now {:?}", registration(&app, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_fields_keep_the_reference_point() {
|
||||
let r = Rect::new(0.0, 0.0, 300.0, 200.0);
|
||||
// Centre: a new X centres it there, a new W grows it both ways.
|
||||
assert_eq!(artboard_rect(r, 4, Some(200.0), None, None, None, false), Rect::new(50.0, 0.0, 350.0, 200.0));
|
||||
assert_eq!(artboard_rect(r, 4, None, None, Some(400.0), None, false), Rect::new(-50.0, 0.0, 350.0, 200.0));
|
||||
// Top-left: a new W keeps the left edge; linked, H follows.
|
||||
assert_eq!(artboard_rect(r, 0, None, None, Some(600.0), None, true), Rect::new(0.0, 0.0, 600.0, 400.0));
|
||||
// Bottom-right: a new Y puts the bottom edge there.
|
||||
assert_eq!(artboard_rect(r, 8, None, Some(500.0), None, None, false), Rect::new(0.0, 300.0, 300.0, 500.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_proportions() {
|
||||
assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0));
|
||||
assert_eq!(constrained(100.0, 50.0, None, Some(25.0)), (50.0, 25.0));
|
||||
assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0));
|
||||
}
|
||||
|
||||
/// The Transform panel names its Rotate and Shear inputs.
|
||||
#[test]
|
||||
fn rotate_and_shear_fields_are_named() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap();
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(280.0, 500.0)).with_accessibility_check(false).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Rotate");
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Shear");
|
||||
}
|
||||
Reference in New Issue
Block a user