diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index c4a39bf689..e269fda375 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -61,6 +61,15 @@ impl PageImage { self.clips.iter().any(|clip| clip.app_crop) } + /// The editing frame, respecting explicit PDF/editor crops but not the page boundary. + /// Selected handles remain reachable outside the page; body hits still use `contains`. + pub fn frame_rect(&self) -> Option<[f64; 4]> { + self.clips.iter().try_fold(self.rect, |rect, clip| { + let b = clip.path.bounding_box(); + intersection(rect, [b.x0, b.y0, b.x1, b.y1]) + }) + } + /// Whether a user-space point belongs to the placed artwork and every active clip. pub fn contains(&self, point: [f64; 2]) -> bool { if !point.iter().all(|v| v.is_finite()) { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d56240ee87..f6f68829c8 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13610,6 +13610,25 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } +#[test] +fn image_editing_frame_keeps_page_edge_outside_explicit_clips() { + for (prefix, expected) in [ + ("", [-30.0, -20.0, 70.0, 80.0]), + ("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]), + ("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]), + ] { + let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q")); + for doc in [&doc, &reopen(&doc)] { + let im = images::page_images(doc, 0).unwrap().remove(0); + assert_eq!(im.frame_rect(), Some(expected)); + assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop")); + assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]); + assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable"); + assert!(im.contains([10.0, 10.0])); + } + } +} + #[test] fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() { let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index eb213f3f64..2694e90492 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -420,13 +420,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi if let Some((added_idx, added_item)) = matched_added && let AddedContent::Image(ai) = added_item.content.clone() { + // Page visibility alone is not a crop: dragging an added image over the page edge + // must not discard its editable added-item identity on the next resize. let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6) - || img.visible_rect.is_some_and(|v| { - (v[0] - img.rect[0]).abs() > 1e-4 - || (v[1] - img.rect[1]).abs() > 1e-4 - || (v[2] - img.rect[2]).abs() > 1e-4 - || (v[3] - img.rect[3]).abs() > 1e-4 - }); + || img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4)); let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _]) if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5))); let should_promote = has_active_crop diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3912bfd5ec..611cd1dfb5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -13241,6 +13241,25 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() { assert_eq!(named, 0, "no image names the dictionary, which left with them"); } +#[test] +fn resizing_an_added_image_back_inside_the_page_keeps_its_identity() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([-30.0, -20.0, 70.0, 80.0]), name: "edge.png".into(), bytes: Arc::new(png) }).unwrap(); + let original = s.get(id).unwrap().added()[0].obj; + let target = [10.0, 10.0, 60.0, 60.0]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(target) }).unwrap(); + let added = s.get(id).unwrap().added(); + assert_eq!(added.len(), 1); + assert_eq!(added[0].obj, original); + assert_eq!(added[0].content.rect(), target); +} + #[test] fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { let (mut session, id) = rotated_blank(0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eebcece9b2..f9afb55bb6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -102,39 +102,15 @@ impl PainterExt for egui::Painter { } } -/// User-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect. +/// User-space editing frame: explicit PDF/editor crops apply, but the page edge does not +/// truncate a selected image's handles. Body hit testing still respects page visibility. pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] { - if let Some(clip) = im.clip_rect { - let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])]; - if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] { - return r; - } - } - im.visible_rect.unwrap_or(im.rect) + im.frame_rect().unwrap_or(im.rect) } -/// Screen-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// the final selectable `egui::Rect` is the mathematical intersection of the image's -/// full CTM-derived rect and the clipping path's rect. +/// Screen-space editing frame, including selected handles outside the page. pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { - let full = xf.user_rect_f64(info, page, im.rect); - if let Some(clip) = im.clip_rect { - let clip_r = xf.user_rect_f64(info, page, clip); - let inter = full.intersect(clip_r); - if inter.is_positive() { - return inter; - } - } - if let Some(vr) = im.visible_rect { - let r = xf.user_rect_f64(info, page, vr); - if r.is_positive() { - return r; - } - } - full + xf.user_rect_f64(info, page, image_selectable_bounds(im)) } /// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space, @@ -1860,7 +1836,12 @@ fn draw_pages( if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } - let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + // A resized handle can sit in the window beside a page that does not fill it. The + // response has to cover that visible area or the next press never starts: the pointer + // is reported only inside this rectangle once the previous drag has been released. + // Page positions stay on `content_w`/`content_h`; the extra area is not a new page. + let response_size = vec2(content_w.max(viewport.width()), content_h.max(viewport.height())); + let (resp_rect, resp) = ui.allocate_exact_size(response_size, Sense::click_and_drag()); view.page_widget = Some(resp.id); // A drag lasts only while egui reports it, so the mark can't outlive one. if !resp.dragged_by(egui::PointerButton::Primary) { diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index ddb4fdf88a..3f18348598 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -275,29 +275,30 @@ impl MoveBackground { return; } let r = screen_rect(xf, info, page, from); + let base = self.settling.map_or(r, |s| screen_rect(xf, info, page, s)); let to = if let Some((_, grab, start)) = cv.grab { let Some(p) = pointer else { return }; - dragged(r, grab, p - start, self.picture) - } else if let Some(to) = self.settling { - let to = screen_rect(xf, info, page, to); + dragged(base, grab, p - start, self.picture) + } else if self.settling.is_some() { // A moved text box keeps its top edge (its height follows its lines). - if self.picture { to } else { r.translate(to.left_top() - r.left_top()) } + if self.picture { base } else { r.translate(base.left_top() - r.left_top()) } } else { return; }; let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); - // A pixel around the box, so its edges come along. + // A pixel around the box, so its edges come along. Clip the source to the page raster so + // an item extending past the page border never samples clamped edge texels (#3938, #4477). let (r, to) = (r.expand(1.0), to.expand(1.0)); + let clipped = r.intersect(xf.rect); + if !clipped.is_positive() || r.width() <= 0.0 || r.height() <= 0.0 { + return; + } + let map = |p: Pos2| Pos2::new(to.min.x + (p.x - r.min.x) / r.width() * to.width(), to.min.y + (p.y - r.min.y) / r.height() * to.height()); let mut mesh = egui::Mesh::with_texture(item.id()); - for (a, b) in [ - (r.left_top(), to.left_top()), - (r.right_top(), to.right_top()), - (r.right_bottom(), to.right_bottom()), - (r.left_bottom(), to.left_bottom()), - ] { + for a in [clipped.left_top(), clipped.right_top(), clipped.right_bottom(), clipped.left_bottom()] { let (u, v) = xf.screen_to_norm(a); - mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE }); + mesh.vertices.push(egui::epaint::Vertex { pos: map(a), uv: Pos2::new(u, v), color: Color32::WHITE }); } mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); @@ -344,7 +345,11 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { Grab::Corner(left, top) => { let (mut dx, mut dy) = (if left { -d.x } else { d.x }, if top { -d.y } else { d.y }); if keep_aspect && r.width() > 0.0 && r.height() > 0.0 { - let k = ((r.width() + dx) / r.width()).max((r.height() + dy) / r.height()).max(0.05); + // Use the axis with the larger relative pointer change so shrinking along one axis + // does not freeze when the other axis has not moved inward (#4477). + let (rx, ry) = (dx / r.width(), dy / r.height()); + let delta = if rx.abs() > ry.abs() { rx } else { ry }; + let k = (1.0 + delta).max(0.05); dx = r.width() * k - r.width(); dy = r.height() * k - r.height(); } @@ -373,7 +378,8 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect(); let selected = view.content.selected.filter(|s| s.0 == page).and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)); if let Some((index, a)) = selected - && let Some((_, left, top)) = handles(screen_rect(xf, info, page, a.content.rect())).into_iter().find(|(c, ..)| c.distance(p) <= 6.0) + && let Some((_, left, top)) = + handles(screen_rect(xf, info, page, a.content.rect()).expand(1.0)).into_iter().find(|(c, ..)| c.distance(p) <= 6.0) { let icon = if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }; return Some(Hit::new(Owner::Added { index: *index, corner: Some((left, top)) }, Layer::Handle, icon)); @@ -1596,4 +1602,14 @@ mod tests { assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too"); assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits"); } + + /// #4477: dragging a corner handle predominantly inward on one axis must still shrink an image + /// proportionally instead of freezing because the other axis did not shrink. + #[test] + fn corner_resize_shrinks_image_when_dragged_inward_along_one_axis() { + let r = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0)); + let shrunk = dragged(r, Grab::Corner(false, false), egui::vec2(-40.0, 2.0), true); + assert!((shrunk.width() - 60.0).abs() < 1e-3, "width shrinks by 40%: {shrunk:?}"); + assert!((shrunk.height() - 30.0).abs() < 1e-3, "height shrinks proportionally by 40%: {shrunk:?}"); + } } diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index ba3601d0da..c8e1294854 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -528,12 +528,19 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture for r in blank { clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE); } + // The raster has no pixels beyond the page. Clip the source and its corresponding + // destination together, rather than stretching clamped edge texels into the preview. + let source = from.intersect(xf.rect); + if !source.is_positive() || !from.is_positive() { + return; + } + let target = egui::emath::RectTransform::from_to(from, to).transform_rect(source); mapped_picture( &clipped, xf, tex, - [from.left_top(), from.right_top(), from.right_bottom(), from.left_bottom()], - [to.left_top(), to.right_top(), to.right_bottom(), to.left_bottom()], + [source.left_top(), source.right_top(), source.right_bottom(), source.left_bottom()], + [target.left_top(), target.right_top(), target.right_bottom(), target.left_bottom()], ); } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 572bdf10f9..d894e30c33 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1039,6 +1039,177 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); } +/// #4477, #3938: in Edit a PDF, an added image can be resized repeatedly by its corner handles +/// (including the outer part of the handle circle and when a corner reaches past the page edge) +/// without losing selection to the object marquee, and dragging an out-of-bounds image back onto +/// the page does not smear edge pixels across the out-of-bounds half. +#[test] +fn added_picture_resizes_repeatedly_and_drags_back_from_out_of_bounds_without_smear() { + let _gpu = gpu(); + let mut h = harness(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 60, 60); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[200u8, 30, 30].repeat(3600)).unwrap(); + } + h.state_mut().add_image("red.png".into(), png); + h.run_steps(3); + crate::support::settle(&mut h); + + // 1. Grab the outer part of the lower-right corner handle (where the resize cursor is shown, + // outside r.expand(2.0)) and drag inward along one axis to shrink the image. + let rect0 = added(&h)[0].content.rect(); + let r0 = item_on_screen(&h, rect0); + let grip1 = r0.expand(1.0).right_bottom() + egui::vec2(2.0, 2.0); + h.hover_at(grip1); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + let to1 = grip1 + egui::vec2(-30.0, 0.0); + h.drag_at(grip1); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip1 + (to1 - grip1) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to1); + h.run_steps(3); + assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added image stays selected after releasing a corner resize"); + let rect1 = added(&h)[0].content.rect(); + assert!(rect1[2] - rect1[0] < (rect0[2] - rect0[0]) - 10.0, "first corner resize shrinks the image: {rect0:?} -> {rect1:?}"); + + // 2. Immediately resize again right after letting go, stretching past the right page edge, + // then shrink back from the out-of-bounds corner handle. + let r1 = item_on_screen(&h, rect1); + let page_right = h.state().views[0].page_screen_rect(0).unwrap().right(); + let grip2 = r1.expand(1.0).right_bottom(); + let to2 = pos2(page_right + 12.0, grip2.y); + h.hover_at(grip2); + h.run_steps(1); + h.drag_at(grip2); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip2 + (to2 - grip2) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to2); + h.run_steps(3); + let rect2 = added(&h)[0].content.rect(); + assert!(rect2[2] > 300.0, "second resize reaches past the page edge: {rect2:?}"); + + // Grab the corner handle that now sits outside the page rect and shrink it back inside. + let r2 = item_on_screen(&h, rect2); + let grip3 = r2.expand(1.0).right_bottom(); + assert!(!h.state().views[0].page_screen_rect(0).unwrap().contains(grip3), "corner handle lies outside the page rect"); + let to3 = pos2(page_right - 40.0, grip3.y); + h.hover_at(grip3); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + h.drag_at(grip3); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip3 + (to3 - grip3) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to3); + h.run_steps(3); + let rect3 = added(&h)[0].content.rect(); + assert!(rect3[2] < 300.0, "out-of-bounds corner handle still resizes the image back inside: {rect2:?} -> {rect3:?}"); + + // 3. Place the image half below the bottom edge of the page (y = -30..30), wait for the page + // raster, then drag it up into the page: the out-of-bounds bottom half must not smear the + // page edge's red texels across the lower half during the drag preview. + let img = match &added(&h)[0].content { + pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [120.0, -30.0, 180.0, 30.0], ..i.clone() }, + _ => panic!(), + }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(img) })); + crate::support::settle(&mut h); + let from = item_on_screen(&h, [120.0, -30.0, 180.0, 30.0]); + let grab = pos2(from.center().x, from.top() + 10.0); + let shift = egui::vec2(0.0, -120.0); + h.hover_at(grab); + h.run_steps(1); + h.drag_at(grab); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grab + shift * (k as f32 / 4.0)); + h.run_steps(1); + } + crate::support::settle(&mut h); + let moved = from.translate(shift); + let upper_half = egui::Rect::from_min_max(moved.min + egui::vec2(4.0, 4.0), pos2(moved.max.x - 4.0, moved.center().y - 4.0)); + let lower_half = egui::Rect::from_min_max(pos2(moved.min.x + 4.0, moved.center().y + 4.0), moved.max - egui::vec2(4.0, 4.0)); + let pixels = h.render().unwrap(); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + pixels.save(format!("{dir}/added-image-outside-page-drag.png")).unwrap(); + } + assert!(red(&pixels, upper_half) > 100, "the in-bounds half of the picture follows the drag"); + assert_eq!(red(&pixels, lower_half), 0, "the out-of-bounds half must not smear edge texels across the lower half"); + h.drop_at(grab + shift); + crate::support::settle(&mut h); +} + +/// A short page leaves empty window under the scroll content. A handle dragged into that +/// margin has to start another resize after the pointer is released. +#[test] +fn a_handle_in_the_viewport_below_a_short_page_starts_another_resize() { + let _gpu = gpu(); + let mut h = harness(); + h.state_mut().set_option("zoom", "50").unwrap(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 60, 60); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[200u8, 30, 30].repeat(3600)).unwrap(); + } + h.state_mut().add_image("red.png".into(), png); + h.run_steps(3); + let img = match &added(&h)[0].content { + pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [40.0, 0.0, 100.0, 40.0], ..i.clone() }, + _ => panic!("added an image"), + }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(img) })); + h.run_steps(3); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let grip = item_on_screen(&h, [40.0, 0.0, 100.0, 40.0]).expand(1.0).right_bottom(); + let below = pos2(grip.x, page.bottom() + 80.0); + assert!(below.y < 880.0, "the margin point stays in the window: {below:?}"); + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip + (below - grip) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(below); + h.run_steps(3); + let pulled = added(&h)[0].content.rect(); + assert!(pulled[1] < -5.0, "the first drag leaves the page: {pulled:?}"); + let grip2 = item_on_screen(&h, pulled).expand(1.0).right_bottom(); + assert!(grip2.y > page.bottom() + 40.0, "the handle is below the page margin: grip {grip2:?} page {page:?}"); + let to = pos2(grip2.x, grip2.y - 36.0); + h.hover_at(grip2); + h.run_steps(1); + h.drag_at(grip2); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip2 + (to - grip2) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + let again = added(&h)[0].content.rect(); + assert!(again[1] > pulled[1] + 5.0, "the second press never started a resize: {pulled:?} -> {again:?}"); +} + /// Bug A for added text: with one line of the box selected, an alignment applies to that line /// only, as does a second one, the box stays open, and Undo puts it back and closes the box. #[test] @@ -1491,6 +1662,43 @@ fn typing_in_one_tab_never_reaches_another_tabs_added_text() { assert_eq!(h.state().views[1].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "the other tab's typing stays out"); } +/// Out-of-page editing grips do not turn the invisible image body into a context-menu target. +#[test] +fn image_context_menu_requires_visible_body_even_when_the_frame_crosses_the_page() { + for visible in [false, true] { + let mut h = harness(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let mut png = Vec::new(); + image::RgbImage::from_pixel(20, 20, image::Rgb([200, 30, 30])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + h.state_mut().add_image("edge.png".into(), png); + h.run_steps(3); + let image = match &added(&h)[0].content { + pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [260.0, 150.0, 340.0, 230.0], ..i.clone() }, + _ => panic!("added an image"), + }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { + page: 0, + index: 0, + content: pdfcraft_engine::AddedContent::Image(image), + })); + h.run_steps(3); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + let x = if visible { 280.0 } else { 320.0 }; + let point = item_on_screen(&h, [x, 190.0, x, 190.0]).center(); + assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().contains(point), visible); + h.hover_at(point); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + // Flip horizontal also appears in the persistent sidebar; Arrange image is menu-only. + assert_eq!(h.query_all_by_label("Arrange image").next().is_some(), visible, "image menu requires visible body"); + } +} + /// The rail's page and zoom fields are the same size, and each sits wholly inside the rail, so /// neither box is cut off at the edge (the page box lost its right side once the rail's contents /// changed width).