Files
pdfcraft/crates/ui-egui/tests/object_selection.rs
T
322a1b2ac0 M7: lay out the Edit panel as Acrobat's (#2245)
* M7: the Edit tab turns on Edit text & images, as in Acrobat

Entering Edit boxes every paragraph and image on the page at once, so text
is editable with one click instead of first finding Edit text & images in
the tool list. The floating toolbar gets an Edit text & images button while
Edit is open or the tool is in use. Leaving Edit (or turning the tool off
there) applies a paragraph still open in the editor, as clicking away from
it does; left open, its page kept showing the paragraph's text removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: parity notes for the Edit tab turning on Edit text & images

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: the panel-reveal test looks for the Edit panel's own row

With Edit text & images on, its name is on the floating toolbar as well as in the panel, so
the label alone matched twice and no longer showed that the Tools tab was revealed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: lay out the Edit panel as Acrobat's

Under the Edit tab the panel is titled Edit, with Preferences and Close;
Modify page is a row of icon buttons (rotate, insert, delete, extract) and
Organize pages; Add content lists Text, Image, Header and footer, Watermark
and Link, with the rest under More; Other options links Combine files,
Redact a PDF and Prepare a form. The milestone banner and Ready chips are
gone; a planned item is muted and says when it ships. Every row still
comes from the catalogue. Edit text & images is on the floating toolbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: Organize pages in the Edit panel opens the Organize workspace

It ran page.organize, which showed the page grid behind the Edit panel;
Acrobat replaces the panel with Organize pages' own. It now selects the
Organize tool, so its panel and the grid open together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: translate the Edit panel's Less and Other options

Every catalogue gets the two strings the Acrobat-style Edit panel adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(i18n): refresh the catalogue hashes in ATTRIBUTION.toml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: no idle selection hint above Format text in the Edit panel

"Shift-click to select several objects. Drag empty space to select an
area." sat at the top of the Edit panel whenever Edit text & images was
on, pushing Format text down; Acrobat's panel has no such line. The
selection count and how to move it still show while objects are
selected. Tests that read the hint as "nothing selected" check that no
selection count shows instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: let auto-deref find the Edit panel's buttons (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: the Edit panel keeps main's Add / Update / Remove menu

