//! Edit a PDF ▸ Add content in the real shell (egui_kittest): type text onto a page, move it, //! restyle it from the panel, add an image, delete with the keyboard. use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; use crate::gpu; fn harness() -> Harness<'static, PdfCraftApp> { harness_with_step_dt(0.25) } fn harness_with_step_dt(step_dt: f32) -> Harness<'static, PdfCraftApp> { harness_bytes_with_step_dt(include_bytes!("../data/form.pdf").to_vec(), step_dt) } fn harness_bytes(bytes: Vec) -> Harness<'static, PdfCraftApp> { harness_bytes_with_step_dt(bytes, 0.25) } fn harness_bytes_with_step_dt(bytes: Vec, step_dt: f32) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(step_dt).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("form.pdf", None, bytes.clone()).unwrap(); app.set_option("zoom", "150").unwrap(); app }); crate::support::settle(&mut h); h } fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 { let r = h.state().views[0].page_screen_rect(0).expect("on screen"); let k = r.width() / 300.0; r.min + egui::vec2(x * k, y * k) } fn click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2) { h.hover_at(p); h.run_steps(1); h.drag_at(p); h.run_steps(1); h.drop_at(p); h.run_steps(3); } fn added(h: &Harness<'static, PdfCraftApp>) -> Vec { let s = h.state(); s.session.get(s.views[0].id).unwrap().added().to_vec() } /// A click whose press and release arrive in the same frame, as a quick real mouse click does. fn quick_click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2) { h.hover_at(p); h.run_steps(1); for pressed in [true, false] { h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); } h.run_steps(3); } fn texts(h: &Harness<'static, PdfCraftApp>) -> Vec { added(h) .into_iter() .filter_map(|a| match a.content { pdfcraft_engine::AddedContent::Text(t) => Some(t.text), _ => None, }) .collect() } #[test] fn text_tool_drafts_grow_for_latin_and_arabic() { let _gpu = gpu(); for text in ["Wide words ".repeat(8), "سلام عليكم ".repeat(8)] { let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(2); let point = at(&h, 40.0, 300.0); click(&mut h, point); let before = h.state().views[0].content.draft.as_ref().unwrap().rect; h.event(egui::Event::Text(text.clone())); h.run_steps(4); let draft = h.state().views[0].content.draft.as_ref().unwrap(); assert!(draft.rect[2] - draft.rect[0] > before[2] - before[0] + 10.0, "the editor grows before wrapping: {}", text); assert!(draft.rect[2] <= 300.0 && draft.rect[1] < before[1], "the frame stays on the page and grows vertically"); if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { let language = if text.starts_with("Wide") { "latin" } else { "arabic" }; h.render().unwrap().save(format!("{dir}/growing-{language}-text.png")).unwrap(); } h.get_by_label("Done adding text").click(); h.run_steps(3); if text.starts_with("سلام") && pdfcraft_fonts::document_arabic_font().is_none() { assert!(h.state().views[0].content.draft.is_some(), "without a document font the draft remains available"); assert!(added(&h).is_empty()); continue; } let pdfcraft_engine::AddedContent::Text(saved) = &added(&h)[0].content else { panic!() }; assert_eq!(saved.text, text.trim_end()); assert!(saved.rect[2] - saved.rect[0] > before[2] - before[0] + 10.0, "Save uses the grown width"); } } #[test] fn text_tool_single_click_retypes_added_text_without_another_item() { for command in ["edit.text", "edit.edit_text"] { for (original, replacement) in [("Original", "Replacement"), ("سلام", "سلام عليكم ورحمة الله")] { if original == "سلام" && pdfcraft_fonts::document_arabic_font().is_none() { continue; } let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: original.into(), ..Default::default() }, })); assert!(h.state_mut().execute(command)); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); let draft = h.state().views[0].content.draft.as_ref().expect("one click edits the existing item"); assert_eq!(draft.index, Some(0)); assert_eq!(draft.text, original); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text(replacement.into())); h.run_steps(3); h.get_by_label("Done adding text").click(); h.run_steps(3); assert_eq!(texts(&h), [replacement]); let state = h.state(); let bytes = state.session.save_bytes(state.views[0].id).unwrap(); let mut reopened = pdfcraft_engine::Session::new(); let id = reopened.open("again.pdf", None, bytes, None).unwrap(); assert_eq!(reopened.get(id).unwrap().added().len(), 1); let pdfcraft_engine::AddedContent::Text(text) = &reopened.get(id).unwrap().added()[0].content else { panic!() }; assert_eq!(text.text, replacement); } } } #[test] fn text_tool_deleting_a_draft_hides_the_old_added_glyphs_and_cancel_restores_them() { let _gpu = gpu(); let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 220.0], text: "Original glyphs".into(), size: 18.0, ..Default::default() }, })); let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone(); assert!(h.state_mut().execute("edit.edit_text")); h.run_steps(3); let point = at(&h, 45.0, 185.0); click(&mut h, point); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.key_press(egui::Key::Backspace); crate::support::settle(&mut h); assert!(h.state().views[0].content.draft.as_ref().unwrap().text.is_empty()); let shot = h.render().unwrap(); for x in 105..140 { for y in 185..197 { let pixel = at(&h, x as f32, y as f32); let [r, g, b, a] = shot.get_pixel(pixel.x as u32, pixel.y as u32).0; assert!(r > 200 && g > 200 && b > 200 && a == 255, "old glyph ink remains at {x},{y}: {r},{g},{b},{a}"); } } if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { shot.save(format!("{dir}/cleared-added-text.png")).unwrap(); } assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes, "previewing does not edit the document"); h.key_press(egui::Key::Escape); h.run_steps(3); assert_eq!(texts(&h), ["Original glyphs"]); assert!(h.state().views[0].content.draft.is_none()); } #[test] fn clicking_elsewhere_keeps_the_text_and_starts_a_new_box() { // #74: a click elsewhere on the page threw away the text being typed and opened a new box. let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(2); let p = at(&h, 40.0, 100.0); click(&mut h, p); h.event(egui::Event::Text("First".into())); h.run_steps(2); // A slow click (press and release in different frames)… let p = at(&h, 40.0, 200.0); click(&mut h, p); assert_eq!(texts(&h), ["First"]); assert!(h.state().views[0].content.draft.is_some(), "a new box opened"); assert_eq!(h.state().quick_tool, QuickTool::AddText, "still adding text"); h.event(egui::Event::Text("Second".into())); h.run_steps(2); // …and a quick one (both in one frame, as a real mouse click usually arrives). let p = at(&h, 40.0, 300.0); quick_click(&mut h, p); assert_eq!(texts(&h), ["First", "Second"]); // Discard throws away only the box being typed. h.event(egui::Event::Text("Third".into())); h.run_steps(2); h.get_by_label("Discard this text (Esc)").click(); h.run_steps(3); assert_eq!(texts(&h), ["First", "Second"]); assert!(h.state().views[0].content.draft.is_none()); } #[test] fn typing_moving_styling_and_deleting_added_content() { let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(2); h.get_by_label("Edit a PDF"); let p = at(&h, 40.0, 300.0); click(&mut h, p); assert!(h.state().views[0].content.draft.is_some(), "the editor opened"); h.event(egui::Event::Text("Reviewed".into())); h.run_steps(2); // Done keeps the text and goes back to Select, with the new text selected. h.get_by_label("Done adding text").click(); h.run_steps(3); let a = added(&h); assert_eq!(a.len(), 1); let pdfcraft_engine::AddedContent::Text(t) = &a[0].content else { panic!() }; assert_eq!(t.text, "Reviewed"); assert_eq!(h.state().quick_tool, QuickTool::Select); h.run_steps(2); assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the new text is selected"); // Bold from the Format panel: one undoable change. h.get_by_label("Bold").click(); h.run_steps(3); let pdfcraft_engine::AddedContent::Text(t) = &added(&h)[0].content else { panic!() }; assert!(t.bold); { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Edit content")); } // Delete. h.key_press(egui::Key::Delete); h.run_steps(3); assert!(added(&h).is_empty()); // An image lands in the middle of the page, selected. let mut png = Vec::new(); { let mut enc = png::Encoder::new(&mut png, 30, 20); enc.set_color(png::ColorType::Rgb); let mut w = enc.write_header().unwrap(); w.write_image_data(&[90u8; 1800]).unwrap(); } h.state_mut().add_image("logo.png".into(), png); h.run_steps(3); let a = added(&h); assert_eq!(a.len(), 1); assert_eq!(a[0].content.rect(), [135.0, 190.0, 165.0, 210.0]); assert_eq!(h.state().views[0].content.selected, Some((0, 0))); // Edit image: rotate clockwise turns the box around its centre. h.get_by_label("Rotate clockwise").click(); h.run_steps(3); let a = added(&h); let pdfcraft_engine::AddedContent::Image(img) = &a[0].content else { panic!() }; assert_eq!((img.rotation, img.rect), (3, [140.0, 185.0, 160.0, 215.0])); h.get_by_label("Flip horizontal").click(); h.run_steps(3); let pdfcraft_engine::AddedContent::Image(img) = &added(&h)[0].content else { panic!() }; assert!(img.flip_h); } /// #2494: ⌘V / Ctrl+V with a picture on the system clipboard (a screenshot, a picture copied in a /// browser) places it on the current page, as Add Image does. #[test] fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { use pdfcraft_ui_egui::Clip; // Three frames between modifier releases represent a short real key gesture. let mut h = harness_with_step_dt(1.0 / 60.0); let v = |pressed, modifiers| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers }; let images = |h: &Harness<'static, PdfCraftApp>| added(h).iter().filter(|a| matches!(a.content, pdfcraft_engine::AddedContent::Image(_))).count(); // Text on the clipboard: egui gets a Paste event instead of the press; nothing lands on the page. h.state_mut().clipboard_override = Some(Clip::Text("hello".into())); h.event(egui::Event::Paste("hello".into())); h.event(v(false, egui::Modifiers::COMMAND)); h.run_steps(3); assert_eq!(images(&h), 0); h.state_mut().clipboard_override = Some(Clip::Image { width: 30, height: 20, rgba: [90u8, 90, 90, 255].repeat(30 * 20) }); // A plain V comes through pressed and released: it picks the Select tool, it doesn't paste. h.event(v(true, egui::Modifiers::NONE)); h.run_steps(1); h.event(v(false, egui::Modifiers::NONE)); h.run_steps(3); assert_eq!(images(&h), 0, "a plain V doesn't paste"); // The integration delivers image pixels and consumes the native shortcut keys. h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.run_steps(3); let a = added(&h); assert_eq!(a.len(), 1); let pdfcraft_engine::AddedContent::Image(img) = &a[0].content else { panic!("an image was added") }; assert_eq!(img.rect, [135.0, 190.0, 165.0, 210.0], "in the middle of the page, at its own size"); assert_eq!(h.state().views[0].content.selected, Some((0, 0))); { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add image")); } // Modifier release order does not change delivery of the next image paste. h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)); h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.event(v(false, egui::Modifiers::NONE)); h.run_steps(3); assert_eq!(images(&h), 2, "Ctrl released first"); // A text field with the keyboard keeps ⌘V for itself. assert!(h.state_mut().execute("edit.find")); h.run_steps(3); h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.run_steps(3); assert_eq!(images(&h), 2, "the Find field has the keyboard"); h.key_press(egui::Key::Escape); h.run_steps(3); // Organize Pages keeps ⌘V for pages. assert!(h.state_mut().execute("page.organize")); h.run_steps(4); assert!(h.state().views[0].organize); h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.run_steps(3); assert_eq!(images(&h), 2, "Organize Pages pastes pages"); h.state_mut().views[0].organize = false; h.run_steps(3); // Prepare Form keeps ⌘V for form fields. assert!(h.state_mut().execute("form.prepare")); h.run_steps(3); assert!(h.state().is_preparing()); h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.run_steps(3); assert_eq!(images(&h), 2, "Prepare Form pastes fields"); } /// #125: text the standard fonts can't draw stays in the editor with a warning instead of being /// written to the page as `?` (or lost). #[test] fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { if crate::support::skip_without_glyphs("text_the_standard_fonts_cant_draw_stays_in_the_editor", "en", "Hello 世界") { return; } let _gpu = gpu(); let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(2); let p = at(&h, 40.0, 300.0); click(&mut h, p); h.event(egui::Event::Text("Hello 世界".into())); h.run_steps(2); h.get_by_label_contains("can't draw “世” (U+4E16)"); if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { h.render().unwrap().save(format!("{dir}/undrawable-text.png")).unwrap(); } h.get_by_label("Done adding text").click(); h.run_steps(3); assert!(texts(&h).is_empty(), "nothing was written to the page"); let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); assert_eq!(draft.as_deref(), Some("Hello 世界"), "the typed text is kept"); // A click elsewhere on the page doesn't finish it, or replace it with a new box. let elsewhere = at(&h, 40.0, 150.0); click(&mut h, elsewhere); let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); assert_eq!(draft.as_deref(), Some("Hello 世界"), "clicking elsewhere keeps the typed text"); assert!(texts(&h).is_empty()); // Once the text can be drawn, Done adds it (Done took the focus, so edit the draft directly). if let Some(d) = h.state_mut().views[0].content.draft.as_mut() { d.text = "Hello world".into(); } h.run_steps(2); h.get_by_label("Done adding text").click(); h.run_steps(3); assert_eq!(texts(&h), ["Hello world"]); } /// Text kept because it can't be drawn goes with its page when the page is deleted, so nothing /// is left blocking Save out of sight. #[test] fn a_kept_draft_goes_with_its_deleted_page() { if crate::support::skip_without_glyphs("a_kept_draft_goes_with_its_deleted_page", "en", "日本語") { return; } let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 })); h.run_steps(2); assert!(h.state_mut().execute("edit.text")); h.state_mut().views[0].go_to_page(1); h.run_steps(4); let p = h.state().views[0].page_screen_rect(1).expect("page 2 on screen").min + egui::vec2(40.0, 40.0); click(&mut h, p); h.event(egui::Event::Text("日本語".into())); h.run_steps(2); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| (d.page, d.text.as_str())), Some((1, "日本語"))); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![1] })); h.run_steps(2); assert!(h.state().views[0].content.draft.is_none()); } #[test] fn typing_thai_text_keeps_marks_and_commits_to_page() { if pdfcraft_fonts::document_thai_font().is_none() { return; } let fonts = pdfcraft_ui_egui::theme::font_definitions(); let face = pdfcraft_fonts::document_thai_font().unwrap(); assert!(fonts.families[&egui::FontFamily::Proportional].contains(&face.name())); let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(2); let p = at(&h, 40.0, 100.0); click(&mut h, p); h.event(egui::Event::Text("น้ำ กิ่ ผู้ใช้ Hello".into())); h.run_steps(3); let p = at(&h, 40.0, 200.0); click(&mut h, p); assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]); } #[test] fn finished_text_over_form_field_keeps_selection_and_drag_ownership() { let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(2); let p = at(&h, 60.0, 202.0); click(&mut h, p); h.event(egui::Event::Text("Reviewed".into())); h.run_steps(2); h.get_by_label("Done adding text").click(); h.run_steps(3); assert_eq!(texts(&h), ["Reviewed"]); let rect = added(&h)[0].content.rect(); let mid_x = ((rect[0] + rect[2]) / 2.0) as f32; let mid_y = ((rect[1] + rect[3]) / 2.0) as f32; assert!((20.0..280.0).contains(&mid_x) && (180.0..200.0).contains(&mid_y), "fixture text must overlap city: {rect:?}"); let center = at(&h, mid_x, 400.0 - mid_y); click(&mut h, center); assert!(h.state().views[0].forms.focus.is_none(), "selecting finished text must not focus the field underneath"); assert_eq!(h.state().views[0].content.selected, Some((0, 0))); h.run_steps(60); let start = at(&h, mid_x, 400.0 - mid_y); let end = at(&h, mid_x, 400.0 - mid_y - 80.0); h.drag_at(start); h.run_steps(1); for step in 1..=40 { h.hover_at(start + (end - start) * (step as f32 / 40.0)); h.run_steps(1); } h.drop_at(end); h.run_steps(4); let moved = added(&h)[0].content.rect(); for (actual, expected) in moved.into_iter().zip([rect[0], rect[1] + 80.0, rect[2], rect[3] + 80.0]) { assert!((actual - expected).abs() < 1.0, "text should move upward: {rect:?} -> {moved:?}"); } assert!(h.state().views[0].forms.focus.is_none()); let bare = at(&h, 270.0, 210.0); click(&mut h, bare); assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); } fn text_over_city() -> Harness<'static, PdfCraftApp> { let mut h = harness_with_step_dt(1.0 / 60.0); assert!(h.state_mut().execute("edit.text")); h.run_steps(2); let p = at(&h, 60.0, 202.0); click(&mut h, p); h.event(egui::Event::Text("Reviewed".into())); h.run_steps(2); h.get_by_label("Done adding text").click(); h.run_steps(3); h } #[test] fn finished_text_over_field_keeps_move_and_resize_cursors() { let mut h = text_over_city(); let rect = added(&h)[0].content.rect(); let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32); h.hover_at(center); h.run_steps(2); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move); // The upper-right handle overlaps the city field; it retains its resize gesture. let corner = at(&h, rect[2] as f32, (400.0 - rect[3]) as f32); h.hover_at(corner); h.run_steps(2); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw); let end = corner + egui::vec2(30.0, -20.0); h.drag_at(corner); h.run_steps(1); for step in 1..=20 { h.hover_at(corner + (end - corner) * (step as f32 / 20.0)); h.run_steps(1); } h.drop_at(end); h.run_steps(4); let resized = added(&h)[0].content.rect(); assert!(resized[2] > rect[2] + 1.0 && resized[3] > rect[3] + 1.0, "resize must retain ownership: {rect:?} -> {resized:?}"); assert!(h.state().views[0].forms.focus.is_none()); } #[test] fn finished_text_over_field_still_opens_its_editor() { let mut h = text_over_city(); let rect = added(&h)[0].content.rect(); let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32); h.run_steps(60); h.hover_at(center); h.run_steps(1); for _ in 0..2 { for pressed in [true, false] { h.event(egui::Event::PointerButton { pos: center, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); } h.run_steps(1); } h.run_steps(2); assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "double click edits the added text"); assert!(h.state().views[0].forms.focus.is_none()); } #[test] fn closing_edit_panel_leaves_form_filling_available() { let mut h = text_over_city(); h.state_mut().set_option("left", "closed").unwrap(); h.run_steps(3); let p = at(&h, 100.0, 210.0); click(&mut h, p); assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); } /// The screen rectangle of an added item's display-space box (y up, from the page's bottom). fn item_on_screen(h: &Harness<'static, PdfCraftApp>, r: [f64; 4]) -> egui::Rect { let s = h.state(); let ph = s.session.get(s.views[0].id).unwrap().info.pages[0].height; let page = s.views[0].page_screen_rect(0).expect("on screen"); let k = page.width() / 300.0; let p = |x: f64, y: f64| page.min + egui::vec2(x as f32 * k, (ph - y as f32) * k); egui::Rect::from_two_pos(p(r[0], r[1]), p(r[2], r[3])) } /// Dark pixels in `r`: glyph ink (the selection outline is blue, the page white). fn ink(pixels: &image::RgbaImage, r: egui::Rect) -> usize { let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); (y0..y1.min(pixels.height())) .flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y))) .filter(|(x, y)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90)) .count() } /// Only the left part of the glyphs: kittest paints its cursor down and right of the pointer. fn probe(r: egui::Rect) -> egui::Rect { egui::Rect::from_min_size(r.min, egui::vec2(r.width() * 0.6, r.height())) } /// Ink in the probes of the text's first and dragged places. fn ink_at(h: &mut Harness<'static, PdfCraftApp>, from: egui::Rect, to: egui::Rect) -> (usize, usize) { let pixels = h.render().unwrap(); (ink(&pixels, probe(from)), ink(&pixels, probe(to))) } /// Types text, finishes it, and drags it up without releasing, until the drag preview shows. /// Returns the text's box, its screen rectangles before and after, and the pointer. fn drag_added_text(h: &mut Harness<'static, PdfCraftApp>) -> ([f64; 4], egui::Rect, egui::Rect, Pos2) { assert!(h.state_mut().execute("edit.text")); h.run_steps(2); let p = at(h, 40.0, 300.0); click(h, p); h.event(egui::Event::Text("MMMMMMMM".into())); h.run_steps(2); h.get_by_label("Done adding text").click(); h.run_steps(3); let rect = added(h)[0].content.rect(); let from = item_on_screen(h, rect); let mut shown = 0; for _ in 0..100 { h.run_steps(2); shown = ink(&h.render().unwrap(), probe(from)); if shown > 20 { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } assert!(shown > 20, "the added text is on the page ({shown} dark pixels)"); let grab = pos2(from.right() - 12.0, from.center().y); let shift = egui::vec2(0.0, -150.0); h.hover_at(grab); h.run_steps(1); h.drag_at(grab); h.run_steps(1); h.hover_at(grab + shift); let to = from.translate(shift); for _ in 0..100 { h.run_steps(2); let (old, new) = ink_at(h, from, to); if old == 0 && new > 20 { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } (rect, from, to, grab + shift) } /// #1488: while a finished text box is dragged, its text follows the box instead of staying put /// until the button is released. #[test] fn dragged_text_follows_the_pointer_before_release() { let _gpu = gpu(); let mut h = harness(); let (rect, from, to, end) = drag_added_text(&mut h); if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { h.render().unwrap().save(format!("{dir}/added-text-drag.png")).unwrap(); } let (old, new) = ink_at(&mut h, from, to); assert!(new > 20, "the text is drawn where the box was dragged ({new} dark pixels)"); assert_eq!(old, 0, "and no longer where it was"); assert_eq!(added(&h)[0].content.rect(), rect, "nothing is applied before release"); h.drop_at(end); h.run_steps(3); assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); } /// #4464: what a dragged text box covered in its first place stays there; only the text follows /// the pointer. The page has a red square under the text's first place and nothing elsewhere. #[test] fn dragged_text_leaves_what_it_covered_behind() { let _gpu = gpu(); let content = "1 0 0 rg 20 60 240 70 re f"; let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", content.len() ); let mut h = harness_bytes(pdf.into_bytes()); let (rect, from, to, end) = drag_added_text(&mut h); assert!(rect[0] > 20.0 && rect[2] < 260.0 && rect[1] > 60.0 && rect[3] < 130.0, "the text starts on the red square: {rect:?}"); if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { h.render().unwrap().save(format!("{dir}/added-text-drag-over-red.png")).unwrap(); } let pixels = h.render().unwrap(); let red = |r: egui::Rect| { let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); (y0..y1.min(pixels.height())) .flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y))) .filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 200 && g < 80 && b < 80)) .count() }; let (old, new) = ink_at(&mut h, from, to); assert!(new > 20 && old == 0, "the text follows the pointer ({old} old, {new} new)"); assert!(red(from) > 100, "the red square stays where the text was ({} red pixels)", red(from)); assert_eq!(red(to), 0, "and doesn't come along with the text"); h.drop_at(end); h.run_steps(3); assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); } /// After release the text stays in its new place while the page is re-rendered with the move, /// instead of jumping back to where it was for those frames. #[test] fn released_text_does_not_jump_back_before_the_page_is_redrawn() { let _gpu = gpu(); // A page slow enough to render that the frames before its new raster can be looked at: many // pale squares, too light to count as ink. let mut content = String::from("0.9 g\n"); for i in 0..60_000 { content.push_str(&format!("{} {} 1 1 re f\n", i % 290, (i / 290) % 390)); } let pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", content.len() ); let mut h = harness_bytes(pdf.into_bytes()); let (rect, from, to, end) = drag_added_text(&mut h); h.drop_at(end); // Frame by frame until the page has been re-rendered. The one frame that swaps in the new // raster reads back blank here (kittest renders it after its old texture is freed). let mut shown = Vec::new(); while h.state().render_pending() || shown.is_empty() { h.run_steps(1); assert!(shown.len() < 500, "the page is re-rendered"); let (old, new) = ink_at(&mut h, from, to); assert_eq!(old, 0, "frame {} after release: the text is back where it was", shown.len() + 1); shown.push(new); } assert!(shown.len() > 2, "frames before the new raster: {shown:?}"); assert!(shown.iter().filter(|n| **n <= 20).count() <= 1, "the text stays where it was released: {shown:?}"); assert_ne!(added(&h)[0].content.rect(), rect, "the move is applied"); h.run_steps(2); let (old, new) = ink_at(&mut h, from, to); assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); } /// Format text is laid out as Acrobat's: alignment from its flyout, style buttons by name, and /// the controls PdfCraft can't apply yet present but disabled. /// Format text lines its rows up (#3037): the size list is centred beside the colour swatch and /// the alignment button and is as tall as the font list, each spacing row's controls share one /// line, and nothing reaches past the font list, which spans the panel. #[test] fn format_text_rows_line_up_and_fit_the_panel() { use egui::accesskit::Role; let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); h.get_by_label("FORMAT TEXT"); let combo = |h: &Harness<'static, PdfCraftApp>, value: &str| { h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some(value)).map(|n| n.rect()).expect(value) }; let (font, size) = (combo(&h, "Helvetica"), h.get_by_label("Font size:").rect()); let middle = |r: egui::Rect| r.center().y; for label in ["Colour", "Alignment"] { let other = h.get_by_label(label).rect(); assert!((middle(size) - middle(other)).abs() < 0.5, "the size list ({size:?}) is centred with {label} ({other:?})"); } assert_eq!(size.height(), font.height(), "the font and size lists are the same height"); // Line and paragraph spacing; scale and character spacing with their lists. let line = [combo(&h, "Auto"), combo(&h, "0")]; assert!((middle(line[0]) - middle(line[1])).abs() < 0.5, "{line:?}"); // Each: its number field and the arrow that lists common values. let scale_row: Vec = ["Horizontal scale", "Character spacing"].iter().flat_map(|l| h.query_all_by_label(l).map(|n| n.rect())).collect(); assert_eq!(scale_row.len(), 4, "two fields and their two arrows: {scale_row:?}"); for r in &scale_row { assert!((middle(*r) - middle(scale_row[0])).abs() < 0.5, "{scale_row:?}"); } for r in h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).chain(scale_row).chain([size, h.get_by_label("Font size").rect()]) { assert!(r.right() <= font.right() + 0.5, "{r:?} reaches past the panel ({font:?})"); } } #[test] fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { use egui_kittest::kittest::NodeT; let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); h.get_by_label("FORMAT TEXT"); for planned in ["Bulleted list", "Numbered list", "Superscript", "Subscript"] { assert!(h.get_by_label(planned).accesskit_node().is_disabled(), "{planned} is shown but disabled"); } h.get_by_label("Alignment").click(); h.run_steps(2); h.get_by_label("Centre").click(); h.run_steps(2); assert_eq!(h.state().text_style.align, pdfcraft_engine::TextAlign::Center); h.get_by_label("Bold").click(); h.run_steps(2); assert!(h.state().text_style.bold); } /// Opening right-aligned added text to edit it shows it right-aligned, as the page does, not at /// the box's left edge, where it never was. #[test] fn the_text_editor_keeps_the_boxs_alignment() { let _gpu = gpu(); let mut h = harness(); let rect = [40.0, 195.0, 240.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 200.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.run_steps(3); let r = item_on_screen(&h, rect); let pixels = h.render().unwrap(); let left = ink(&pixels, egui::Rect::from_min_max(r.min, pos2(r.center().x, r.max.y))); let right = ink(&pixels, egui::Rect::from_min_max(pos2(r.center().x, r.min.y), r.max)); assert!(left == 0 && right > 20, "the text is drawn at the right ({left} dark pixels left, {right} right)"); } /// A box narrower than the editor's least width (60 points on screen): right-aligned text stays /// at the box's right edge, not the wider editor's, which lies past it. #[test] fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() { let _gpu = gpu(); let mut h = harness(); let rect = [40.0, 195.0, 60.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 50.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.run_steps(3); let r = item_on_screen(&h, rect); assert!(r.width() < 60.0, "the box is narrower than the editor: {}", r.width()); let pixels = h.render().unwrap(); let inside = ink(&pixels, r); let edge = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() - r.width() * 0.25, r.min.y), r.max)); let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); assert!( inside > 20 && edge > 0 && past == 0, "the text is drawn in its box, up to its right edge ({inside} dark pixels in it, {edge} in its last quarter, {past} past it)" ); } /// Added text takes character spacing from Format text (#4245), typed like a paragraph's; its /// line spacing and horizontal scale stay disabled until added text has them. #[test] fn format_text_sets_character_spacing_for_added_text() { use egui::accesskit::Role; use egui_kittest::kittest::NodeT; let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let disabled = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_role_and_label(Role::SpinButton, label).accesskit_node().is_disabled(); assert!(disabled(&h, "Horizontal scale"), "added text has no scale yet"); assert!(!disabled(&h, "Character spacing"), "added text takes character spacing"); h.get_by_role_and_label(Role::SpinButton, "Character spacing").click(); h.run_steps(2); h.event(egui::Event::Text("2.5".into())); h.run_steps(1); h.key_press(egui::Key::Enter); h.run_steps(4); assert_eq!(h.state().text_style.char_spacing, 2.5); } /// The same when the line is right-aligned on its own while the box keeps Left: all its lines /// share one alignment, so the editor keeps the box's right edge for them. #[test] fn the_text_editor_aligns_a_narrow_box_whose_lines_share_an_alignment() { let _gpu = gpu(); let mut h = harness(); let rect = [40.0, 195.0, 60.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), aligns: vec![Some(pdfcraft_engine::TextAlign::Right)], ..Default::default() }; assert_eq!(text.align, pdfcraft_engine::TextAlign::Left); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 50.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.run_steps(3); let r = item_on_screen(&h, rect); let pixels = h.render().unwrap(); let inside = ink(&pixels, r); let edge = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() - r.width() * 0.25, r.min.y), r.max)); let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); assert!( inside > 20 && edge > 0 && past == 0, "the text is drawn in its box, up to its right edge ({inside} dark pixels in it, {edge} in its last quarter, {past} past it)" ); } /// Where a mode bar tab is, by its label. fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") } /// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each /// tab it lies under: with the All tools panel closed, every tab: All tools, Edit, Convert and E-Sign. fn mode_tab_cursors( h: &mut Harness<'static, PdfCraftApp>, what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect, ) -> [(&'static str, egui::CursorIcon); 4] { let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen"); let y = what(h).center().y - page(h).top(); let tabs = mode_tab(h, "Convert").center().y; h.state_mut().views[0].goto = Some((0, 0.0)); h.run_steps(4); let top = page(h).top(); h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height())); h.run_steps(4); let under = what(h); ["All tools", "Edit", "Convert", "E-Sign"].map(|label| { let at = mode_tab(h, label).center(); assert!(under.contains(at), "{under:?} lies under {label} at {at:?}"); h.hover_at(at); h.run_steps(2); (label, h.output().platform_output.cursor_icon) }) } const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ ("All tools", egui::CursorIcon::PointingHand), ("Edit", egui::CursorIcon::PointingHand), ("Convert", egui::CursorIcon::PointingHand), ("E-Sign", egui::CursorIcon::PointingHand), ]; /// Add text, with the page scrolled up under the mode bar, leaves its tabs the hand (#2755): the /// tool gave them its I-beam, added text its four-way arrow, and the open editor floated over /// them and took their clicks. #[test] fn mode_tabs_keep_the_hand_over_added_text_scrolled_under_them() { let mut h = harness(); assert!(h.state_mut().execute("edit.text")); h.state_mut().set_option("left", "closed").unwrap(); // The page across the window, so text placed at its left edge reaches under the tabs. h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; h.run_steps(4); assert_eq!(h.state().quick_tool, QuickTool::AddText); // The empty page, ready for text. assert_eq!(mode_tab_cursors(&mut h, |h| item_on_screen(h, [10.0, 290.0, 210.0, 305.0])), HAND_ON_EVERY_TAB, "the page under the tabs"); // Text kept by a click lower down, which starts a second box (#74). h.state_mut().views[0].goto = Some((0, 0.0)); h.run_steps(4); let p = at(&h, 10.0, 40.0); click(&mut h, p); h.event(egui::Event::Text("Reviewed".into())); h.run_steps(2); let p = at(&h, 10.0, 120.0); click(&mut h, p); h.event(egui::Event::Text("Approved".into())); h.run_steps(2); assert_eq!(texts(&h), ["Reviewed"]); let kept = added(&h)[0].content.rect(); assert_eq!(mode_tab_cursors(&mut h, |h| item_on_screen(h, kept)), HAND_ON_EVERY_TAB, "added text under the tabs"); // The second box, still being typed. let draft = |h: &Harness<'static, PdfCraftApp>| item_on_screen(h, h.state().views[0].content.draft.as_ref().expect("the open box").rect); assert_eq!(mode_tab_cursors(&mut h, draft), HAND_ON_EVERY_TAB, "the open box under the tabs"); let convert = mode_tab(&h, "Convert").center(); click(&mut h, convert); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the box"); } /// Red pixels in `r`: the test picture. fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize { let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); (y0..y1.min(pixels.height())) .flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y))) .filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 150 && g < 90 && b < 90)) .count() } /// An added picture resized by a corner is drawn at its new size while dragged and after the /// release until the page is rendered again. It used to stay at its old size under the outline for /// the whole drag, and to show there again after the release. #[test] fn a_resized_added_picture_shows_only_at_its_new_size() { 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); assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the picture is selected"); crate::support::settle(&mut h); let rect = added(&h)[0].content.rect(); let r = item_on_screen(&h, rect); // The lower right corner to the middle: half the size, the upper left corner kept. let (grip, to) = (r.expand(1.0).right_bottom(), r.center()); let kept = egui::Rect::from_min_max(r.min, r.center()).shrink(3.0); let gone = egui::Rect::from_min_max(r.center() + egui::vec2(4.0, 4.0), r.max - egui::vec2(3.0, 3.0)); 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 + (to - grip) * (k as f32 / 4.0)); h.run_steps(1); } crate::support::settle(&mut h); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100, "while dragged, the picture is drawn at its new size"); assert_eq!(red(&pixels, gone), 0, "while dragged, the picture shows at its old size"); assert_eq!(added(&h)[0].content.rect(), rect, "nothing is applied before release"); h.state_mut().views[0].hold_rasters = true; h.drop_at(to); for frame in 0..4 { h.step(); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100, "frame {frame} after the release: the picture is not at its new size"); assert_eq!(red(&pixels, gone), 0, "frame {frame} after the release: the picture shows at its old size"); } assert_ne!(added(&h)[0].content.rect(), rect, "the resize is applied"); h.state_mut().views[0].hold_rasters = false; crate::support::settle(&mut h); h.run_steps(2); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); } /// 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] fn aligning_a_selected_line_of_added_text_aligns_only_that_line() { let mut h = harness(); let rect = [40.0, 195.0, 240.0, 220.0]; let text = pdfcraft_engine::AddedText { rect, text: "Line one\nLine two".into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Home); h.run_steps(2); let button = h.get_by_label("Alignment").rect().center(); click(&mut h, button); let item = h.get_by_label("Align right").rect().center(); click(&mut h, item); let aligns = |h: &Harness<'static, PdfCraftApp>| match &added(h)[0].content { pdfcraft_engine::AddedContent::Text(t) => (t.align, t.aligns.clone()), _ => panic!(), }; assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)])); assert!(h.state().views[0].content.draft.is_some(), "the box stays open"); // A second choice, the line still selected, is for that line again. let button = h.get_by_label("Alignment").rect().center(); click(&mut h, button); let item = h.get_by_label("Centre").rect().center(); click(&mut h, item); assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Center)])); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); h.run_steps(4); assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)]), "Undo puts it back"); assert!(h.state().views[0].content.draft.is_none(), "and closes the box"); assert!(h.state_mut().execute("edit.undo")); assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![])); } /// Two lines of added text with the same words, each aligned its own way: deleting the first line /// with its break leaves the second line's alignment, not the deleted one's (CodeRabbit on /// 648dd428). #[test] fn deleting_the_first_of_two_same_lines_keeps_the_second_lines_alignment() { use pdfcraft_engine::TextAlign::{Center, Right}; let mut h = harness(); let rect = [40.0, 195.0, 240.0, 220.0]; let text = pdfcraft_engine::AddedText { rect, text: "Same\nSame".into(), aligns: vec![Some(Center), Some(Right)], ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 120.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); // Select the first line with its break, and delete it. h.key_press(egui::Key::ArrowUp); h.key_press(egui::Key::Home); // "Same" and its line break. for _ in 0..5 { h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::ArrowRight); } h.key_press(egui::Key::Backspace); h.run_steps(2); let d = h.state().views[0].content.draft.as_ref().expect("still typing"); assert_eq!((d.text.as_str(), d.style.aligns.clone()), ("Same", vec![Some(Right)]), "the second line's alignment stays"); } /// The same with the alignment controls reached by keyboard or a screen reader (focused, then /// pressed, with no pointer): the box stays open when the focus leaves it for the Format text /// panel, and both choices are for the selected line. #[test] fn aligning_a_selected_line_of_added_text_by_keyboard_keeps_the_box_open() { let mut h = harness(); let rect = [40.0, 195.0, 240.0, 220.0]; let text = pdfcraft_engine::AddedText { rect, text: "Line one Line two" .into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Home); h.run_steps(2); let align_by_focus = |h: &mut Harness<'static, PdfCraftApp>, choice: &str| { for label in ["Alignment", choice] { h.get_by_label(label).focus(); h.run_steps(2); h.get_by_label(label).click(); h.run_steps(3); } }; let aligns = |h: &Harness<'static, PdfCraftApp>| match &added(h)[0].content { pdfcraft_engine::AddedContent::Text(t) => (t.align, t.aligns.clone()), _ => panic!(), }; align_by_focus(&mut h, "Align right"); assert!(h.state().views[0].content.draft.is_some(), "the box stays open"); assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)])); align_by_focus(&mut h, "Centre"); assert!(h.state().views[0].content.draft.is_some(), "the box is still open"); assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Center)])); } /// The same in an added text box: Redo after Undo puts the typing back in the box, and the /// document's own Redo stays waiting. #[test] fn redo_after_undoing_typing_in_added_text_puts_it_back() { let mut h = harness(); let rect = [40.0, 195.0, 240.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); let bold = pdfcraft_engine::AddedText { bold: true, ..text }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); assert!(h.state_mut().execute("edit.undo")); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" twice".into())); h.run_steps(2); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed")); h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts the typing back"); let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); assert_eq!(doc.can_redo(), Some("Edit content"), "the document's Redo still waits"); } /// Redo from the menu with retyping the page doesn't have yet in an added text box: the typing /// stays in the box and the document's Redo waits (closing the box first wrote the typing, which /// dropped that Redo). After Undo in the box, Redo from the menu puts the typing back there. #[test] fn redo_from_the_menu_with_retyping_in_the_box_keeps_the_documents_redo() { let mut h = harness(); let rect = [40.0, 195.0, 240.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); let bold = pdfcraft_engine::AddedText { bold: true, ..text }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); assert!(h.state_mut().execute("edit.undo")); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" twice".into())); h.run_steps(2); assert!(h.state_mut().execute("edit.redo")); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "the typing stays in the box"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); assert!(h.state_mut().execute("edit.redo")); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo from the menu puts it back"); let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit content"), "the document's Redo still waits"); } /// Undo from the menu with retyping the page doesn't have yet in an added text box: the box takes /// it back and stays open, and the document's waiting Redo stays. #[test] fn undo_from_the_menu_takes_back_retyping_in_the_added_text_box() { let mut h = harness(); let rect = [40.0, 195.0, 240.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); let bold = pdfcraft_engine::AddedText { bold: true, ..text }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); assert!(h.state_mut().execute("edit.undo")); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" twice".into())); h.run_steps(2); h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Home); h.run_steps(2); assert!(h.state().views[0].content.draft.as_ref().unwrap().selection.is_some()); h.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("test-menu"))); assert!(h.state_mut().execute("edit.undo")); assert_eq!(h.state().views[0].content.draft.as_ref().unwrap().selection, None, "menu history clears the old byte range before the next frame"); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit content"), "the document's Redo still waits"); } /// Redo from the menu after Undo in an added text box, with nothing for the document to redo: the /// menu item follows the box's own Redo too, and puts the typing back. #[test] fn redo_from_the_menu_is_enabled_for_the_added_text_boxs_own_typing() { let mut h = harness(); let rect = [40.0, 195.0, 240.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" twice".into())); h.run_steps(2); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), None, "the document has nothing to redo"); // A range retained while the unfocused editor still showed its previous text. h.state_mut().views[0].content.draft.as_mut().unwrap().selection = Some(0..1); h.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("test-menu"))); assert!(h.state_mut().execute("edit.redo"), "Redo is enabled for the box's own step"); assert_eq!(h.state().views[0].content.draft.as_ref().unwrap().selection, None, "menu history clears the old byte range before the next frame"); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts it back"); } /// Saved added text of three lines aligned Left, Centre and Right: the centred line deleted /// with its line break, then brought back with Ctrl+Z, is centred again, and Ctrl+Shift+Z deletes /// it again. The box's history keeps only text, so the restored line took the first line's /// alignment, and finishing the box would have saved that. #[test] fn undo_brings_a_deleted_line_of_added_text_back_with_its_alignment() { use pdfcraft_engine::TextAlign::{Center, Right}; let mut h = harness(); let rect = [40.0, 170.0, 240.0, 215.0]; let aligns = vec![None, Some(Center), Some(Right)]; let text = pdfcraft_engine::AddedText { rect, text: "A\nB\nC".into(), aligns: aligns.clone(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); // On the first line, at the top of the box. let point = at(&h, 45.0, 205.0); click(&mut h, point); let draft = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].content.draft.clone().expect("the box is open"); assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nB\nC".to_string(), aligns.clone()), "the editor opened on the text"); // Select the second line with its line break, and delete it. h.run_steps(2); // To the start of the first line, wherever the click put the caret. for _ in 0..3 { h.key_press(egui::Key::ArrowUp); } h.key_press(egui::Key::Home); h.run_steps(1); h.key_press(egui::Key::ArrowDown); h.run_steps(1); h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::ArrowDown); h.key_press(egui::Key::Backspace); h.run_steps(2); assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "the centred line is gone"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); h.run_steps(3); assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nB\nC".to_string(), aligns), "Undo brings it back centred"); h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); h.run_steps(3); assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "Redo deletes it again"); } /// Typing in tab A's added text box, the same text opened in tab B, then back to A: Edit ▸ Undo /// takes the typing back in A's box, which stays open. #[test] fn undo_after_a_round_trip_through_another_tab_takes_back_added_text_typing() { let mut h = harness(); h.state_mut().open_bytes("copy.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); h.run_steps(4); let text = pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 210.0], text: "Reviewed".into(), ..Default::default() }; let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { h.state_mut().active = Some(tab); h.run_steps(2); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); let k = r.width() / 300.0; click(h, r.min + egui::vec2(45.0 * k, 195.0 * k)); assert_eq!(h.state().views[tab].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); }; for tab in [0, 1] { h.state_mut().active = Some(tab); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); } open_in(&mut h, 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" twice".into())); h.run_steps(2); open_in(&mut h, 1); h.state_mut().active = Some(0); h.run_steps(3); let draft = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); assert_eq!(draft(&h).as_deref(), Some("Reviewed twice"), "A's box is still open with the typing"); assert!(h.state_mut().execute("edit.undo")); h.run_steps(3); assert_eq!(draft(&h).as_deref(), Some("Reviewed"), "Undo takes the typing back in A's box"); } /// Edit ▸ Undo chosen in the same frame as the switch back to tab A's added text box, before it /// is drawn again: the shared text box still holds tab B's history, from a box on other text, so /// A's box claims its own back before stepping. #[test] fn undo_in_the_frame_of_the_switch_back_takes_back_added_text_typing() { let mut h = harness(); h.state_mut().open_bytes("copy.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); h.run_steps(4); let rect = [40.0, 195.0, 240.0, 210.0]; for (tab, text) in [(0, "Reviewed"), (1, "Approved")] { h.state_mut().active = Some(tab); let text = pdfcraft_engine::AddedText { rect, text: text.into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); } let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { h.state_mut().active = Some(tab); h.run_steps(2); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); let k = r.width() / 300.0; click(h, r.min + egui::vec2(45.0 * k, 195.0 * k)); assert_eq!(h.state().views[tab].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); }; open_in(&mut h, 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" twice".into())); h.run_steps(2); open_in(&mut h, 1); // Back to A and Undo at once: no frame draws A's box in between. h.state_mut().active = Some(0); assert!(h.state_mut().execute("edit.undo")); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes the typing back in A's box"); } /// Retyping taken back with Ctrl+Z, then Bold picked with the added text box still open: the /// restyle is applied in place and the page has the box's text, so neither Ctrl+Shift+Z nor the /// menu's Redo may bring the undone retyping back on top of the bold (CodeRabbit on a33a8bba). #[test] fn redo_after_restyling_open_added_text_never_brings_back_typing_undone_before_it() { for route in ["key", "menu"] { let mut h = harness(); let rect = [40.0, 195.0, 240.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" twice".into())); h.run_steps(2); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "{route}: taken back"); h.get_by_label("Bold").click(); h.run_steps(4); let pdfcraft_engine::AddedContent::Text(now) = &added(&h)[0].content else { panic!() }; assert!(now.bold, "{route}: the bold is applied to the item"); assert!(h.state().views[0].content.draft.is_some(), "{route}: the box stays open"); if route == "key" { h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); } else { // The menu item is off: neither the document nor the box has a Redo. assert!(!h.state_mut().execute("edit.redo"), "menu: Redo is refused, there is nothing to redo"); } h.run_steps(3); let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); assert_eq!(draft.as_deref(), Some("Reviewed"), "{route}: the retyping undone before the bold comes back in the box"); let pdfcraft_engine::AddedContent::Text(now) = &added(&h)[0].content else { panic!() }; assert_eq!(now.text, "Reviewed", "{route}: nor in the document"); } } /// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when /// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear /// that Redo first. #[test] fn redo_with_an_unchanged_box_open_redoes_the_document() { let mut h = harness(); let rect = [40.0, 195.0, 240.0, 210.0]; let text = pdfcraft_engine::AddedText { rect, text: "Reviewed ".into(), ..Default::default() }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); let pdfcraft_engine::AddedContent::Text(saved) = added(&h)[0].content.clone() else { panic!() }; assert_eq!(saved.text, "Reviewed ", "the trailing space is kept"); let bold = pdfcraft_engine::AddedText { bold: true, ..saved }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); assert!(h.state_mut().execute("edit.undo")); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let point = at(&h, 45.0, 195.0); click(&mut h, point); assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); h.run_steps(4); let pdfcraft_engine::AddedContent::Text(now) = &added(&h)[0].content else { panic!() }; assert!(now.bold, "the waiting Redo was redone"); assert!(h.state().views[0].content.draft.is_none(), "the box closed with the step"); } /// Two copies of a document, the same added text open in each: the boxes share one Undo history, /// so typing taken back with Undo in one tab came back with Redo in the other. #[test] fn typing_in_one_tab_never_reaches_another_tabs_added_text() { let mut h = harness(); h.state_mut().open_bytes("copy.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); h.run_steps(4); let text = pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 210.0], text: "Reviewed".into(), ..Default::default() }; let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { h.state_mut().active = Some(tab); h.run_steps(2); assert!(h.state_mut().execute("edit.text")); h.run_steps(3); let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); let k = r.width() / 300.0; click(h, r.min + egui::vec2(45.0 * k, 195.0 * k)); assert_eq!(h.state().views[tab].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); }; for tab in [0, 1] { h.state_mut().active = Some(tab); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); } open_in(&mut h, 1); open_in(&mut h, 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" secret".into())); h.run_steps(2); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "taken back"); h.state_mut().active = Some(1); h.run_steps(3); h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); h.run_steps(3); 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"); }