Files
photocraft/crates/ui-egui/tests/layout_perf.rs
Fabio Lissi 137d7c2df1 Tests: set the Move tool's Auto-Select target to Layer in the layout_perf clicks (#4056) (#4548)
#4093 changed the Auto-Select default to Group, as in Photoshop. The two
layout_perf tests that click layers inside groups with Auto-Select on
expected the layer under the pointer, so they now select the group and
fail on main. They set the target to Layer explicitly, which is what
they test.
2026-10-12 20:44:01 -04:00

259 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Layout documents (#125, #128) through the real app: the Move tool shows the layers at the
//! pointer while dragging and commits one `layer.translate` (one undo step) that lands where the
//! pointer went; Auto-Select takes the layer under the pointer; selecting a layer recomposites
//! nothing and hiding one recomposites only its area; and the GPU compositor (which composites
//! small layers over their bounds only) matches the CPU reference on the whole document.
#[path = "support/layout_doc.rs"]
mod layout_doc;
use egui::{Event, Modifiers, PointerButton, Pos2};
use egui_kittest::Harness;
use photocraft_doc::{Document, LayerContent, LayerId};
use photocraft_engine::layer_multi_cmds::{layer_bounds, move_targets, moved};
use photocraft_geom::Rect;
use photocraft_ui_egui::canvas::ViewXform;
use photocraft_ui_egui::state::Tool;
use photocraft_ui_egui::{PhotocraftApp, Services};
use serde_json::json;
type H = Harness<'static, PhotocraftApp>;
/// The app (CPU canvas) with the small layout document open.
fn app() -> (H, layout_doc::Handles) {
let (doc, handles) = layout_doc::build(layout_doc::Spec::small());
let h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.session.open_document(doc, None);
app.sync_views();
app
});
let mut h = h;
h.run_steps(6);
(h, handles)
}
fn doc(h: &H) -> std::sync::Arc<Document> {
h.state().session.active().expect("document").doc.clone()
}
fn bounds(h: &H, id: LayerId) -> Rect {
layer_bounds(doc(h).layer(id).expect("layer")).expect("bounds")
}
fn screen(h: &H, p: [f64; 2]) -> Pos2 {
let app = h.state();
let v = &app.ui.views[0];
let rect = photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect);
ViewXform { rect, zoom: v.zoom, center: v.center, flip: false, rotation: v.rotation, aspect: 1.0 }.to_screen(p[0] as f32, p[1] as f32)
}
/// A point where `id` (or, for a group, one of its layers) is the topmost layer with pixels.
fn point_on(h: &H, id: LayerId) -> [f64; 2] {
fn holds(l: &photocraft_doc::Layer, id: LayerId) -> bool {
l.id == id || l.children().is_some_and(|c| c.iter().any(|c| holds(c, id)))
}
let d = doc(h);
let b = bounds(h, id);
let layer = d.layer(id).expect("layer");
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
if photocraft_engine::pick_cmds::layers_at(&d, x, y).first().is_some_and(|&top| holds(layer, top)) {
return [x as f64 + 0.5, y as f64 + 0.5];
}
}
}
panic!("layer {id:?} has no visible pixel");
}
/// Press at document point `from`, move by `d` in `steps` frames; returns before releasing.
fn press_and_drag(h: &mut H, from: [f64; 2], d: [f64; 2], steps: usize) {
let p0 = screen(h, from);
h.event(Event::PointerMoved(p0));
h.step();
h.event(Event::PointerButton { pos: p0, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for i in 1..=steps {
let f = i as f64 / steps as f64;
h.event(Event::PointerMoved(screen(h, [from[0] + d[0] * f, from[1] + d[1] * f])));
h.step();
}
}
fn release(h: &mut H, at: [f64; 2]) {
let p = screen(h, at);
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// Zoom so one document pixel is one screen point (pointer deltas are then exact).
fn actual_pixels(h: &mut H) {
let app = h.state_mut();
let size = app.session.active().map(|s| s.doc.size).expect("doc");
app.ui.views[0].zoom = 1.0;
app.ui.views[0].center = [size.width as f32 / 2.0, size.height as f32 / 2.0];
app.ui.views[0].fit_pending = false;
// View › Snap and smart guides would pull the layers onto their neighbours' edges.
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
h.run_steps(2);
}
#[test]
fn move_drag_follows_the_pointer_and_is_one_undo_step() {
let (mut h, k) = app();
actual_pixels(&mut h);
h.state_mut().ui.tool = Tool::Move;
h.state_mut().ui.tool_options.move_auto_select = false;
for id in [k.text, k.shape, k.group, k.smart, k.pixel] {
h.state_mut().run("layer.select", json!({"layer": id.0})).expect("select");
h.run_steps(2);
let before = bounds(&h, id);
let undo = h.state().session.active().expect("doc").history.entries().len();
let from = point_on(&h, id);
press_and_drag(&mut h, from, [40.0, 25.0], 6);
// While dragging the document is untouched (no history step per pointer move) and the
// canvas shows the moved layers.
assert_eq!(bounds(&h, id), before, "the drag commits nothing until release");
assert_eq!(h.state().session.active().expect("doc").history.entries().len(), undo);
assert!(h.state().perf.spans.contains_key("move preview"), "the canvas shows the layers moving");
release(&mut h, [from[0] + 40.0, from[1] + 25.0]);
assert_eq!(bounds(&h, id), before.translate(40, 25), "the layer lands where the pointer went");
assert_eq!(h.state().session.active().expect("doc").history.entries().len(), undo + 1, "one undo step");
h.state_mut().run("edit.undo", json!({})).expect("undo");
assert_eq!(bounds(&h, id), before);
}
}
#[test]
fn auto_select_moves_the_layer_under_the_pointer() {
let (mut h, k) = app();
actual_pixels(&mut h);
h.state_mut().ui.tool = Tool::Move;
h.state_mut().ui.tool_options.move_auto_select = true;
// The default target is Group (#4056); these layers sit in groups, and the test is about the
// layer under the pointer.
h.state_mut().ui.tool_options.move_target = "layer".into();
h.state_mut().run("layer.select", json!({"layer": k.pixel.0})).expect("select");
h.run_steps(2);
let (pixel_before, text_before) = (bounds(&h, k.pixel), bounds(&h, k.text));
let from = point_on(&h, k.text);
press_and_drag(&mut h, from, [-15.0, 10.0], 4);
release(&mut h, [from[0] - 15.0, from[1] + 10.0]);
assert_eq!(h.state().session.active().and_then(|s| s.active_layer), Some(k.text));
assert_eq!(bounds(&h, k.text), text_before.translate(-15, 10));
assert_eq!(bounds(&h, k.pixel), pixel_before, "the previously selected layer stays");
}
#[test]
fn a_move_tool_click_selects_without_moving() {
// Snapping stays on (the default): it used to pull the release point onto a nearby edge, so
// a plain click nudged the layer (and recomposited the whole document).
let (mut h, k) = app();
h.state_mut().ui.tool = Tool::Move;
h.state_mut().ui.tool_options.move_auto_select = true;
h.state_mut().ui.tool_options.move_target = "layer".into();
for id in [k.text, k.shape, k.pixel, k.text] {
let before = (bounds(&h, id), h.state().session.active().expect("doc").history.entries().len());
let p = screen(&h, point_on(&h, id));
h.event(Event::PointerMoved(p));
h.step();
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().session.active().and_then(|s| s.active_layer), Some(id), "the click selects the layer under it");
assert_eq!((bounds(&h, id), h.state().session.active().expect("doc").history.entries().len()), before, "and moves nothing");
}
}
#[test]
fn selecting_recomposites_nothing_and_hiding_only_the_layer() {
let (mut h, k) = app();
for id in [k.text, k.group, k.smart, k.shape] {
h.state_mut().perf.last_refresh = "untouched";
h.state_mut().run("layer.select", json!({"layer": id.0})).expect("select");
h.run_steps(3);
assert_eq!(h.state().perf.last_refresh, "untouched", "selecting a layer must not recomposite");
}
// The Properties panel's Levels histogram isn't recomputed for a selection change either.
let levels = doc(&h).layers.iter().rev().find(|l| matches!(l.content, LayerContent::Adjustment(_))).map(|l| l.id).expect("levels layer");
h.state_mut().run("layer.select", json!({"layer": levels.0})).expect("select");
h.run_steps(3);
assert!(h.state().perf.spans.contains_key("histogram"), "the Levels editor shows a histogram");
h.state_mut().perf.spans.remove("histogram");
h.state_mut().run("layer.select", json!({"layer": levels.0})).expect("select");
h.run_steps(3);
assert!(!h.state().perf.spans.contains_key("histogram"), "selecting changed no pixels");
// Hiding a text layer refreshes its area, not the whole canvas.
h.state_mut().run("layer.setProps", json!({"layer": k.text.0, "visible": false})).expect("hide");
h.run_steps(2);
assert_eq!(h.state().perf.last_refresh, "rect");
let d = doc(&h);
let reduced = h.state().perf.last_refresh_px;
assert!(reduced < d.size.area() / 4, "{reduced} px refreshed");
}
#[test]
fn the_move_preview_is_what_the_move_commits() {
let (d, k) = layout_doc::build(layout_doc::Spec::small());
let mut s = photocraft_engine::Session::new();
s.open_document(d, None);
for id in [k.text, k.shape, k.group, k.smart, k.pixel, k.closed_group] {
s.execute("layer.select", json!({"layer": id.0})).expect("select");
let before = s.active().expect("doc").doc.clone();
let ids = move_targets(&before, &[id]);
let preview = moved(&before, &ids, 13, -7).expect("preview");
s.execute("layer.translate", json!({"dx": 13, "dy": -7})).expect("move");
let after = s.active().expect("doc");
// The damage the canvas refreshes covers where the layer was and is.
let dmg = after.last_damage.unwrap_or_else(|| panic!("{id:?} {}: a move has a damage rect", before.layer(id).map_or("?", |l| l.content.kind_name())));
let b0 = photocraft_compose::composite_bounds(before.layer(id).expect("layer"), before.bounds()).expect("bounds");
assert!(dmg.contains_rect(&b0) && dmg.contains_rect(&b0.translate(13, -7).intersect(&before.bounds())), "{dmg:?} vs {b0:?}");
let (a, b) = (photocraft_compose::flatten(&preview), photocraft_compose::flatten(&after.doc));
let worst = a.px.iter().zip(&b.px).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3] - q[c] * q[3]).abs())).fold(0.0f32, f32::max);
assert!(worst <= 2.0 / 255.0, "{id:?}: preview differs from the commit by {}/255", worst * 255.0);
s.execute("edit.undo", json!({})).expect("undo");
}
}
#[test]
fn layout_document_composites_on_the_gpu_like_the_cpu() {
use eframe::wgpu;
fn block_on<F: std::future::Future>(f: F) -> F::Output {
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
let mut f = std::pin::pin!(f);
loop {
if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) {
return v;
}
std::thread::yield_now();
}
}
let Ok(adapter) = block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())) else {
eprintln!("skipping: no GPU adapter");
return;
};
if photocraft_gpu::Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
eprintln!("skipping: adapter can't render Rgba32Float");
return;
}
let Ok((device, queue)) = block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { return };
let Ok(mut comp) = photocraft_gpu::Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) else { return };
let (d, _) = layout_doc::build(layout_doc::Spec::small());
let (_, texts) = layout_doc::count(&d);
assert!(texts >= 10);
assert!(d.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Smart(_))));
let cpu = photocraft_compose::flatten(&d);
let gpu = photocraft_gpu::render_to_vec(&mut comp, &device, &queue, &d, d.bounds()).expect("the layout composites on the GPU");
let mut worst = 0.0f32;
for (c, g) in cpu.px.iter().zip(&gpu) {
for k in 0..4 {
let (a, b) = if k == 3 { (c[3], g[3]) } else { (c[k] * c[3], g[k] * g[3]) };
worst = worst.max((a - b).abs());
}
}
assert!(worst <= 2.0 / 255.0, "GPU differs from the CPU by {}/255", worst * 255.0);
}