Main gave Header and footer, Watermark and Background a menu of Add,
Update and Remove in the tool panel (#1857). This branch draws the Edit
group in its own panel, so those rows ran only Add and Update / Remove
were unreachable there. Both panels now share one helper for the caret
and the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Edit panel: Modify page buttons follow command enablement; Organize pages with no document asks for a file

CodeRabbit on #2245: the four Modify page buttons were enabled from the catalogue
(always Ready) while execute() refuses them on a document that forbids page assembly;
they now use command_enabled. Organize pages with no document open selected a panel
with no grid to show; it now runs page.organize, which asks for a file first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:33:59 -04:00

393 lines
15 KiB
Rust

//! Multi-object editing through the real shell. Synthetic artwork and standard-font text only.
use egui::{Key, Modifiers, Pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::{AddedText, Edit, EditableObject};
use pdfcraft_ui_egui::PdfCraftApp;
fn fixture(clipping: bool) -> Vec<u8> {
fixture_with_background(clipping, false)
}
fn fixture_with_background(clipping: bool, background: bool) -> Vec<u8> {
let stream = |body: &str, dict: &str| format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len());
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Figure 6 0 R >> >> >>"
.into(),
stream(
&format!(
"{}q BT {} Tr /F1 18 Tf 40 430 Td (Group headline) Tj ET Q q 1 0 0 1 40 340 cm /Figure Do Q BT /F1 12 Tf 240 90 Td (Keep here) Tj ET",
if background { "q /Background Do Q " } else { "" },
if clipping { 4 } else { 0 }
),
"",
),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
stream("1 0.2 0.1 rg 0 0 60 30 re f", "/Type /XObject /Subtype /Form /BBox [0 0 60 30]"),
];
if background {
objects[2] = objects[2].replace("/Figure 6 0 R", "/Figure 6 0 R /Background 7 0 R");
objects.push(stream("1 1 1 rg 0 0 400 500 re f", "/Type /XObject /Subtype /Form /BBox [0 0 400 500]"));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
fn harness() -> Harness<'static, PdfCraftApp> {
harness_with(false)
}
fn harness_with(clipping: bool) -> Harness<'static, PdfCraftApp> {
harness_document(clipping, false)
}
fn harness_document(clipping: bool, background: bool) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.session = pdfcraft_engine::Session::new().with_clock(|| 1_700_000_000);
app.set_option("language", "en").unwrap();
app.open_bytes("group.pdf", None, fixture_with_background(clipping, background)).unwrap();
let id = app.views[0].id;
app.session
.apply(
id,
Edit::AddText { page: 0, text: AddedText { text: "Added note".into(), rect: [130.0, 340.0, 230.0, 370.0], ..Default::default() } },
)
.unwrap();
let saved = app.session.save_bytes(id).unwrap();
app.session.mark_saved(id, saved, None).unwrap();
app.set_option("zoom", "100").unwrap();
assert!(app.execute("edit.edit_text"));
app
});
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
h
}
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
let r = h.state().views[0].page_screen_rect(0).unwrap();
r.min + egui::vec2(x, y) * (r.width() / 400.0)
}
fn objects(h: &Harness<'static, PdfCraftApp>) -> Vec<EditableObject> {
let app = h.state();
app.session.get(app.views[0].id).unwrap().editable_objects(0).unwrap()
}
fn click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2, mods: Modifiers) {
h.hover_at(p);
h.run_steps(2);
for pressed in [true, false] {
h.event_modifiers(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: mods }, mods);
}
h.run_steps(4);
}
fn marquee(h: &mut Harness<'static, PdfCraftApp>) {
let (a, b) = (at(h, 25.0, 45.0), at(h, 235.0, 175.0));
h.hover_at(a);
h.run_steps(2);
h.drag_at(a);
h.run_steps(2);
h.hover_at(b);
h.run_steps(2);
h.drop_at(b);
h.run_steps(4);
h.get_by_label("3 objects selected");
}
fn drag(h: &mut Harness<'static, PdfCraftApp>, a: Pos2, b: Pos2) {
h.hover_at(a);
h.run_steps(2);
h.drag_at(a);
h.run_steps(2);
h.hover_at(b);
h.run_steps(2);
h.drop_at(b);
h.run_steps(4);
}
#[test]
fn marquee_and_mixed_group_drag_move_once_and_undo_together() {
let mut h = harness();
let before = objects(&h);
marquee(&mut h);
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0));
drag(&mut h, a, b);
let after = objects(&h);
for old in &before {
let new = after.iter().find(|o| o.target == old.target).unwrap();
let shift = if old.text.as_deref() == Some("Keep here") { [0.0, 0.0] } else { [25.0, -30.0] };
for i in 0..4 {
assert!((new.rect[i] - old.rect[i] - shift[i % 2]).abs() < 0.01, "{old:?} -> {new:?}");
}
}
h.get_by_label("3 objects selected");
let app = h.state();
assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Move objects"));
if let Ok(path) = std::env::var("PDFCRAFT_GROUP_SHOT") {
h.render().unwrap().save(path).unwrap();
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_eq!(objects(&h), before);
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(4);
assert_eq!(objects(&h), after);
}
#[test]
fn modifier_clicks_extend_an_ordinary_selection_and_toggle_objects() {
let mut h = harness();
let p = at(&h, 55.0, 70.0);
click(&mut h, p, Modifiers::NONE);
assert!(h.state().views[0].line_editor.is_some());
let p = at(&h, 60.0, 145.0);
click(&mut h, p, Modifiers::SHIFT);
h.get_by_label("2 objects selected");
assert!(h.state().views[0].line_editor.is_none());
let p = at(&h, 155.0, 140.0);
click(&mut h, p, Modifiers::COMMAND);
h.get_by_label("3 objects selected");
let p = at(&h, 60.0, 145.0);
click(&mut h, p, Modifiers::CTRL);
h.get_by_label("2 objects selected");
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
h.key_press(Key::Escape);
h.run_steps(3);
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
}
#[test]
fn escaping_a_group_drag_and_switching_tools_do_not_edit() {
let mut h = harness();
let before = objects(&h);
marquee(&mut h);
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 90.0, 110.0));
h.hover_at(a);
h.run_steps(2);
h.drag_at(a);
h.run_steps(2);
h.hover_at(b);
h.run_steps(2);
assert_eq!(objects(&h), before, "only a preview while held");
h.key_press(Key::Escape);
h.run_steps(2);
h.drop_at(b);
h.run_steps(4);
assert_eq!(objects(&h), before);
marquee(&mut h);
assert!(h.state_mut().execute("edit.text"));
h.run_steps(4);
assert_eq!(objects(&h), before);
}
#[test]
fn a_changed_text_editor_is_kept_when_group_selection_is_requested() {
let mut h = harness();
let before = objects(&h);
let p = at(&h, 55.0, 70.0);
click(&mut h, p, Modifiers::NONE);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "A draft still being edited".into();
let p = at(&h, 60.0, 145.0);
click(&mut h, p, Modifiers::SHIFT);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, "A draft still being edited");
assert_eq!(objects(&h), before);
}
#[test]
fn a_document_change_invalidates_old_selection_before_the_next_drag() {
let mut h = harness();
marquee(&mut h);
let id = h.state().views[0].id;
h.state_mut()
.session
.apply(id, Edit::AddText { page: 0, text: AddedText { text: "A new item".into(), rect: [40.0, 210.0, 150.0, 240.0], ..Default::default() } })
.unwrap();
h.run_steps(4);
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
assert_eq!(objects(&h).len(), 5);
}
#[test]
fn refusing_one_object_keeps_the_whole_group_and_its_selection() {
let mut h = harness_with(true);
let before = objects(&h);
marquee(&mut h);
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0));
drag(&mut h, a, b);
assert_eq!(objects(&h), before);
h.get_by_label("3 objects selected");
assert!(h.state().toast.as_ref().unwrap().0.contains("Text used as a clipping path cannot be moved with this selection."));
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
}
#[test]
fn a_group_drag_released_beyond_the_page_finishes_once() {
let mut h = harness();
let before = objects(&h);
marquee(&mut h);
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 415.0, 100.0));
drag(&mut h, a, b);
let after = objects(&h);
for (old, new) in before.iter().zip(&after) {
let delta = if old.text.as_deref() == Some("Keep here") { 0.0 } else { 360.0 };
assert!((new.rect[0] - old.rect[0] - delta).abs() < 0.01);
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_eq!(objects(&h), before);
}
#[test]
fn group_selection_can_be_extended_by_an_additive_marquee() {
let mut h = harness();
let p = at(&h, 55.0, 70.0);
click(&mut h, p, Modifiers::SHIFT);
h.get_by_label("1 object selected");
let (a, b) = (at(&h, 25.0, 120.0), at(&h, 235.0, 175.0));
h.hover_at(a);
h.run_steps(2);
h.event_modifiers(
egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT },
Modifiers::SHIFT,
);
h.run_steps(2);
h.hover_at(b);
h.run_steps(2);
h.drop_at(b);
h.run_steps(4);
h.get_by_label("3 objects selected");
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
}
#[test]
fn group_drag_respects_document_rotation_and_view_rotation() {
for document_rotation in [0, 90, 180, 270] {
let mut h = harness();
assert!(h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: document_rotation }));
h.state_mut().views[0].rotate_view(true);
h.run_steps(4);
let before = objects(&h);
let targets: Vec<_> = before.iter().filter(|o| o.text.as_deref() != Some("Keep here")).map(|o| o.target).collect();
for target in &targets {
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
let o = before.iter().find(|o| o.target == *target).unwrap();
let p = pdfcraft_ui_egui::canvas::PageXform {
rect: app.views[0].page_screen_rect(0).unwrap(),
rot: 90,
pw: doc.info.pages[0].width,
ph: doc.info.pages[0].height,
}
.user_rect(&doc.info, 0, o.rect.map(|v| v as f32))
.center();
click(&mut h, p, Modifiers::SHIFT);
}
h.get_by_label("3 objects selected");
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
let xf = pdfcraft_ui_egui::canvas::PageXform {
rect: app.views[0].page_screen_rect(0).unwrap(),
rot: 90,
pw: doc.info.pages[0].width,
ph: doc.info.pages[0].height,
};
let start =
xf.user_rect(&doc.info, 0, before.iter().find(|o| o.text.as_deref() == Some("Group headline")).unwrap().rect.map(|v| v as f32)).center();
let end = start + egui::vec2(21.0, 33.0);
let from = xf.screen_to_view(start);
let to = xf.screen_to_view(end);
let delta = doc.object_move_offset(0, [f64::from(to.0 - from.0), f64::from(to.1 - from.1)]).unwrap();
drag(&mut h, start, end);
for (old, new) in before.iter().zip(objects(&h)) {
let expected = if targets.contains(&old.target) { delta } else { [0.0, 0.0] };
for i in 0..4 {
assert!((new.rect[i] - old.rect[i] - expected[i % 2]).abs() < 0.01, "rotation {document_rotation}");
}
}
}
}
#[test]
fn group_drags_cancel_on_focus_loss_tab_switch_home_and_modal_dialogs() {
for case in 0..5 {
let mut h = harness();
let before = objects(&h);
marquee(&mut h);
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 90.0, 110.0));
h.hover_at(a);
h.run_steps(2);
h.drag_at(a);
h.run_steps(2);
h.hover_at(b);
h.run_steps(2);
match case {
0 => h.input_mut().focused = false,
1 => {
h.state_mut().open_bytes("other.pdf", None, fixture(false)).unwrap();
}
2 => h.state_mut().active = None,
3 => h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About),
_ => h.state_mut().request_document_url("https://example.invalid/", pdfcraft_ui_egui::LinkOrigin::Link),
}
h.run_steps(2);
h.drop_at(b);
h.run_steps(2);
h.input_mut().focused = true;
h.state_mut().active = Some(0);
h.state_mut().dialog = None;
h.state_mut().pending_link = None;
h.run_steps(4);
assert_eq!(objects(&h), before, "cancelled gesture case {case}");
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "case {case}");
h.get_by_label("3 objects selected");
}
}
#[test]
fn modifier_selection_picks_foreground_text_and_added_items_over_background_artwork() {
let mut h = harness_document(false, true);
let before = objects(&h);
assert_eq!(before.len(), 5);
for (x, y) in [(55.0, 70.0), (150.0, 135.0), (60.0, 145.0)] {
let p = at(&h, x, y);
click(&mut h, p, Modifiers::SHIFT);
}
h.get_by_label("3 objects selected");
let (a, b) = (at(&h, 55.0, 70.0), at(&h, 80.0, 100.0));
drag(&mut h, a, b);
let after = objects(&h);
for old in before {
let new = after.iter().find(|o| o.target == old.target).unwrap();
let stationary = old.text.as_deref() == Some("Keep here") || (old.target.kind == pdfcraft_engine::ObjectKind::Image && old.target.index == 0);
let shift = if stationary { [0.0, 0.0] } else { [25.0, -30.0] };
assert!(new.rect.iter().enumerate().all(|(i, value)| (value - old.rect[i] - shift[i % 2]).abs() < 0.01), "{old:?} -> {new:?}");
}
}