Files
pdfcraft/crates/ui-egui/tests/it/comments.rs
T

5757 lines
279 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Commenting in the real shell (egui_kittest): tools, gestures, selection, the composer and
//! the Comments panel.
use egui::accesskit::Role;
use egui::{Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_render::Annotation;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
use crate::support;
/// Two 300×200 pages of Helvetica text.
const TEXT_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >> endobj
5 0 obj << /Length 55 >> stream
BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET
endstream endobj
6 0 obj << /Length 58 >> stream
BT /F1 14 Tf 20 150 Td (A second brown animal) Tj ET
endstream endobj
7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
harness_with_bytes(TEXT_FIXTURE.to_vec(), setup)
}
fn harness_with_bytes(bytes: Vec<u8>, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> 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.set_option("language", "en").unwrap();
app.open_bytes("text.pdf", None, bytes).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("author", "Tester").unwrap();
setup(&mut app);
app
});
h.run_steps(4);
crate::support::settle(&mut h);
h
}
/// A point on page 1 in PDF user space → screen (no rotation; MediaBox 300×200).
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height())
}
fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(3);
}
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);
}
/// Drag between two page points (user space).
fn drag_pt(h: &mut Harness<'static, PdfCraftApp>, a: (f32, f32), b: (f32, f32)) {
let (a, b) = (at(h, a.0, a.1), at(h, b.0, b.1));
drag(h, a, b);
}
/// Click a page point (user space).
fn click_pt(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)) {
let p = at(h, p.0, p.1);
click(h, p);
}
/// Click a page point with modifier keys held (⌘-click adds a comment to the selection).
fn click_pt_with(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32), modifiers: egui::Modifiers) {
let p = at(h, p.0, p.1);
h.hover_at(p);
h.event(egui::Event::ModifiersChanged(modifiers));
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers });
h.run_steps(1);
}
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(2);
}
/// A text field by its placeholder ("Add a comment", "Add a reply").
fn field<'a>(h: &'a Harness<'static, PdfCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> {
h.query_all_by(|n| n.placeholder() == Some(placeholder)).next().unwrap_or_else(|| panic!("no field {placeholder:?}"))
}
fn comments(h: &Harness<'static, PdfCraftApp>) -> Vec<Annotation> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.clone()
}
/// #3418: choosing a sidebar comment must reveal the annotation, not just its page's top.
#[test]
fn sidebar_comment_centres_the_annotation_at_high_zoom() {
for (layout, rotation) in [("continuous", 0), ("single", 0), ("two-page", 0), ("continuous", 90), ("continuous", 180), ("continuous", 270)] {
let mut h = harness(move |app| {
app.set_option("panel", "comments").unwrap();
app.set_option("layout", layout).unwrap();
app.set_option("zoom", "800").unwrap();
app.views[0].rotation = rotation;
let shape = pdfcraft_engine::Shape::Note { at: [150.0, 90.0], icon: pdfcraft_engine::NoteIcon::Comment };
assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 1,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: "Reveal this note".into(),
author: "Tester".into(),
})));
app.views[0].comments.selected = None;
app.set_option("page", "1").unwrap();
});
h.get_by_label("Reveal this note").click();
h.run_steps(8);
assert_eq!(h.state().views[0].comments.selected, Some((1, 0)), "the sidebar card was selected");
let a = &comments(&h)[0];
let view = &h.state().views[0];
let page = view.page_screen_rect(1).expect("the selected comment's page is drawn");
let (u, v) = ((a.rect[0] + a.rect[2]) / 600.0, 1.0 - (a.rect[1] + a.rect[3]) / 400.0);
let (u, v) = match rotation {
90 => (1.0 - v, u),
180 => (1.0 - u, 1.0 - v),
270 => (v, 1.0 - u),
_ => (u, v),
};
let centre = pos2(page.left() + u * page.width(), page.top() + v * page.height());
let viewport = view.viewport_rect();
assert!(centre.distance(viewport.center()) < 3.0, "{layout}, {rotation}°: comment at {centre:?}, viewport {viewport:?}");
assert!((view.zoom - 8.0).abs() < 0.01, "revealing a comment preserves zoom");
if layout == "continuous"
&& rotation == 0
&& let Ok(dir) = std::env::var("PDFCRAFT_SHOTS")
{
let _gpu = crate::gpu();
crate::support::settle(&mut h);
h.render().unwrap().save(format!("{dir}/comment-centred.png")).unwrap();
}
}
}
/// A double-click on the text of a card in the Comments panel edits it, as one on its header does
/// (a click there would select a word): on a selected card, and on one the first click selects.
#[test]
fn double_clicking_a_cards_text_edits_it() {
for selected in [true, false] {
double_click_a_cards_text(selected);
}
}
fn double_click_a_cards_text(selected: bool) {
// Frames 1/60 s apart, so two clicks make a double-click.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "comments").unwrap();
let shape = pdfcraft_engine::Shape::Note { at: [150.0, 90.0], icon: pdfcraft_engine::NoteIcon::Comment };
assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: "Edit this note".into(),
author: "Tester".into(),
})));
app.views[0].comments.selected = selected.then_some((0, 0));
app
});
h.run_steps(4);
crate::support::settle(&mut h);
assert_eq!(h.state().views[0].comments.selected.is_some(), selected);
let p = h.query_all_by_label("Edit this note").next().expect("the card's text").rect().center();
h.hover_at(p);
h.run_steps(1);
for _ in 0..2 {
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(1);
}
h.run_steps(2);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "selected: {selected}");
assert_eq!(h.state().views[0].comments.editing, Some((0, 0, "Edit this note".into())), "selected: {selected}");
// And the editor is drawn.
note_editor(&h);
}
#[test]
fn dragging_over_text_with_the_highlighter_highlights_it() {
let mut h = harness(|app| app.set_option("quick", "highlight").unwrap());
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
drag(&mut h, a, b);
let c = comments(&h);
assert_eq!(c.len(), 1, "{c:?}");
assert_eq!((c[0].subtype.as_str(), c[0].author.as_deref(), c[0].quads.len()), ("Highlight", Some("Tester"), 1));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the new comment is selected");
assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::Highlight), "the highlighter stays on");
// The Comments panel opened with the tool and lists it.
h.get_by_label_contains("Comments");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add highlight"));
}
#[test]
fn the_quick_bar_colour_changes_the_selected_comment() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|app| app.set_option("quick", "highlight").unwrap());
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
drag(&mut h, a, b);
assert_eq!(comments(&h)[0].color, Some([1.0, 0.94, 0.0]));
h.get_by_label("Colour").click();
h.run_steps(2);
h.get_by_label("Blue").click();
h.run_steps(3);
let state = h.state();
let doc = state.session.get(state.views[0].id).expect("document");
assert_eq!(doc.comment_props(0, 0).and_then(|props| props.color), Some([0.0, 0.47, 0.84]));
assert_eq!(doc.can_undo(), Some("Change comment properties"), "recoloring is one undo step");
assert_eq!(state.comment_prefs.style(CommentTool::Highlight).color, [0.0, 0.47, 0.84]);
}
#[test]
fn the_quick_bar_colour_changes_the_selected_arrow() {
use pdfcraft_ui_egui::comments::CommentTool;
// Arrows are saved as Line annotations; the Arrow tool's colour must still reach them.
let mut h = harness(|app| app.set_option("quick", "arrow").unwrap());
drag_pt(&mut h, (20.0, 20.0), (120.0, 30.0));
assert_eq!(comments(&h)[0].subtype, "Line");
assert_eq!(h.state().quick_tool, QuickTool::Comment(CommentTool::Arrow), "the arrow tool is still active");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the new arrow is selected");
h.get_by_label("Colour").click();
h.run_steps(2);
h.get_by_label("Blue").click();
h.run_steps(3);
let state = h.state();
let doc = state.session.get(state.views[0].id).expect("document");
assert_eq!(doc.comment_props(0, 0).and_then(|props| props.color), Some([0.0, 0.47, 0.84]));
assert_eq!(doc.can_undo(), Some("Change comment properties"));
}
#[test]
fn the_quick_bar_colour_skips_the_selected_comment_when_commenting_is_forbidden() {
use pdfcraft_ui_egui::comments::CommentTool;
// One page with a yellow highlight, encrypted to allow printing only (no commenting).
let plain = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Highlight /Rect [20 140 120 165] /QuadPoints [20 165 120 165 20 140 120 140] /C [1 0.94 0] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(plain.to_vec())).expect("fixture parses");
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: 0b0100,
encrypt_metadata: true,
seed: [5; 32],
})
.expect("encrypts");
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).expect("writes");
let mut h = harness(move |app| {
app.open_bytes("restricted.pdf", None, bytes).expect("opens restricted");
app.set_option("quick", "highlight").unwrap();
});
let id = h.state().active_ids().expect("active document").1;
assert!(!h.state().session.get(id).expect("document").allows_annotation(), "commenting is forbidden");
let index = h.state().views.iter().position(|v| v.id == id).expect("view");
h.state_mut().views[index].comments.selected = Some((0, 0));
h.run_steps(2);
h.get_by_label("Colour").click();
h.run_steps(2);
h.get_by_label("Blue").click();
h.run_steps(3);
let state = h.state();
let doc = state.session.get(id).expect("document");
assert_eq!(doc.comment_props(0, 0).and_then(|props| props.color), Some([1.0, 0.94, 0.0]), "the comment keeps its colour");
assert_eq!(doc.can_undo(), None, "no edit was made");
assert_eq!(state.toast, None, "picking a colour is not reported as a failed edit");
assert_eq!(state.comment_prefs.style(CommentTool::Highlight).color, [0.0, 0.47, 0.84], "the tool default still changes");
}
#[test]
fn a_new_highlight_opens_its_note_for_typing() {
let mut h = harness(|app| app.set_option("quick", "highlight").unwrap());
assert_eq!(h.state().views[0].comments.editing, None);
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
drag(&mut h, a, b);
assert_eq!(comments(&h).len(), 1);
assert_eq!(h.state().views[0].comments.editing, Some((0, 0, String::new())), "the Comments panel edits the new highlight");
// An area highlight opens its note too, replacing the empty one.
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
assert_eq!(comments(&h).len(), 2);
assert_eq!(h.state().views[0].comments.editing, Some((0, 1, String::new())));
// A note being typed is kept: the next highlight is only selected.
h.state_mut().views[0].comments.editing = Some((0, 1, "typed".into()));
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
assert_eq!(comments(&h).len(), 3);
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)));
assert_eq!(h.state().views[0].comments.editing, Some((0, 1, "typed".into())));
}
/// The Comments panel's editor for a comment's note (the reply field is the other multiline one).
fn note_editor<'a>(h: &'a Harness<'static, PdfCraftApp>) -> egui_kittest::Node<'a> {
h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply")).next().expect("note editor")
}
/// The text in the open note editor.
fn note(h: &Harness<'static, PdfCraftApp>) -> Option<String> {
h.state().views[0].comments.editing.as_ref().map(|(_, _, text)| text.clone())
}
/// A letter key as a keyboard sends it: the key goes down, types its letter and comes up.
fn type_key(h: &mut Harness<'static, PdfCraftApp>, key: egui::Key, letter: &str) {
h.key_down(key);
h.event(egui::Event::Text(letter.into()));
h.key_up(key);
h.run_steps(2);
}
/// Highlight glyphs `from..to` of page 1's text with the highlighter.
fn highlight_text(h: &mut Harness<'static, PdfCraftApp>, from: usize, to: usize) {
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, from).expect("text layer"), v.glyph_screen_pos(0, to).expect("glyph"))
};
drag(h, a, b);
h.run_steps(2);
}
#[test]
fn clicking_the_page_gives_the_keyboard_back_from_a_highlight_note() {
// #2669: the note editor took the keyboard back on every frame, so after a click on the
// page V and H were typed into the note instead of picking a tool.
let mut h = harness(|app| {
app.set_option("quick", "highlight").unwrap();
app.set_option("panel", "comments").unwrap();
});
highlight_text(&mut h, 4, 14);
assert!(note_editor(&h).is_focused(), "a new highlight's note takes the keyboard straight away");
h.event(egui::Event::Text("Note".into()));
h.run_steps(2);
assert_eq!(note(&h).as_deref(), Some("Note"), "typing goes into the note");
// A click on an empty part of the page gives the keyboard back to the page.
click_pt(&mut h, (250.0, 20.0));
type_key(&mut h, egui::Key::V, "v");
assert_eq!(h.state().quick_tool, QuickTool::Select, "V picks the Select tool");
assert_eq!(note(&h).as_deref(), Some("Note"), "V is not typed into the note");
type_key(&mut h, egui::Key::H, "h");
assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool");
assert_eq!(note(&h).as_deref(), Some("Note"), "nor is H");
// Clicking back into the note types into it again, and the letters are text there.
note_editor(&h).click();
h.run_steps(2);
type_key(&mut h, egui::Key::V, "v");
assert_eq!(note(&h).as_deref(), Some("Notev"), "the note takes keys while the user types in it");
assert_eq!(h.state().quick_tool, QuickTool::Hand, "typed letters don't pick tools");
}
#[test]
fn drawing_another_comment_takes_the_keyboard_from_a_highlight_note() {
// #2669: a highlight's note kept the keyboard while rectangles and ovals were drawn (a drag
// is not a click, so the editor never let go), and V and H were typed into it.
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|app| {
app.set_option("quick", "highlight").unwrap();
app.set_option("panel", "comments").unwrap();
});
highlight_text(&mut h, 4, 14);
assert!(note_editor(&h).is_focused());
// The tools are picked without a click, as a shortcut would: the drawing alone must take
// the keyboard back. An empty note closes.
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
assert_eq!(comments(&h).len(), 2);
assert_eq!(h.state().views[0].comments.editing, None, "the empty note closed");
type_key(&mut h, egui::Key::H, "h");
assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool");
// A note with text in it stays open with its text, but lets the keyboard go.
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Highlight);
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
h.run_steps(2);
assert_eq!(h.state().views[0].comments.editing, Some((0, 2, String::new())), "the new area highlight's note opens");
assert!(note_editor(&h).is_focused());
h.event(egui::Event::Text("Note".into()));
h.run_steps(2);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
drag_pt(&mut h, (40.0, 30.0), (260.0, 8.0));
assert_eq!(comments(&h).len(), 4);
assert_eq!(h.state().views[0].comments.editing, Some((0, 2, "Note".into())), "a note with text stays open");
type_key(&mut h, egui::Key::H, "h");
assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool");
assert_eq!(note(&h).as_deref(), Some("Note"), "H is not typed into the note");
}
#[test]
fn ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line() {
let mut h = harness(|app| {
app.set_option("quick", "highlight").unwrap();
app.set_option("panel", "comments").unwrap();
});
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
drag(&mut h, a, b);
h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply"))
.next()
.expect("highlight editor")
.click();
h.run_steps(2);
h.event(egui::Event::Text("First line".into()));
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Enter);
h.run_steps(2);
assert_eq!(h.state().views[0].comments.editing.as_ref().unwrap().2, "First line\n");
assert_eq!(comments(&h)[0].contents.as_deref().unwrap_or(""), "", "a newline does not post");
h.event(egui::Event::Text("Second line".into()));
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[0].comments.editing, None);
assert_eq!(comments(&h)[0].contents.as_deref(), Some("First line\nSecond line"));
h.state_mut().undo();
h.run_steps(2);
assert_eq!(comments(&h)[0].contents.as_deref().unwrap_or(""), "", "posting is one undo step");
}
#[test]
fn ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line() {
let mut h = harness(|app| app.set_option("panel", "comments").unwrap());
field(&h, "Add a comment").click();
h.run_steps(2);
field(&h, "Add a comment").type_text("Parent");
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(3);
field(&h, "Add a reply").click();
h.run_steps(2);
field(&h, "Add a reply").type_text("First line");
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Enter);
h.run_steps(2);
assert_eq!(comments(&h).len(), 1, "Shift+Enter does not post a reply");
assert_eq!(h.state().views[0].comments.reply, "First line\n");
h.event(egui::Event::Text("Second line".into()));
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter);
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 2);
assert_eq!(c[1].contents.as_deref(), Some("First line\nSecond line"));
assert_eq!(c[1].in_reply_to, c[0].name);
assert_eq!(c[1].author.as_deref(), Some("Tester"));
assert!(h.state().views[0].comments.reply.is_empty());
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter);
h.run_steps(2);
assert_eq!(comments(&h).len(), 2, "an empty reply is not posted");
h.state_mut().undo();
h.run_steps(2);
assert_eq!(comments(&h).len(), 1, "posting is one undo step");
h.state_mut().set_option("comment", "1:1").unwrap();
h.run_steps(2);
field(&h, "Add a reply").click();
h.run_steps(2);
field(&h, "Add a reply").type_text("Enter still posts");
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(comments(&h)[1].contents.as_deref(), Some("Enter still posts"));
}
#[test]
fn the_comment_composer_shortcut_only_posts_the_focused_editor() {
let mut h = harness(|app| app.set_option("quick", "note").unwrap());
click_pt(&mut h, (250.0, 180.0));
field(&h, "Add a comment").click();
h.run_steps(2);
h.event(egui::Event::Text("First line".into()));
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Enter);
h.run_steps(2);
assert_eq!(h.state().views[0].comments.composer.as_ref().unwrap().text, "First line\n");
assert!(comments(&h).is_empty());
h.event(egui::Event::Text("Second line".into()));
h.run_steps(2);
h.query_all_by_value("1").next().expect("current page input").focus();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter);
h.run_steps(2);
assert!(h.state().views[0].comments.composer.is_some(), "a shortcut outside the editor keeps the draft");
assert!(comments(&h).is_empty());
h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).next().expect("composer editor").focus();
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter);
h.run_steps(3);
assert!(h.state().views[0].comments.composer.is_none());
assert_eq!(comments(&h)[0].contents.as_deref(), Some("First line\nSecond line"));
}
#[test]
fn a_selection_made_first_is_marked_when_a_tool_is_picked() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
assert!(h.state_mut().execute("comment.strikeout"));
h.run_steps(2);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!(c[0].subtype, "StrikeOut");
assert!(h.state().views[0].selected_text().is_none(), "the selection is consumed");
}
#[test]
fn selected_text_shows_actions_and_underlines_without_changing_tools() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
h.run_steps(3);
h.get_by_label("Add comment");
h.get_by_label("Replace text");
h.get_by_label("Underline").click();
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!(c[0].subtype, "Underline");
assert!(h.state().views[0].selected_text().is_none());
assert_eq!(h.state().quick_tool, QuickTool::Select);
}
#[test]
fn selected_text_copy_action_closes_the_bar_and_keeps_the_selection() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
h.run_steps(3);
h.get_by_label("Copy").click();
h.run_steps(3);
assert!(h.query_by_label("Add comment").is_none(), "the action bar closes after Copy");
assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "the words stay selected");
// A new selection brings the bar back.
h.state_mut().views[0].select_text(0, 2, 6);
h.run_steps(3);
h.get_by_label("Add comment");
}
#[test]
fn dragging_over_copied_text_again_brings_the_action_bar_back() {
let mut h = harness(|_| {});
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 8).expect("glyph"))
};
drag(&mut h, a, b);
let copied = h.state().views[0].selected_text();
assert!(copied.is_some(), "the drag selects text");
h.get_by_label("Copy").click();
h.run_steps(3);
assert!(h.query_by_label("Add comment").is_none(), "the action bar closes after Copy");
// The same drag again selects the same words, which is a new selection: the bar is back.
drag(&mut h, a, b);
assert_eq!(h.state().views[0].selected_text(), copied, "the drag selects the same words");
assert!(h.query_by_label("Add comment").is_some(), "the action bar is back after a new drag");
}
#[test]
fn selected_text_replace_action_opens_replacement_composer() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
h.run_steps(3);
h.get_by_label("Replace text").click();
h.run_steps(2);
h.get_by_role_and_label(Role::MultilineTextInput, "Replacement text");
assert!(h.state().views[0].selected_text().is_none());
}
#[test]
fn selected_text_comment_action_opens_note_composer() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
h.run_steps(3);
h.get_by_label("Add comment").click();
h.run_steps(2);
assert!(matches!(h.state().views[0].comments.composer.as_ref().map(|c| &c.kind), Some(pdfcraft_ui_egui::comments::ComposerKind::Note)));
assert!(h.state().views[0].selected_text().is_none());
}
#[test]
fn shapes_and_ink_are_drawn_by_dragging() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "ink").unwrap();
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
h.state_mut().set_option("quick", "arrow").unwrap();
drag_pt(&mut h, (20.0, 20.0), (120.0, 30.0));
// A click with a drawing tool draws nothing.
h.state_mut().set_option("quick", "circle").unwrap();
click_pt(&mut h, (200.0, 180.0));
let c = comments(&h);
let mut c = c;
c.sort_by_key(|a| a.index);
let kinds: Vec<&str> = c.iter().map(|a| a.subtype.as_str()).collect();
assert_eq!(kinds, ["Square", "Ink", "Line"], "{c:?}");
let r = c[0].rect;
assert!((r[0] - 40.0).abs() < 3.0 && (r[1] - 40.0).abs() < 3.0 && (r[2] - 140.0).abs() < 3.0 && (r[3] - 100.0).abs() < 3.0, "{r:?}");
}
/// Press, drag and release a stroke, running only one frame after the release. Finished
/// renders are held back from the view (`hold_rasters`), so the raster the commit asks for can't
/// arrive before the test looks, however fast it renders; a test lets it through by clearing it.
fn stroke_without_settling(h: &mut Harness<'static, PdfCraftApp>, a: (f32, f32), b: (f32, f32)) {
h.state_mut().views[0].hold_rasters = true;
let (from, to) = (at(h, a.0, a.1), at(h, b.0, b.1));
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(1);
}
/// #1858: on release the live stroke is replaced by the committed annotation, but the page
/// raster that draws it arrives later. The stroke stays painted from its points meanwhile.
#[test]
fn a_released_pen_stroke_stays_drawn_until_the_page_raster_has_it() {
let mut h = harness(|app| app.set_option("quick", "ink").unwrap());
stroke_without_settling(&mut h, (40.0, 100.0), (140.0, 60.0));
assert_eq!(comments(&h).len(), 1, "the stroke is committed");
assert_eq!(h.state().views[0].comments.settling_len(), 1, "still drawn from its points");
h.run_steps(5);
assert_eq!(h.state().views[0].comments.settling_len(), 1, "drawn from its points for as long as its raster takes");
h.state_mut().views[0].hold_rasters = false;
support::wait_until(&mut h, "the page raster with the stroke", |app| app.views[0].comments.settling_len() == 0);
assert_eq!(h.state().views[0].comments.settling_len(), 0, "the raster draws it now");
}
#[test]
fn undoing_a_pen_stroke_before_its_raster_leaves_no_ghost() {
let mut h = harness(|app| app.set_option("quick", "ink").unwrap());
stroke_without_settling(&mut h, (40.0, 100.0), (140.0, 60.0));
assert_eq!(h.state().views[0].comments.settling_len(), 1);
h.state_mut().undo();
assert_eq!(h.state().views[0].comments.settling_len(), 0);
}
#[test]
fn pen_strokes_stay_unselected_so_writing_continues() {
let mut h = harness(|app| app.set_option("quick", "ink").unwrap());
// No Comments panel: the author's name should only show in a hover popup.
h.state_mut().set_option("panel", "none").unwrap();
h.run_steps(2);
drag_pt(&mut h, (40.0, 100.0), (140.0, 60.0));
assert_eq!(h.state().views[0].comments.selected, None, "the first stroke isn't selected");
// The pointer still rests on the stroke: no author popup covers it.
h.hover_at(at(&h, 140.0, 60.0));
h.run_steps(3);
assert!(h.query_by_label_contains("Tester").is_none(), "no author popup while drawing");
drag_pt(&mut h, (60.0, 100.0), (160.0, 60.0));
let c = comments(&h);
let kinds: Vec<&str> = c.iter().map(|a| a.subtype.as_str()).collect();
assert_eq!(kinds, ["Ink", "Ink"], "{c:?}");
assert_eq!(h.state().views[0].comments.selected, None, "nothing is selected after drawing");
assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::Ink), "the pen stays on");
// Other drawn shapes are still selected when made.
h.state_mut().set_option("quick", "square").unwrap();
drag_pt(&mut h, (180.0, 100.0), (260.0, 40.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)));
}
#[test]
fn sticky_note_composer_posts_and_returns_to_select() {
let mut h = harness(|app| app.set_option("quick", "note").unwrap());
click_pt(&mut h, (250.0, 180.0));
assert!(h.state().views[0].comments.composer.is_some(), "the composer opened");
field(&h, "Add a comment").type_text("Please check");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!((c[0].subtype.as_str(), c[0].contents.as_deref()), ("Text", Some("Please check")));
assert!(h.state().views[0].comments.composer.is_none());
assert_eq!(h.state().quick_tool, QuickTool::Select, "one-shot tools return to Select");
// Cancelling another one creates nothing.
h.state_mut().set_option("quick", "freetext").unwrap();
click_pt(&mut h, (50.0, 60.0));
h.get_by_label("Cancel").click();
h.run_steps(2);
assert_eq!(comments(&h).len(), 1);
}
/// #2583: a sticky note is saved NoRotate, so on a turned page its icon is drawn upright from
/// where it was placed (right and down), not where its `/Rect` lies as displayed. The Select tool
/// picks it where it is drawn.
#[test]
fn a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn() {
let mut h = harness(|app| {
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
app.set_option("quick", "note").unwrap();
});
crate::support::settle(&mut h);
let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
// Screen pixels per point of the displayed page (300 × 200 pt turned to 200 × 300).
let k = page.width() / 200.0;
let placed = page.left_top() + egui::vec2(100.0 * k, 60.0 * k);
click(&mut h, placed);
field(&h, "Add a comment").type_text("Turned");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
crate::support::settle(&mut h);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!((c[0].subtype.as_str(), c[0].no_rotate), ("Text", true));
assert_eq!(h.state().quick_tool, QuickTool::Select);
// Where the note's /Rect lies as displayed, left of where it was placed: nothing.
click(&mut h, placed + egui::vec2(-10.0 * k, 10.0 * k));
assert_eq!(h.state().views[0].comments.selected, None);
// On the icon, drawn right and down from where it was placed.
click(&mut h, placed + egui::vec2(10.0 * k, 10.0 * k));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
}
/// The composer leaves with its comment when the page scrolls it out of the document, and comes
/// back with the draft (#4021). It used to be pushed back into the window, over the mode bar and
/// the toolbar, while its comment was out of sight.
#[test]
fn the_composer_hides_while_its_comment_is_scrolled_away() {
let mut h = harness(|app| app.set_option("quick", "note").unwrap());
click_pt(&mut h, (250.0, 180.0));
field(&h, "Add a comment").type_text("Draft");
h.run_steps(2);
let editors = |h: &Harness<'static, PdfCraftApp>| -> Vec<egui::Rect> {
h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).map(|n| n.rect()).collect()
};
let viewport = h.state().views[0].viewport_rect();
assert!(editors(&h).iter().any(|r| viewport.contains(r.center())), "the composer opens in the document");
// The page scrolled up: it still shows, the comment near its top doesn't.
h.state_mut().views[0].goto = Some((0, 0.4));
h.run_steps(4);
assert!(h.state().views[0].page_screen_rect(0).is_some_and(|r| r.bottom() > viewport.top()), "page 1 still shows");
assert!(at(&h, 250.0, 180.0).y < viewport.top(), "the comment is scrolled away");
let shown = editors(&h);
assert!(shown.is_empty(), "the composer stays with its comment, not over the toolbar: {shown:?} (document {viewport:?})");
// Back again, the draft is still there.
h.state_mut().views[0].goto = Some((0, 0.0));
h.run_steps(4);
assert!(editors(&h).iter().any(|r| viewport.contains(r.center())), "the composer is back");
assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft"));
}
/// Editing a large comment whose top-right corner is scrolled away still shows the composer
/// while the rest of the comment is on screen, so it can be posted or cancelled (#4095).
#[test]
fn the_edit_composer_shows_while_part_of_its_comment_is_on_screen() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (20.0, 190.0), (280.0, 10.0));
// What "Edit text…" opens: the composer anchored at the comment's top-right corner.
h.state_mut().views[0].comments.composer = Some(pdfcraft_ui_egui::comments::Composer {
page: 0,
at: [280.0, 190.0],
kind: pdfcraft_ui_egui::comments::ComposerKind::Edit(0),
text: String::new(),
focus: true,
});
h.run_steps(2);
let viewport = h.state().views[0].viewport_rect();
h.state_mut().views[0].goto = Some((0, 0.4));
h.run_steps(4);
assert!(at(&h, 280.0, 190.0).y < viewport.top(), "the comment's top-right corner is scrolled away");
assert!(at(&h, 280.0, 10.0).y > viewport.top(), "part of the comment is still on screen");
let editors: Vec<_> = h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).map(|n| n.rect()).collect();
assert!(editors.iter().any(|r| viewport.contains(r.center())), "the editor is drawn: {editors:?} (document {viewport:?})");
h.get_by_label("Cancel").click();
h.run_steps(2);
assert!(h.state().views[0].comments.composer.is_none(), "Cancel closes it");
}
/// With its whole page scrolled off the screen the composer keeps its draft, and the editor takes
/// the keyboard again when the page comes back (#4021).
#[test]
fn the_composer_takes_the_keyboard_back_after_its_page_was_off_screen() {
let mut h = harness(|app| app.set_option("quick", "note").unwrap());
click_pt(&mut h, (250.0, 180.0));
field(&h, "Add a comment").type_text("Draft");
h.run_steps(2);
let editor_focused =
|h: &Harness<'static, PdfCraftApp>| h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).any(|n| n.is_focused());
assert!(editor_focused(&h), "the new note's editor has the keyboard");
let viewport = h.state().views[0].viewport_rect();
h.state_mut().views[0].goto = Some((1, 0.6));
h.run_steps(4);
assert!(h.state().views[0].page_screen_rect(0).is_none_or(|r| r.bottom() < viewport.top()), "page 1 is off the screen");
assert!(!editor_focused(&h), "nothing to type into while the page is away");
h.state_mut().views[0].goto = Some((0, 0.0));
h.run_steps(4);
assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft"));
assert!(editor_focused(&h), "the editor has the keyboard again");
}
/// The rectangle of the comment whose text starts with `text`.
fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] {
let c = comments(h);
c.iter().find(|a| a.contents.as_deref().is_some_and(|t| t.starts_with(text))).unwrap_or_else(|| panic!("no comment {text:?}: {c:?}")).rect
}
/// Post `text` in the open text box composer.
fn post_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
field(h, "Type text").type_text(text);
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
}
fn near(r: [f32; 4], want: [f32; 4]) -> bool {
r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1.5)
}
/// #2669: a drag with Add a text box draws the box at that size, and the text wraps inside it;
/// a click (or a drag of a few pixels) still makes a box sized to its text.
#[test]
fn a_text_box_is_sized_by_dragging() {
let mut h = harness(|app| app.set_option("quick", "freetext").unwrap());
let (from, to) = (at(&h, 40.0, 150.0), at(&h, 200.0, 100.0));
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
h.hover_at(to);
h.run_steps(2);
assert!(
matches!(&h.state().views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { points, .. }) if points.len() == 2),
"the box shows while it is dragged"
);
h.drop_at(to);
h.run_steps(3);
post_text(&mut h, "Drawn");
let r = rect_of(&h, "Drawn");
assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "{r:?}");
// Too much text for the drawn box: it keeps its width and grows down, moving up so that it
// stays on the page (whose bottom is at 0).
h.state_mut().set_option("quick", "freetext").unwrap();
drag_pt(&mut h, (20.0, 80.0), (120.0, 70.0));
post_text(&mut h, &"word ".repeat(20));
let r = rect_of(&h, "word word");
assert!((r[0] - 20.0).abs() < 1.5 && (r[2] - 120.0).abs() < 1.5 && r[1].abs() < 0.5 && r[3] > 90.0, "{r:?}");
// More text than the page can hold: the box keeps the drawn size and the text is clipped.
h.state_mut().set_option("quick", "freetext").unwrap();
drag_pt(&mut h, (150.0, 190.0), (250.0, 180.0));
post_text(&mut h, &"longerwords ".repeat(16));
let r = rect_of(&h, "longerwords");
assert!(near(r, [150.0, 180.0, 250.0, 190.0]), "{r:?}");
// Drags of two and four pixels are clicks: the box fits its text.
let fitted = pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], "Hi", 12.0, 0);
for (text, x, d) in [("Hi", 200.0, 2.0), ("Hi again", 270.0, 4.0)] {
h.state_mut().set_option("quick", "freetext").unwrap();
let p = at(&h, x, 40.0);
drag(&mut h, p, p + egui::vec2(d, d));
post_text(&mut h, text);
let r = rect_of(&h, text).map(f64::from);
let fitted = if text == "Hi" { fitted } else { pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], text, 12.0, 0) };
assert!((r[2] - r[0] - (fitted[2] - fitted[0])).abs() < 1.0 && (r[3] - r[1] - (fitted[3] - fitted[1])).abs() < 1.0, "{text}: {r:?}");
}
}
/// #2669: with the view turned (View > Rotate View) the box lands where it was drawn, not
/// transposed or scaled by the turned page's other side.
#[test]
fn a_dragged_text_box_follows_a_rotated_view() {
for rotation in [90u16, 270] {
let mut h = harness(|app| app.set_option("quick", "freetext").unwrap());
while h.state().views[0].rotation != rotation {
h.state_mut().views[0].rotate_view(true);
}
h.state_mut().set_option("zoom", "150").unwrap();
h.run_steps(3);
crate::support::settle(&mut h);
let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| {
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
let (u, v) = (x / 300.0, (200.0 - y) / 200.0);
let (a, b) = if rotation == 90 { (1.0 - v, u) } else { (v, 1.0 - u) };
pos2(r.left() + a * r.width(), r.top() + b * r.height())
};
let (from, to) = (screen(&h, 40.0, 150.0), screen(&h, 200.0, 100.0));
drag(&mut h, from, to);
post_text(&mut h, "Turned");
let r = rect_of(&h, "Turned");
assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "view turned {rotation}: {r:?}");
}
}
/// #2669: the text of a drawn box wraps inside its border, as its appearance draws it, so a thick
/// border does not cut off the last line.
#[test]
fn a_dragged_text_box_wraps_inside_its_border() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|app| {
app.set_option("quick", "freetext").unwrap();
app.comment_prefs.set_width(CommentTool::TextBox, 3.0);
});
// Wide enough for "word word" inside a 2 pt padding, not inside 2 + 3 pt.
let w = pdfcraft_engine::annot_text::text_width("word word", 12.0) as f32 + 6.0;
drag_pt(&mut h, (20.0, 190.0), (20.0 + w, 180.0));
post_text(&mut h, "word word");
let r = rect_of(&h, "word word");
assert!((r[3] - r[1] - (2.0 * 14.4 + 2.0 * 5.0 + 2.0)).abs() < 1.0, "two lines inside the border: {r:?}");
}
#[test]
fn comments_are_selected_moved_and_deleted_with_the_select_tool() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
// Click away, then on the rectangle's border area.
click_pt(&mut h, (250.0, 20.0));
assert_eq!(h.state().views[0].comments.selected, None);
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
drag_pt(&mut h, (90.0, 70.0), (140.0, 70.0));
let r = comments(&h)[0].rect;
assert!((r[0] - 90.0).abs() < 3.0 && (r[2] - 190.0).abs() < 3.0, "moved right by 50 pt: {r:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment"));
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert!(comments(&h).is_empty());
h.state_mut().undo();
h.run_steps(2);
assert_eq!(comments(&h).len(), 1);
}
/// A line's ends have handles: dragging one with the Select tool changes the line's length and
/// angle (Shift holds it level or plumb with the other end), as one undo step. The line tool keeps
/// drawing from an end: a new line starts there rather than dragging the one just drawn.
#[test]
fn line_ends_are_dragged_to_change_length_and_angle() {
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 100.0));
let line = comments(&h).into_iter().find(|a| a.subtype == "Line").expect("a line");
assert_eq!(line.points.len(), 2);
let ends = |h: &Harness<'static, PdfCraftApp>| comments(h).into_iter().find(|a| a.index == line.index).unwrap().points;
// With the line tool, a drag from the end just drawn draws another line.
let first = ends(&h);
drag_pt(&mut h, (first[1][0], first[1][1]), (140.0, 40.0));
assert_eq!(comments(&h).iter().filter(|a| a.subtype == "Line").count(), 2, "a second line");
assert_eq!(ends(&h), first, "the first one stays");
h.state_mut().set_option("quick", "select").unwrap();
click_pt(&mut h, (90.0, 100.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, line.index)));
// Drag the right end up and further right: longer, and turned.
let [_, end] = [line.points[0], line.points[1]];
drag_pt(&mut h, (end[0], end[1]), (200.0, 160.0));
let p = ends(&h);
assert!((p[0][0] - 40.0).abs() < 1.0 && (p[0][1] - 100.0).abs() < 1.0, "the other end stays: {p:?}");
assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}");
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment"));
// Shift: held level or plumb with the other end, here level (110 pt across, 90 up).
let a = at(&h, 200.0, 160.0);
let b = at(&h, 150.0, 190.0);
h.hover_at(a);
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
h.drag_at(a);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(a + (b - a) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(b);
h.run_steps(3);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(1);
let p = ends(&h);
assert!((p[1][1] - p[0][1]).abs() < 0.01 && (p[1][0] - 150.0).abs() < 1.0, "level: {p:?}");
// Undo puts the line back as it was before the drag.
h.state_mut().undo();
h.run_steps(2);
let p = ends(&h);
assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "{p:?}");
}
/// The arrow keys move the selected comment a screen pixel, ⌘/Ctrl or Shift and an arrow ten,
/// instead of scrolling or turning the page; each press is one undo step. Without a selection
/// the arrows scroll and turn pages as before.
#[test]
fn arrow_keys_nudge_the_selected_comment() {
use egui::{Key, Modifiers};
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
// One screen pixel in user space, at this zoom.
let pixel = 300.0 / h.state().views[0].page_screen_rect(0).unwrap().width();
let before = comments(&h)[0].rect;
h.key_press(Key::ArrowRight);
h.run_steps(3);
let r = comments(&h)[0].rect;
assert!((r[0] - before[0] - pixel).abs() < 0.01 && (r[1] - before[1]).abs() < 0.01, "one pixel right: {r:?} from {before:?}");
assert_eq!(h.state().views[0].current, 0, "the page didn't turn");
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comment"));
// ⌘/Ctrl and Shift: ten pixels (down on screen is down in user space here).
h.key_press_modifiers(Modifiers::COMMAND, Key::ArrowDown);
h.run_steps(3);
let r2 = comments(&h)[0].rect;
assert!((r[1] - r2[1] - 10.0 * pixel).abs() < 0.01 && (r2[0] - r[0]).abs() < 0.01, "ten pixels down: {r2:?} from {r:?}");
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft);
h.run_steps(3);
let r3 = comments(&h)[0].rect;
assert!((r2[0] - r3[0] - 10.0 * pixel).abs() < 0.01, "ten pixels left: {r3:?} from {r2:?}");
h.state_mut().undo();
h.run_steps(2);
assert!((comments(&h)[0].rect[0] - r2[0]).abs() < 0.01, "each press is one undo step");
// Nothing selected: → turns the page again.
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(h.state().views[0].comments.selected, None);
let rect = comments(&h)[0].rect;
h.key_press(Key::ArrowRight);
h.run_steps(3);
assert_eq!(comments(&h)[0].rect, rect, "the comment stays");
assert_eq!(h.state().views[0].current, 1, "and the view goes to the next page");
}
/// #3632: with a comment selected, Alt+← / Alt+→ still go back and forward through the view
/// history instead of nudging it (egui's key patterns let an extra Alt through).
#[test]
fn alt_arrows_navigate_instead_of_nudging_the_selected_comment() {
use egui::{Key, Modifiers};
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
let before = comments(&h)[0].rect;
// A followed link's jump to page 2 and back, as `go_to_dest` makes it.
h.state_mut().views[0].go_to_page(1);
h.run_steps(2);
h.state_mut().views[0].go_to_page(0);
h.run_steps(2);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "still selected, so a nudge could take the keys");
h.key_press_modifiers(Modifiers::ALT, Key::ArrowLeft);
h.run_steps(3);
assert_eq!(h.state().views[0].current, 1, "Alt+← went back");
h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight);
h.run_steps(3);
assert_eq!(h.state().views[0].current, 0, "Alt+→ went forward");
assert_eq!(comments(&h)[0].rect, before, "the comment didn't move");
let id = h.state().views[0].id;
assert_ne!(h.state().session.get(id).unwrap().can_undo(), Some("Move comment"), "nothing was nudged");
// A plain arrow still nudges it.
h.key_press(Key::ArrowRight);
h.run_steps(3);
assert!(comments(&h)[0].rect[0] > before[0], "→ nudged it");
}
/// Over the first corner, where a click closes the polygon, the rubber band runs to that corner
/// with Shift held too: the closing side the click will make, not a level or plumb one.
#[test]
fn the_rubber_band_shows_the_closing_side_with_shift_held() {
let _gpu = crate::gpu();
let mut h = harness(|app| app.set_option("quick", "polygon").unwrap());
for p in [(40.0, 40.0), (140.0, 40.0), (140.0, 140.0)] {
click_pt(&mut h, p);
}
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
h.hover_at(at(&h, 40.0, 40.0) + egui::vec2(2.0, 2.0));
h.run_steps(3);
let pixels = h.render().unwrap();
// Halfway along the diagonal closing side, (90, 90).
let m = at(&h, 90.0, 90.0);
let drawn = (-2..=2).any(|d: i32| {
let v = pixels.get_pixel((m.x as i32 + d) as u32, (m.y as i32 + d) as u32).0;
v[0] > 150 && v[1] < 100 && v[2] < 100
});
assert!(drawn, "the closing side is drawn");
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(1);
}
/// Shift holds a new line or arrow level or plumb with where it starts, and each new side of
/// connected lines or a polygon with the corner before (a CAD drawing's ortho mode). Without
/// Shift they go where the pointer is.
#[test]
fn shift_holds_new_lines_and_sides_level_or_plumb() {
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
let shift = |h: &mut Harness<'static, PdfCraftApp>, on: bool| {
h.event(egui::Event::ModifiersChanged(if on { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }));
h.run_steps(1);
};
let points = |h: &Harness<'static, PdfCraftApp>, subtype: &str| {
// In the order drawn: the list isn't sorted by index.
let mut drawn: Vec<_> = comments(h).into_iter().filter(|a| a.subtype == subtype).collect();
drawn.sort_by_key(|a| a.index);
drawn.into_iter().map(|a| a.points).collect::<Vec<_>>()
};
let close = |p: [f32; 2], q: [f32; 2]| (p[0] - q[0]).abs() < 1.0 && (p[1] - q[1]).abs() < 1.0;
shift(&mut h, true);
drag_pt(&mut h, (40.0, 100.0), (140.0, 120.0));
drag_pt(&mut h, (200.0, 40.0), (215.0, 150.0));
shift(&mut h, false);
drag_pt(&mut h, (40.0, 160.0), (140.0, 190.0));
let lines = points(&h, "Line");
assert_eq!(lines.len(), 3, "{lines:?}");
assert!(close(lines[0][0], [40.0, 100.0]) && close(lines[0][1], [140.0, 100.0]), "mostly across: level: {lines:?}");
assert!(close(lines[1][0], [200.0, 40.0]) && close(lines[1][1], [200.0, 150.0]), "mostly up: plumb: {lines:?}");
assert!(close(lines[2][1], [140.0, 190.0]), "no Shift, no hold: {lines:?}");
h.state_mut().set_option("quick", "arrow").unwrap();
shift(&mut h, true);
drag_pt(&mut h, (260.0, 180.0), (150.0, 170.0));
shift(&mut h, false);
let arrow = points(&h, "Line").pop().expect("an arrow");
assert!(close(arrow[1], [150.0, 180.0]), "an arrow too, leftwards: {arrow:?}");
// Connected lines: each click with Shift is held to the corner before.
h.state_mut().set_option("quick", "polyline").unwrap();
click_pt(&mut h, (20.0, 20.0));
shift(&mut h, true);
click_pt(&mut h, (90.0, 30.0));
click_pt(&mut h, (95.0, 80.0));
shift(&mut h, false);
click_pt(&mut h, (130.0, 60.0));
h.key_press(egui::Key::Enter);
h.run_steps(3);
let poly = points(&h, "PolyLine").pop().expect("connected lines");
assert_eq!(poly.len(), 4, "{poly:?}");
assert!(close(poly[1], [90.0, 20.0]) && close(poly[2], [90.0, 80.0]) && close(poly[3], [130.0, 60.0]), "{poly:?}");
}
#[test]
fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() {
// #2338: the new shape showed its handles, but only the Select tool answered them.
let square = QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::Rectangle);
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the new rectangle is selected");
let before = comments(&h)[0].rect;
// Drag its bottom-right handle 40 pt right and 20 pt down.
drag_pt(&mut h, (before[2], before[1]), (before[2] + 40.0, before[1] - 20.0));
let c = comments(&h);
assert_eq!(c.len(), 1, "the handle resized the rectangle instead of drawing another: {c:?}");
let r = c[0].rect;
assert!((r[2] - (before[2] + 40.0)).abs() < 3.0 && (r[1] - (before[1] - 20.0)).abs() < 3.0, "resized: {before:?} → {r:?}");
assert!((r[0] - before[0]).abs() < 1.0 && (r[3] - before[3]).abs() < 1.0, "the opposite corner stays: {before:?} → {r:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"));
assert_eq!(h.state().quick_tool, square, "the rectangle tool stays on");
// Away from the handles the tool still draws, over the shape too.
drag_pt(&mut h, (60.0, 90.0), (120.0, 60.0));
assert_eq!(comments(&h).len(), 2, "a drag inside the rectangle draws a second one");
}
#[test]
fn the_panel_posts_comments_and_replies() {
let mut h = harness(|app| app.set_option("panel", "comments").unwrap());
field(&h, "Add a comment").click();
h.run_steps(2);
field(&h, "Add a comment").type_text("General remark");
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
let c = comments(&h);
assert_eq!((c.len(), c[0].subtype.as_str(), c[0].contents.as_deref()), (1, "Text", Some("General remark")));
// The new comment is selected: reply to it.
field(&h, "Add a reply").click();
h.run_steps(2);
field(&h, "Add a reply").type_text("Agreed");
h.run_steps(1);
h.get_by_label("Post").click();
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 2);
let reply = c.iter().find(|a| a.in_reply_to.is_some()).expect("a reply");
assert_eq!((reply.contents.as_deref(), reply.author.as_deref()), (Some("Agreed"), Some("Tester")));
h.get_by_label_contains("Agreed");
}
/// The side panel's width as egui keeps it from one frame to the next.
fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 {
h.ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::unique("pdfcraft-inspector-body"))).expect("the inspector body is shown").width()
}
#[test]
fn the_panel_keeps_its_width_with_a_reply_box_open() {
if crate::support::skip_without_catalogs("the_panel_keeps_its_width_with_a_reply_box_open") {
return;
}
// The reply row reserved 48 px for "Post"; Russian's wider button overflowed it, and a
// resizable panel keeps the width its content used, so every repaint widened the panel.
let mut h = harness(|app| app.set_option("panel", "comments").unwrap());
field(&h, "Add a comment").click();
h.run_steps(2);
field(&h, "Add a comment").type_text("General remark");
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
field(&h, "Add a reply").click(); // the new comment is selected
h.run_steps(2);
field(&h, "Add a reply").type_text("Agreed");
h.run_steps(1);
h.state_mut().set_option("language", "ru").unwrap();
h.run_steps(3);
let before = panel_width(&h);
h.get_by_label("Опубликовать").hover();
for _ in 0..30 {
h.run_steps(1);
}
assert_eq!(panel_width(&h), before, "the Comments panel drifted wider");
}
#[test]
fn line_ending_properties_change_and_undo() {
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
drag_pt(&mut h, (40.0, 100.0), (160.0, 40.0));
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.get_by_label("Line ending");
h.query_all_by_value("None").next().expect("start ending").click();
h.run_steps(2);
for label in ["Open arrow", "Closed arrow", "Butt", "Reverse open arrow", "Reverse closed arrow", "Slash", "Square", "Circle", "Diamond"] {
h.get_by_label(label);
}
assert_eq!(h.query_all_by_label("ROpenArrow").count(), 0);
assert_eq!(h.query_all_by_label("OpenArrow").count(), 0);
h.get_by_label("None").click();
h.run_steps(1);
{
let d = h.state_mut().comment_props.as_mut().expect("open");
assert_eq!(d.edited.endings.as_deref(), Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::None][..]));
d.edited.endings = Some(vec![pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::Diamond]);
}
h.get_by_label("OK").click();
h.run_steps(3);
let endings = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Change comment properties"));
doc.comment_props(0, 0).unwrap().endings
};
assert_eq!(endings.as_deref(), Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::Diamond][..]));
h.state_mut().execute("edit.undo");
h.run_steps(2);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(
doc.comment_props(0, 0).unwrap().endings.as_deref(),
Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::None][..])
);
}
#[test]
fn comment_properties_change_appearance_and_author() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.get_by_label("Rectangle Properties");
{
let d = h.state_mut().comment_props.as_mut().expect("open");
assert_eq!(d.original.width, Some(2.0));
d.edited.color = Some([0.0, 0.0, 1.0]);
d.edited.width = Some(5.0);
d.edited.author = "Grace".into();
}
h.get_by_label("General").click();
h.run_steps(2);
h.get_by_label("Author");
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let p = doc.comment_props(0, 0).unwrap();
assert_eq!((p.color, p.width, p.author.as_str()), (Some([0.0, 0.0, 1.0]), Some(5.0), "Grace"));
assert_eq!(doc.can_undo(), Some("Change comment properties"), "one undo step");
}
/// #686: rectangles, ovals, polygons and clouds can be filled, from the quick bar for new ones
/// and from Properties for existing ones; "No fill" takes it away again.
#[test]
fn shapes_take_a_fill_colour_from_the_quick_bar_and_properties() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
h.run_steps(2);
// No fill by default; the quick bar says so.
h.get_by_label("Fill colour: none");
h.state_mut().comment_prefs.set_fill(CommentTool::Rectangle, Some([1.0, 0.5, 0.0]));
// Tools without an interior keep no fill.
h.state_mut().comment_prefs.set_fill(CommentTool::Line, Some([1.0, 0.5, 0.0]));
assert_eq!(h.state().comment_prefs.style(CommentTool::Line).fill, None);
h.run_steps(2);
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
let fill_of = |h: &Harness<'static, PdfCraftApp>, index: usize| {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let p = doc.comment_props(0, index).unwrap();
assert!(p.fillable);
p.fill
};
assert_eq!(fill_of(&h, 0), Some([1.0, 0.5, 0.0]), "the new rectangle is filled");
// Quick bar ▸ Fill colour ▸ No fill: the next rectangle has none.
h.state_mut().set_option("quick", "square").unwrap();
h.run_steps(2);
h.get_by_label("Fill colour").click();
h.run_steps(2);
h.get_by_label("No fill").click();
h.run_steps(2);
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, None);
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
assert_eq!(fill_of(&h, 1), None);
// Properties ▸ Fill colour changes an existing one, as one undo step.
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.get_by_label("Rectangle Properties");
h.get_by_label("Fill colour");
assert_eq!(h.state().comment_props.as_ref().unwrap().original.fill, Some([1.0, 0.5, 0.0]));
h.get_by_label("No fill").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(fill_of(&h, 0), None);
let undo = h.state().session.get(h.state().views[0].id).unwrap().can_undo().map(str::to_owned);
assert_eq!(undo.as_deref(), Some("Change comment properties"));
h.state_mut().open_comment_props(0, 1);
h.run_steps(2);
h.state_mut().comment_props.as_mut().expect("open").edited.fill = Some([0.0, 0.0, 1.0]);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(fill_of(&h, 1), Some([0.0, 0.0, 1.0]));
// Make Current Properties Default carries the fill into the tool.
h.state_mut().make_comment_default(0, 1);
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, Some([0.0, 0.0, 1.0]));
}
/// Comment Properties ▸ Border shape puts an oval or a circle around a text box's text; a
/// callout has none to choose. Make Properties Default carries it into the Text box tool.
#[test]
fn text_box_properties_choose_the_border_shape() {
use pdfcraft_engine::BorderShape;
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|_| {});
let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 };
let mut style = pdfcraft_engine::Style::default_for(&shape);
style.width = 1.0;
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
shape,
style,
contents: "Pos. 3".into(),
author: "Tester".into(),
})));
h.run_steps(2);
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.get_by_label("Border shape");
{
let d = h.state_mut().comment_props.as_mut().expect("open");
assert_eq!(d.original.border_shape, Some(BorderShape::Rectangle));
d.edited.border_shape = Some(BorderShape::Oval);
d.make_default = true;
}
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.comment_props(0, 0).unwrap().border_shape, Some(BorderShape::Oval));
assert_eq!(doc.can_undo(), Some("Change comment properties"));
assert_eq!(s.comment_prefs.style(CommentTool::TextBox).border_shape, BorderShape::Oval, "the default for new text boxes");
}
/// Thickness 0 in Comment Properties takes a circle away with the line, as Border ▸ None does on
/// the toolbar, and a default made from it draws no circle either.
#[test]
fn no_thickness_in_text_box_properties_takes_the_circle_away() {
use pdfcraft_engine::BorderShape;
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|_| {});
let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 };
let mut style = pdfcraft_engine::Style::default_for(&shape);
style.width = 1.0;
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
shape,
style,
contents: "Pos. 3".into(),
author: "Tester".into(),
})));
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::BorderShapeAnnotation { page: 0, index: 0, shape: BorderShape::Circle }));
h.state_mut().comment_prefs.set_border_shape(CommentTool::TextBox, BorderShape::Circle);
h.run_steps(2);
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
{
let d = h.state_mut().comment_props.as_mut().expect("open");
assert_eq!(d.original.border_shape, Some(BorderShape::Circle));
d.edited.width = Some(0.0);
d.make_default = true;
}
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let p = doc.comment_props(0, 0).unwrap();
assert_eq!((p.width, p.border_shape), (Some(0.0), Some(BorderShape::Rectangle)));
assert_eq!(doc.can_undo(), Some("Change comment properties"));
assert_eq!(s.comment_prefs.style(CommentTool::TextBox).border_shape, BorderShape::Rectangle, "the default for new text boxes");
}
/// A filled rectangle's line and fill, and a text box's text, border and fill, each take an
/// opacity of their own in Properties; Make Properties Default carries them into the tool, so
/// the next rectangle is drawn with a see-through fill and an opaque line.
#[test]
fn comment_properties_set_each_parts_opacity() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
h.state_mut().comment_prefs.set_fill(CommentTool::Rectangle, Some([1.0, 1.0, 0.0]));
h.run_steps(2);
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
let s = h.state();
let p = s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap();
(p.opacity, p.fill_opacity, p.text_opacity)
};
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
// The sliders (each labelled by its row, which shares the name).
h.get_by_role_and_label(egui::accesskit::Role::Slider, "Line opacity");
h.get_by_role_and_label(egui::accesskit::Role::Slider, "Fill opacity");
assert_eq!(h.query_all_by_label("Text opacity").count(), 0, "a rectangle has no text");
{
let d = h.state_mut().comment_props.as_mut().expect("open");
d.edited.fill_opacity = Some(0.3);
d.make_default = true;
}
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(props(&h, 0), (1.0, Some(0.3), None));
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties"));
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill_opacity, Some(0.3));
h.state_mut().set_option("quick", "square").unwrap();
h.run_steps(2);
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
assert_eq!(props(&h, 1), (1.0, Some(0.3), None), "the next rectangle takes the default");
// A text box with a border and a fill: its text, border and fill.
let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 };
let mut style = pdfcraft_engine::Style::default_for(&shape);
(style.width, style.fill) = (1.0, Some([1.0, 1.0, 0.6]));
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
shape,
style,
contents: "Note".into(),
author: "Tester".into(),
})));
h.state_mut().set_option("quick", "select").unwrap();
h.run_steps(2);
h.state_mut().open_comment_props(0, 2);
h.run_steps(2);
for label in ["Text opacity", "Border opacity", "Fill opacity"] {
h.get_by_role_and_label(egui::accesskit::Role::Slider, label);
}
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
let _gpu = crate::gpu();
h.render().unwrap().save(format!("{dir}/comment-part-opacity.png")).unwrap();
}
{
let d = h.state_mut().comment_props.as_mut().expect("open");
(d.edited.opacity, d.edited.fill_opacity) = (0.5, Some(0.2));
}
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(props(&h, 2), (0.5, Some(0.2), Some(1.0)), "the border at half, the fill at a fifth, the text opaque");
// Without its fill and border, the text is the whole text box: its opacity is the comment's.
h.state_mut().open_comment_props(0, 2);
h.run_steps(2);
{
let d = h.state_mut().comment_props.as_mut().expect("open");
(d.edited.fill, d.edited.width) = (None, Some(0.0));
}
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
h.state_mut().open_comment_props(0, 2);
h.run_steps(2);
let shown = egui_kittest::kittest::NodeT::accesskit_node(&h.get_by_role_and_label(egui::accesskit::Role::Slider, "Text opacity")).numeric_value();
assert_eq!(shown, Some(100.0), "the text's slider shows the opacity it is drawn at, not the border's half");
h.state_mut().comment_props.as_mut().expect("open").edited.opacity = 0.4;
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(props(&h, 2), (0.4, Some(0.4), Some(0.4)));
}
#[test]
fn the_panel_filters_and_sorts() {
let mut h = harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "square").unwrap();
});
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "circle").unwrap();
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
h.run_steps(2);
let ovals = h.query_all_by_label("Oval").count();
assert!(ovals >= 1);
h.state_mut().views[0].comments.hidden_types.push("Oval".into());
h.run_steps(2);
assert_eq!(h.query_all_by_label("Oval").count(), ovals - 1, "the oval's card is filtered out");
assert!(h.query_all_by_label("Rectangle").count() >= 1);
h.state_mut().views[0].comments.hidden_types.clear();
h.state_mut().views[0].comments.sort = pdfcraft_ui_egui::comments::SortBy::Type;
h.run_steps(2);
// Grouped by type: a group header names each type.
assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added");
}
// Contributor-original PDF. Two roots deliberately share /NM; the replies (on another page,
// with dates opposite to source order) point at the first by reference. Nothing replies to the
// unnamed root.
const REVIEW_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R] >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R] >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (shared) /T (Alpha) /Contents (First root) >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [60 140 80 160] /NM (shared) /T (Beta) /Contents (Duplicate root) >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [100 140 120 160] /T (Gamma) /Contents (Unnamed root) >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (late) /T (Delta) /Contents (Late root) >> endobj
9 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (first-reply) /IRT 5 0 R /T (Reader) /Contents (Needle first reply) /M (D:20260102000000) >> endobj
10 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Review /State /Rejected /Contents (Old review) /M (D:20260102000000) >> endobj
11 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Marked /State /Marked /M (D:20260102000000) >> endobj
12 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (second-reply) /IRT 5 0 R /T (Reader) /Contents (Second reply) /M (D:20260101000000) >> endobj
13 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Review /State /Accepted /Contents (Status-only token) /M (D:20260101000000) >> endobj
14 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /StateModel /Marked /State /Unmarked /M (D:20260101000000) >> endobj
15 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (late-reply) /IRT 8 0 R /T (Reader) /Contents (Late thread reply) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() {
let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| {
app.set_option("panel", "comments").unwrap();
app.views[0].comments.search = Some("needle".into());
app.views[0].comments.hidden_authors.push("Beta".into());
app.views[0].comments.selected = Some((0, 0));
});
h.get_by_label("First root");
h.get_by_label("Page 1");
h.get_by_label("3"); // All three page roots count, including the two filtered out.
assert_eq!(h.query_all_by_label("Duplicate root").count(), 0);
assert_eq!(h.query_all_by_label("Unnamed root").count(), 0);
assert!(
h.get_by_label("Needle first reply").rect().top() < h.get_by_label("Second reply").rect().top(),
"replies follow source order, not dates"
);
h.get_by_label("Accepted");
h.get_by_label("Mark with checkmark"); // The later Unmarked reply wins independently.
assert_eq!(h.query_all_by_label("Status-only token").count(), 0, "status text is searchable but is not a displayed reply");
h.state_mut().views[0].comments.hidden_authors.clear();
h.run_steps(2);
// The replies point at the first root: sharing its /NM doesn't give the other its thread
// (or a search match on that thread).
assert_eq!(h.query_all_by_label("Duplicate root").count(), 0);
assert_eq!(h.query_all_by_label("Needle first reply").count(), 1);
h.state_mut().views[0].comments.search = Some("status-only token".into());
h.state_mut().views[0].comments.hidden_statuses.push("Rejected".into());
h.state_mut().views[0].comments.hidden_checks.push("Checked".into());
h.run_steps(2);
h.get_by_label("First root");
assert_eq!(h.query_all_by_label("Duplicate root").count(), 0);
h.state_mut().views[0].comments.hidden_statuses.push("Accepted".into());
h.run_steps(2);
h.get_by_label("No comments match.");
h.state_mut().views[0].comments.hidden_statuses.clear();
h.state_mut().views[0].comments.hidden_checks.push("Unchecked".into());
h.run_steps(2);
h.get_by_label("No comments match.");
h.state_mut().views[0].comments.search = Some("unnamed root".into());
h.state_mut().views[0].comments.hidden_checks.clear();
h.run_steps(2);
h.get_by_label("Unnamed root");
assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "nothing replies to the unnamed root");
}
// Contributor-original PDF. A status is set on a reply (its /IRT names the reply, ISO 32000-2
// §12.5.6.3), not on the comment; the status reply's text is found only there.
const REPLY_STATUS_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /T (Alpha) /Contents (Plain root) >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [60 140 80 160] /T (Beta) /Contents (Other root) >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /T (Reader) /Contents (Ordinary reply) >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 7 0 R /T (Carol) /StateModel /Review /State /Accepted /Contents (Reply-status token) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn search_finds_a_comment_by_the_status_set_on_one_of_its_replies() {
let mut h = harness_with_bytes(REPLY_STATUS_FIXTURE.to_vec(), |app| {
app.set_option("panel", "comments").unwrap();
app.views[0].comments.search = Some("reply-status token".into());
});
h.run_steps(2);
h.get_by_label("Plain root");
h.get_by_label("Ordinary reply");
assert_eq!(h.query_all_by_label("Other root").count(), 0, "the other comment has no such status");
assert_eq!(h.query_all_by_label("No comments match.").count(), 0);
}
// Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named
// `fitz-A0`, so only the `/IRT` reference tells which comment a reply belongs to.
const PER_PAGE_NAMES: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (Cover note) >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (Second page note) >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply on page two) >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A2) /IRT 6 0 R /T (Beta) /Contents (Answer on page two) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn replies_follow_irt_references_when_names_repeat_on_every_page() {
let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| app.set_option("panel", "comments").unwrap());
let reply = h.get_by_label("Reply on page two");
assert!(reply.rect().top() > h.get_by_label("Second page note").rect().top(), "the reply sits under page 2's comment");
assert!(h.get_by_label("Page 2").rect().top() < reply.rect().top(), "not under page 1's comment, which shares the /NM");
h.get_by_label("Answer on page two");
// The header counts the two comments, not their replies (Acrobat says 2, not 4), and gives
// the replies in small text under it.
assert_eq!(h.query_all_by_label("4").count(), 0);
h.get_by_label("2 replies");
}
// Contributor-original PDF: a comment whose wrong reply (with a pop-up) comes before it in
// `/Annots` and has an answer of its own, a locked right reply and an Accepted status. `/F 128`
// is the Locked flag.
const REPLY_THREAD: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 9 0 R 4 0 R 6 0 R 7 0 R 8 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (note) /T (Ben) /Contents (The comment) >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (wrong) /IRT 4 0 R /T (Cat) /Contents (Wrong reply) /Popup 9 0 R >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (right) /IRT 4 0 R /T (Dan) /Contents (Right reply) /F 128 >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (answer) /IRT 5 0 R /T (Eve) /Contents (Answer to the wrong reply) >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /IRT 4 0 R /StateModel /Review /State /Accepted /T (Ben) /Contents (Accepted set by Ben) >> endobj
9 0 obj << /Type /Annot /Subtype /Popup /Rect [60 100 160 160] /Parent 5 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// The "…" of the reply row that shows `text`: the last one above it (it sits on the row's
/// author line).
fn reply_options<'a>(h: &'a Harness<'static, PdfCraftApp>, text: &str) -> egui_kittest::Node<'a> {
let y = h.get_by_label(text).rect().top();
h.query_all_by_label("Options for this reply")
.filter(|b| b.rect().top() <= y)
.max_by(|a, b| a.rect().top().total_cmp(&b.rect().top()))
.expect("a reply menu")
}
/// One reply is deleted from its own "…" menu, with the answer to it and its pop-up: the comment,
/// its other reply and its status stay, and the card stays selected though the comment moved up
/// in `/Annots`. One undo brings the reply back. A locked reply can't be deleted, and Edit
/// changes the reply's text, not the comment's.
#[test]
fn one_reply_is_deleted_from_its_menu_and_the_thread_stays() {
let mut h = harness_with_bytes(REPLY_THREAD.to_vec(), |app| {
app.set_option("panel", "comments").unwrap();
app.views[0].comments.selected = Some((0, 2));
});
let texts = |h: &Harness<'static, PdfCraftApp>| -> Vec<String> { comments(h).iter().filter_map(|a| a.contents.clone()).collect() };
let all = ["Wrong reply", "The comment", "Right reply", "Answer to the wrong reply", "Accepted set by Ben"];
assert_eq!(texts(&h), all);
assert_eq!(h.query_all_by_label("Options for this reply").count(), 3, "each reply has one; the status is a badge");
reply_options(&h, "Wrong reply").click();
h.run_steps(2);
h.get_by_label("Delete").click();
h.run_steps(3);
assert_eq!(texts(&h), ["The comment", "Right reply", "Accepted set by Ben"], "the reply took its answer along");
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().info.annotations[0].index, 0, "its pop-up went too");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the card stays selected where the comment now is");
assert_eq!(h.query_all_by_label("Wrong reply").count(), 0);
assert_eq!(h.query_all_by_label("Answer to the wrong reply").count(), 0);
h.get_by_label("Right reply");
h.get_by_label("Ben: Accepted"); // One badge per reviewer.
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comment"), "one undo step");
// Copy text copies the reply's text.
reply_options(&h, "Right reply").click();
h.run_steps(2);
h.get_by_label("Copy text").click();
let mut copied = false;
for _ in 0..4 {
h.step();
copied |= h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Right reply"));
}
assert!(copied, "the reply's text is copied");
h.run_steps(2);
// The locked reply's Delete is greyed out; its text can still be edited (ISO 32000-2
// Table 167: Locked doesn't restrict the contents).
reply_options(&h, "Right reply").click();
h.run_steps(2);
assert!(h.get_by_label("Delete").accesskit_node().is_disabled(), "a locked reply isn't deleted");
h.query_all_by_label("Edit").last().expect("the reply menu's Edit, after the toolbar tab").click();
h.run_steps(2);
let editor = h.query_all_by(|n| n.role() == Role::MultilineTextInput && n.placeholder() != Some("Add a reply")).next().expect("reply editor");
editor.click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("Right reply, reworded".into()));
h.run_steps(2);
h.get_by_label("Save").click();
h.run_steps(3);
assert_eq!(texts(&h), ["The comment", "Right reply, reworded", "Accepted set by Ben"]);
// The locked reply keeps its comment too: deleting the comment would take it along.
h.get_by_label("More").click();
h.run_steps(2);
assert!(h.get_by_label("Delete").accesskit_node().is_disabled(), "the comment of a locked reply isn't deleted");
h.key_press(egui::Key::Escape);
h.run_steps(2);
h.state_mut().undo();
h.run_steps(2);
h.state_mut().undo();
h.run_steps(3);
assert_eq!(texts(&h), all, "undo brings the reply and its answer back");
h.get_by_label("Wrong reply");
}
// Contributor-original PDF: a comment and its replies A, B and C, in that `/Annots` order.
const THREE_REPLIES: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (note) /T (Ben) /Contents (The comment) >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (a) /IRT 4 0 R /T (Cat) /Contents (Reply A) >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (b) /IRT 4 0 R /T (Dan) /Contents (Reply B) >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (c) /IRT 4 0 R /T (Eve) /Contents (Reply C) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// Opens the inline editor on the reply that shows `text`, from its "…" menu, and types `draft`
/// into it without saving.
fn draft_reply(h: &mut Harness<'static, PdfCraftApp>, text: &str, draft: &str) {
reply_options(h, text).click();
h.run_steps(2);
h.query_all_by_label("Edit").last().expect("the reply menu's Edit, after the toolbar tab").click();
h.run_steps(2);
let editor = h.query_all_by(|n| n.role() == Role::MultilineTextInput && n.placeholder() != Some("Add a reply")).next().expect("reply editor");
editor.click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text(draft.into()));
h.run_steps(2);
}
/// A reply's inline editor, left open with a draft, closes on Undo and on Redo. Either can put
/// a reply back in its place or take one away, and the editor would have saved the draft over
/// whichever reply took that place: reply A after delete → Undo, reply C after Redo.
#[test]
fn undo_and_redo_close_a_reply_editor_left_open() {
let mut h = harness_with_bytes(THREE_REPLIES.to_vec(), |app| {
app.set_option("panel", "comments").unwrap();
app.views[0].comments.selected = Some((0, 0));
});
let texts = |h: &Harness<'static, PdfCraftApp>| -> Vec<String> { comments(h).iter().filter_map(|a| a.contents.clone()).collect() };
let all = ["The comment", "Reply A", "Reply B", "Reply C"];
assert_eq!(texts(&h), all);
draft_reply(&mut h, "Reply B", "B's draft");
reply_options(&h, "Reply A").click();
h.run_steps(2);
h.get_by_label("Delete").click();
h.run_steps(3);
assert_eq!(texts(&h), ["The comment", "Reply B", "Reply C"]);
assert_eq!(h.state().views[0].comments.editing, Some((0, 1, "B's draft".to_string())), "the editor follows B up");
h.state_mut().undo();
h.run_steps(3);
assert_eq!(texts(&h), all, "undo puts A back where B's editor now points");
assert_eq!(h.state().views[0].comments.editing, None, "the editor closes on undo");
assert_eq!(h.query_all_by_label("Save").count(), 0, "no draft is left to save over A");
h.state_mut().views[0].comments.selected = Some((0, 0));
h.run_steps(2);
draft_reply(&mut h, "Reply B", "B's draft");
assert_eq!(h.state().views[0].comments.editing, Some((0, 2, "B's draft".to_string())));
h.state_mut().redo();
h.run_steps(3);
assert_eq!(texts(&h), ["The comment", "Reply B", "Reply C"], "redo deletes A again, and C takes B's place");
assert_eq!(h.state().views[0].comments.editing, None, "the editor closes on redo");
assert_eq!(h.query_all_by_label("Save").count(), 0, "no draft is left to save over C");
}
#[test]
fn the_header_names_no_replies_when_there_are_none() {
let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| {
app.set_option("panel", "comments").unwrap();
app.views[0].comments.search = Some("cover".into());
});
h.get_by_label("Cover note");
h.get_by_label("2 replies"); // The header counts the document, not the search.
let h = harness_with_bytes(SINGLE_COMMENT.to_vec(), |app| app.set_option("panel", "comments").unwrap());
h.get_by_label("Only note");
assert_eq!(h.query_all_by_label_contains("repl").count(), 0, "no \"0 replies\" line");
}
// Contributor-original PDF: one comment, no replies.
const SINGLE_COMMENT: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (only) /T (Alpha) /Contents (Only note) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
// Contributor-original PDF. Page 1: an unnamed comment with a reply and a reply to that reply,
// a Replace Text strikeout with its grouped caret, and a hidden status whose comment no page
// lists. Page 2: an `/IRT` to the annotation itself, to an annotation no page lists (object 11),
// to a pop-up, to a link and to a form field, and two annotations whose `/IRT`s point at each other.
const THREAD_EDGES: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 19 0 R] >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 20 0 R 21 0 R] >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (Unnamed parent) >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 5 0 R /T (Beta) /Contents (Direct reply) >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 6 0 R /T (Gamma) /Contents (Reply to the reply) >> endobj
8 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [60 100 160 112] /IT /StrikeOutTextEdit /T (Alpha) /Contents (Struck words) >> endobj
9 0 obj << /Type /Annot /Subtype /Caret /Rect [158 96 166 104] /IRT 8 0 R /RT /Group /T (Alpha) /Contents (Replacement words) >> endobj
10 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 10 0 R /T (Delta) /Contents (Points at itself) >> endobj
11 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /T (Nobody) /Contents (Listed on no page) >> endobj
12 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /IRT 11 0 R /T (Delta) /Contents (Points at an unlisted note) >> endobj
13 0 obj << /Type /Annot /Subtype /Popup /Rect [120 100 280 180] >> endobj
14 0 obj << /Type /Annot /Subtype /Text /Rect [20 80 40 100] /IRT 13 0 R /T (Delta) /Contents (Points at a pop-up) >> endobj
15 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj
16 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (field) /Rect [200 60 260 80] >> endobj
17 0 obj << /Type /Annot /Subtype /Text /Rect [20 50 40 70] /IRT 15 0 R /T (Delta) /Contents (Points at a link) >> endobj
18 0 obj << /Type /Annot /Subtype /Text /Rect [20 20 40 40] /IRT 16 0 R /T (Delta) /Contents (Points at a form field) >> endobj
19 0 obj << /Type /Annot /Subtype /Text /Rect [240 20 260 40] /F 30 /IRT 11 0 R /T (Delta) /StateModel /Review /State /Accepted /Contents (Stray status) >> endobj
20 0 obj << /Type /Annot /Subtype /Text /Rect [60 20 80 40] /IRT 21 0 R /T (Delta) /Contents (Loop one) >> endobj
21 0 obj << /Type /Annot /Subtype /Text /Rect [90 20 110 40] /IRT 20 0 R /T (Delta) /Contents (Loop two) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread() {
let mut h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "select").unwrap();
});
// An `/IRT` that reaches no listed comment leaves its annotation a comment of its own,
// rather than a reply shown nowhere.
// Links, form widgets and pop-ups are never parents: neither reader lists them as comments.
for orphan in
["Points at itself", "Points at an unlisted note", "Points at a pop-up", "Points at a link", "Points at a form field", "Loop one", "Loop two"]
{
assert!(h.get_by_label(orphan).rect().top() > h.get_by_label("Page 2").rect().top(), "{orphan}");
}
// A reply to a reply joins the thread of the comment it starts from, also when that comment
// has no /NM.
let (parent, direct, deeper) = (h.get_by_label("Unnamed parent"), h.get_by_label("Direct reply"), h.get_by_label("Reply to the reply"));
assert!(parent.rect().top() < direct.rect().top() && direct.rect().top() < deeper.rect().top());
assert!(deeper.rect().top() < h.get_by_label("Struck words").rect().top(), "under its own thread");
// Comments: the unnamed parent, the strikeout and the seven on page 2. Replies: the two
// replies, not the Replace Text caret grouped with its strikeout.
h.get_by_label("9");
h.get_by_label("2 replies");
// A status whose comment no page lists is not a comment: no card, and nothing to pick on the
// page where it sits.
assert_eq!(h.query_all_by_label("Stray status").count(), 0);
click_pt(&mut h, (250.0, 30.0));
assert_eq!(h.state().views[0].comments.selected, None);
}
/// The arrow keys move a comment, not a reply: also not a reply to a comment with no `/NM`.
#[test]
fn arrow_keys_leave_a_reply_to_an_unnamed_comment_where_it_is() {
let mut h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| app.set_option("quick", "select").unwrap());
let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| comments(h).into_iter().find(|a| a.contents.as_deref() == Some(text)).unwrap().rect;
let (reply, parent) = (rect(&h, "Direct reply"), rect(&h, "Unnamed parent"));
h.state_mut().views[0].comments.selected = Some((0, 1));
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert_eq!(rect(&h, "Direct reply"), reply, "the reply stays");
h.state_mut().views[0].comments.selected = Some((0, 0));
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert_ne!(rect(&h, "Unnamed parent"), parent, "its comment moves");
}
// Contributor-original PDF: a comment, a reply, and a checkmark and a status set on the reply.
const CHECKED_REPLY: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (Unchecked comment) >> endobj
5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (Checked reply) >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// A checkmark or status belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2): one
/// set on a reply doesn't mark the comment, on its card or in the page's menu.
#[test]
fn a_checkmark_on_a_reply_does_not_mark_its_comment() {
let mut h = harness_with_bytes(CHECKED_REPLY.to_vec(), |app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "select").unwrap();
});
click_pt(&mut h, (110.0, 110.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
h.get_by_label("Checked reply");
h.get_by_label("Mark with checkmark"); // The card's checkmark.
assert_eq!(h.query_all_by_label("Accepted").count(), 0, "no status badge");
let p = at(&h, 110.0, 110.0);
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
h.get_by_label("Copy text"); // The page's menu is open.
assert_eq!(h.query_all_by_label("Remove checkmark").count(), 0);
assert_eq!(h.query_all_by_label("Mark with checkmark").count(), 2, "the card and the page's menu");
}
#[test]
fn the_panel_reveals_edits_and_reopens_a_late_thread_without_changing_its_replies() {
let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| {
app.set_option("panel", "comments").unwrap();
app.views[0].comments.collapsed_pages = vec![0, 1];
});
assert_eq!(h.query_all_by_label("Late root").count(), 0);
h.state_mut().views[0].comments.selected = Some((1, 0));
h.state_mut().views[0].comments.reveal = true;
h.run_steps(4);
h.get_by_label("Late root");
h.get_by_label("Late thread reply");
assert_eq!(h.state().views[0].comments.collapsed_pages, vec![0]);
h.get_by_label("More").click();
h.run_steps(2);
h.query_all_by_label("Edit").last().expect("comment menu edit, after the toolbar tab").click();
h.run_steps(2);
let editor = h
.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.placeholder() != Some("Add a reply"))
.next()
.expect("note editor");
editor.click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("Late root edited exactly".into()));
h.run_steps(2);
h.get_by_label("Save").click();
h.run_steps(3);
assert_eq!(h.state().views[0].comments.selected, Some((1, 0)));
let before = comments(&h);
let late = before.iter().find(|a| a.name.as_deref() == Some("late")).unwrap();
assert_eq!(late.contents.as_deref(), Some("Late root edited exactly"));
let id = h.state().views[0].id;
let saved = h.state().session.save_bytes(id).unwrap().as_ref().clone();
let mut reopened = harness_with_bytes(saved, |app| {
app.set_option("panel", "comments").unwrap();
app.views[0].comments.search = Some("late thread reply".into());
});
h.get_by_label("Late root edited exactly");
reopened.get_by_label("Late root edited exactly");
reopened.get_by_label("Late thread reply");
assert_eq!(reopened.query_all_by_label("First root").count(), 0);
let after = comments(&reopened);
assert_eq!(after.len(), before.len());
for (original, saved) in before.iter().zip(&after) {
assert_eq!(
(&saved.name, &saved.contents, &saved.in_reply_to, &saved.state),
(&original.name, &original.contents, &original.in_reply_to, &original.state)
);
}
assert_eq!(after.iter().find(|a| a.name.as_deref() == Some("late-reply")).unwrap().in_reply_to.as_deref(), Some("late"));
reopened.state_mut().views[0].comments.sort = pdfcraft_ui_egui::comments::SortBy::Author;
reopened.run_steps(2);
assert_eq!(reopened.query_all_by_label("Delta").count(), 2, "the author group and its card remain visible after sorting");
reopened.get_by_label("Late root edited exactly");
}
/// #1554: a page group's chevron folds that page's cards away and back, and the choice holds.
#[test]
fn page_groups_collapse_and_expand() {
let mut h = harness(|app| app.set_option("panel", "comments").unwrap());
for (page, text) in [(0, "First page note"), (1, "Second page note")] {
let shape = pdfcraft_engine::Shape::Note { at: [100.0, 100.0], icon: pdfcraft_engine::NoteIcon::Comment };
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: text.into(),
author: "Tester".into(),
}));
}
h.state_mut().views[0].comments.selected = None;
h.run_steps(3);
let shown = |h: &Harness<'static, PdfCraftApp>, text: &str| h.query_all_by_label_contains(text).count() > 0;
assert!(shown(&h, "First page note") && shown(&h, "Second page note"));
assert_eq!(h.query_all_by_label("Collapse").count(), 2, "one chevron per page group");
h.query_all_by_label("Collapse").next().unwrap().click();
h.run_steps(3);
assert!(!shown(&h, "First page note"), "page 1's card is folded away");
assert!(shown(&h, "Second page note"), "page 2 stays open");
h.run_steps(5);
assert!(!shown(&h, "First page note"), "the group stays folded on later frames");
h.get_by_label("Expand").click();
h.run_steps(3);
assert!(shown(&h, "First page note"), "page 1's card is back");
// Selecting a comment on a folded page opens its group so the card can be shown.
h.query_all_by_label("Collapse").next().unwrap().click();
h.run_steps(3);
assert!(!shown(&h, "First page note"));
h.state_mut().views[0].comments.selected = Some((0, 0));
h.state_mut().views[0].comments.reveal = true;
h.run_steps(3);
assert!(shown(&h, "First page note"), "the selected comment's group opened");
// "…" ▸ Collapse all pages, then Expand all pages.
h.state_mut().views[0].comments.selected = None;
h.get_by_label("More options").click();
h.run_steps(2);
h.get_by_label("Collapse all pages").click();
h.run_steps(3);
assert!(!shown(&h, "First page note") && !shown(&h, "Second page note"), "every page is folded");
assert_eq!(h.query_all_by_label("Expand").count(), 2);
h.get_by_label("More options").click();
h.run_steps(2);
h.get_by_label("Expand all pages").click();
h.run_steps(3);
assert!(shown(&h, "First page note") && shown(&h, "Second page note"), "every page is open again");
}
/// Flatten comments is in the page's right-click menu and in the Comments panel's "…" menu: the
/// comments go into the page, one undo step brings them back.
#[test]
fn flatten_comments_from_the_page_menu_and_the_panel_menu() {
let mut h = harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "select").unwrap();
});
let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 80.0, 60.0] };
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
style: pdfcraft_engine::Style::default_for(&shape),
shape,
contents: "Reviewed".into(),
author: "Tester".into(),
}));
h.state_mut().views[0].comments.selected = None;
crate::support::settle(&mut h);
assert_eq!(comments(&h).len(), 1);
// Right-click on the page, away from the comment.
let p = at(&h, 200.0, 150.0);
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
h.get_by_label("Flatten comments").click();
h.run_steps(4);
assert!(comments(&h).is_empty(), "the comment went into the page");
h.state_mut().undo();
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "one undo step brings it back");
h.get_by_label("More options").click();
h.run_steps(2);
h.get_by_label("Flatten comments").click();
h.run_steps(4);
assert!(comments(&h).is_empty(), "the panel's menu flattens too");
}
#[test]
fn comments_take_checkmarks_lock_hide_and_summarize() {
let mut h = harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "square").unwrap();
});
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
h.run_steps(2);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the new comment is selected");
// The card's checkmark.
h.get_by_label("Mark with checkmark").click();
h.run_steps(3);
assert!(comments(&h).iter().any(|a| a.state.as_deref() == Some("Marked")));
h.get_by_label("Remove checkmark");
// "…" ▸ Copy text puts the comment's text on the clipboard.
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetAnnotationContents { page: 0, index: 0, text: "Copy me".into() });
h.run_steps(2);
h.get_by_label("More").click();
h.run_steps(2);
h.get_by_label("Copy text").click();
let mut copied = false;
for _ in 0..4 {
h.step();
copied |= h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Copy me"));
}
assert!(copied, "the text is copied");
h.run_steps(2);
// Properties ▸ Locked.
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.get_by_label("Locked").click();
h.run_steps(1);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(comments(&h)[0].locked);
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert!(comments(&h).iter().any(|a| a.in_reply_to.is_none()), "a locked comment isn't deleted");
// "…" ▸ Hide all comments, then Show all comments.
h.get_by_label("More options").click();
h.run_steps(2);
h.get_by_label("Hide all comments").click();
h.run_steps(2);
let s = h.state();
assert!(s.session.get(s.views[0].id).unwrap().comments_hidden());
h.get_by_label("More options").click();
h.run_steps(2);
h.get_by_label("Show all comments").click();
h.run_steps(2);
let s = h.state();
assert!(!s.session.get(s.views[0].id).unwrap().comments_hidden());
// "…" ▸ Create PDF comment summary.
h.get_by_label("More options").click();
h.run_steps(2);
h.get_by_label("Create PDF comment summary…").click();
h.run_steps(2);
h.get_by_label("Summarize Options");
h.get_by_label("Create PDF Comment Summary").click();
h.run_steps(3);
let s = h.state();
assert_eq!(s.views.len(), 2);
assert!(s.session.get(s.views[1].id).unwrap().name.starts_with("Summary of comments on text"));
}
#[test]
fn polygons_clouds_connected_lines_callouts_and_inserted_text() {
let mut h = harness(|app| app.set_option("quick", "polygon").unwrap());
// Polygon: click the corners, Enter finishes.
for p in [(40.0, 40.0), (120.0, 40.0), (80.0, 110.0)] {
click_pt(&mut h, p);
}
assert_eq!(comments(&h).len(), 0, "still drawing");
h.key_press(egui::Key::Enter);
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!(c[0].subtype, "Polygon");
// Cloud: clicking the first point again closes it.
h.state_mut().set_option("quick", "cloud").unwrap();
for p in [(160.0, 40.0), (260.0, 40.0), (260.0, 100.0), (160.0, 100.0), (160.0, 40.0)] {
click_pt(&mut h, p);
}
assert_eq!(comments(&h).len(), 2);
// Connected lines.
h.state_mut().set_option("quick", "polyline").unwrap();
for p in [(20.0, 150.0), (60.0, 130.0), (100.0, 150.0)] {
click_pt(&mut h, p);
}
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert!(comments(&h).iter().any(|a| a.subtype == "PolyLine"));
// Callout: drag from the target to where the box goes, then type.
h.state_mut().set_option("quick", "callout").unwrap();
drag_pt(&mut h, (60.0, 160.0), (180.0, 190.0));
assert!(h.state().views[0].comments.composer.is_some(), "the composer opened");
field(&h, "Type text").type_text("Why?");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let callout = comments(&h).into_iter().find(|a| a.subtype == "FreeText").expect("a callout");
assert_eq!(callout.contents.as_deref(), Some("Why?"));
assert!(callout.rect[0] <= 60.0 && callout.rect[1] <= 160.0, "holds the leader: {:?}", callout.rect);
// Its knee line is Acrobat's: level, 12 pt out from the middle of the box's left side.
let (tb, knee) = (callout.text_box.expect("a text box"), callout.points[1]);
assert!((knee[0] - (tb[0] - 12.0)).abs() < 0.01 && (knee[1] - (tb[1] + tb[3]) / 2.0).abs() < 0.01, "{tb:?} {knee:?}");
// Insert text at a click.
h.state_mut().set_option("quick", "caret").unwrap();
click_pt(&mut h, (95.0, 150.0));
field(&h, "Text to insert").type_text("very ");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let caret = comments(&h).into_iter().find(|a| a.subtype == "Caret").expect("a caret");
assert_eq!(caret.contents.as_deref(), Some("very"));
assert_eq!(h.state().quick_tool, QuickTool::Select);
}
fn drawn_points(h: &Harness<'static, PdfCraftApp>) -> usize {
match &h.state().views[0].comments.gesture {
Some(pdfcraft_ui_egui::comments::Gesture::Draw { points, .. }) => points.len(),
_ => 0,
}
}
#[test]
fn a_wrong_polygon_point_is_taken_back_with_backspace_or_undo() {
let mut h = harness(|app| app.set_option("quick", "polygon").unwrap());
for p in [(40.0, 40.0), (120.0, 40.0), (200.0, 150.0)] {
click_pt(&mut h, p);
}
assert_eq!(drawn_points(&h), 3);
// Backspace takes back the wrong click, then Undo takes back the one before it.
h.key_press(egui::Key::Backspace);
h.run_steps(2);
assert_eq!(drawn_points(&h), 2);
h.state_mut().undo();
h.run_steps(2);
assert_eq!(drawn_points(&h), 1);
// The right corner goes where the wrong one was, and the polygon finishes with all three.
click_pt(&mut h, (80.0, 110.0));
click_pt(&mut h, (120.0, 40.0));
assert_eq!(drawn_points(&h), 3);
h.key_press(egui::Key::Enter);
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!(c[0].subtype, "Polygon");
// Taking back the only point cancels the drawing without making a comment.
h.state_mut().set_option("quick", "polygon").unwrap();
click_pt(&mut h, (200.0, 200.0));
h.key_press(egui::Key::Backspace);
h.run_steps(2);
assert_eq!(drawn_points(&h), 0);
assert!(h.state().views[0].comments.gesture.is_none());
assert_eq!(comments(&h).len(), 1);
}
#[test]
fn inserted_text_snaps_to_the_gap_between_characters_on_the_baseline() {
let mut h = harness(|app| app.set_option("quick", "caret").unwrap());
// "The quick brown fox", Helvetica 14 pt from x = 20 on the baseline y = 150. With the
// standard-14 widths "The quick b" ends at 92.366 and "r" (333/1000 em) at 97.028, so a click
// in the right half of "r", above the baseline, puts the tip between "r" and "o".
click_pt(&mut h, (95.5, 155.0));
field(&h, "Text to insert").type_text("x");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let caret = comments(&h).into_iter().find(|a| a.subtype == "Caret").expect("a caret");
let [x0, y0, x1, y1] = caret.rect;
assert!(((x0 + x1) / 2.0 - 97.03).abs() < 0.3, "tip at the r|o gap: {:?}", caret.rect);
assert!((y1 - 150.0).abs() < 0.3, "tip on the baseline: {:?}", caret.rect);
assert!((y1 - y0 - 7.0).abs() < 0.3, "half the 14 pt em: {:?}", caret.rect);
// Away from the text the caret stays where the click was, at its default size.
h.state_mut().set_option("quick", "caret").unwrap();
click_pt(&mut h, (200.0, 60.0));
field(&h, "Text to insert").type_text("y");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let away = comments(&h).into_iter().find(|a| a.subtype == "Caret" && a.contents.as_deref() == Some("y")).expect("a second caret");
let [x0, y0, x1, y1] = away.rect;
assert!(((x0 + x1) / 2.0 - 200.0).abs() < 1.0 && (y1 - 60.0).abs() < 1.0 && (y1 - y0 - 8.0).abs() < 0.3, "{:?}", away.rect);
}
#[test]
fn the_panel_filters_by_colour_and_checkmark() {
let mut h = harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "square").unwrap();
});
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "circle").unwrap();
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
h.state_mut().apply_edit(pdfcraft_engine::Edit::StyleAnnotation {
page: 0,
index: 1,
color: Some([0.0, 0.47, 0.84]),
opacity: None,
width: None,
endings: None,
});
h.state_mut().apply_edit(pdfcraft_engine::Edit::MarkAnnotation { page: 0, index: 0, marked: true, author: "Tester".into() });
h.run_steps(2);
let cards = |h: &Harness<'static, PdfCraftApp>| (h.query_all_by_label("Rectangle").count(), h.query_all_by_label("Oval").count());
// The filter menu lists the colours by name (counted with it open: it names the types too).
h.get_by_label("Filter comments").click();
h.run_steps(2);
let before = cards(&h);
h.get_by_label("Blue").click();
h.run_steps(2);
assert_eq!(h.state().views[0].comments.hidden_colors, vec!["0077D6".to_string()]);
assert_eq!(cards(&h).1, before.1 - 1, "the blue oval is filtered out");
h.get_by_label("Checked").click();
h.run_steps(2);
assert_eq!(cards(&h).0, before.0 - 1, "the checked rectangle is filtered out");
h.get_by_label("Show all").click();
h.run_steps(2);
assert_eq!(cards(&h), before);
}
/// ISO 32000-2 §12.5.6.3 and Table 174: a comment shows each reviewer's latest status, with
/// their name, and a status set back to None stays shown; a checkmark shows only for the
/// reviewer who set it.
#[test]
fn the_panel_shows_each_reviewers_status_and_only_my_checkmark() {
let mut h = harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "square").unwrap();
});
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
let me = h.state().comment_prefs.author.clone();
let other = format!("{me} (other)");
for (state, author) in [
(pdfcraft_engine::ReviewState::Accepted, "Bob"),
(pdfcraft_engine::ReviewState::Completed, "Carol"),
(pdfcraft_engine::ReviewState::Rejected, "Bob"),
(pdfcraft_engine::ReviewState::Completed, "Dan"),
(pdfcraft_engine::ReviewState::None, "Dan"),
] {
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetAnnotationStatus { page: 0, index: 0, state, author: author.into() });
}
h.state_mut().apply_edit(pdfcraft_engine::Edit::MarkAnnotation { page: 0, index: 0, marked: true, author: other.clone() });
h.state_mut().views[0].comments.selected = Some((0, 0));
h.run_steps(2);
h.get_by_label("Bob: Rejected");
h.get_by_label("Carol: Completed");
h.get_by_label("Dan: None");
assert_eq!(h.query_all_by_label("Bob: Accepted").count(), 0, "only Bob's latest");
assert!(h.query_all_by_label("Remove checkmark").next().is_none(), "another reviewer's checkmark isn't mine");
h.get_by_label("Mark with checkmark");
// Filters: a comment stays while any reviewer's status is shown; one nobody set a status on
// counts as None; the checkmark filter goes by my checkmark only.
h.state_mut().set_option("quick", "circle").unwrap();
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
h.state_mut().views[0].comments.selected = None;
h.run_steps(2);
let cards = |h: &Harness<'static, PdfCraftApp>| (h.query_all_by_label("Rectangle").count(), h.query_all_by_label("Oval").count());
let shown = cards(&h);
h.state_mut().views[0].comments.hidden_statuses = vec!["Rejected".into()];
h.run_steps(2);
assert_eq!(cards(&h), shown, "Carol's and Dan's statuses still show the rectangle");
h.state_mut().views[0].comments.hidden_statuses = vec!["None".into()];
h.run_steps(2);
assert_eq!(cards(&h), (shown.0, shown.1 - 1), "the oval has no status, so it counts as None");
h.state_mut().views[0].comments.hidden_statuses = vec!["Rejected".into(), "Completed".into(), "None".into()];
h.run_steps(2);
assert_eq!(cards(&h), (shown.0 - 1, shown.1 - 1), "every status hidden");
h.state_mut().views[0].comments.hidden_statuses.clear();
h.state_mut().views[0].comments.hidden_checks = vec!["Checked".into()];
h.run_steps(2);
assert_eq!(cards(&h), shown, "another reviewer's checkmark isn't mine");
h.state_mut().views[0].comments.hidden_checks.clear();
h.state_mut().views[0].comments.selected = Some((0, 0));
h.state_mut().comment_prefs.author = other;
h.run_steps(2);
h.get_by_label("Remove checkmark");
}
#[test]
fn make_current_properties_default() {
let mut h = harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "square").unwrap();
});
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
h.state_mut().apply_edit(pdfcraft_engine::Edit::StyleAnnotation {
page: 0,
index: 0,
color: Some([0.0, 0.47, 0.84]),
opacity: Some(0.5),
width: Some(5.0),
endings: None,
});
h.run_steps(2);
h.get_by_label("More").click();
h.run_steps(2);
h.get_by_label("Make Current Properties Default").click();
h.run_steps(3);
let st = h.state().comment_prefs.style(pdfcraft_ui_egui::comments::CommentTool::Rectangle);
assert_eq!((st.color, st.opacity, st.width), ([0.0, 0.47, 0.84], 0.5, 5.0));
// The default survives a restart (#340): a fresh app reads the persisted settings and its
// next rectangle still takes the style.
let json = h.state().persist();
let mut fresh = PdfCraftApp::new();
fresh.restore(&json);
let st = fresh.comment_prefs.style(pdfcraft_ui_egui::comments::CommentTool::Rectangle);
assert_eq!((st.color, st.opacity, st.width), ([0.0, 0.47, 0.84], 0.5, 5.0), "the tool default persisted");
// The next rectangle takes it.
h.state_mut().set_option("quick", "square").unwrap();
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
let c = comments(&h);
let last = c.iter().rfind(|a| a.subtype == "Square").unwrap();
assert_eq!(last.color, Some([0.0, 0.47, 0.84]));
}
/// Acrobat's Properties ▸ Make Properties Default: OK with the box ticked makes the edited look
/// the tool's default (here a strikethrough's colour); without it, only the comment changes.
#[test]
fn properties_dialog_makes_the_strikethrough_colour_default() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|app| app.set_option("quick", "strikeout").unwrap());
let green = [0.18, 0.62, 0.36];
let blue = [0.0, 0.47, 0.84];
assert_ne!(h.state().comment_prefs.style(CommentTool::StrikeOut).color, green);
drag_pt(&mut h, (18.0, 155.0), (150.0, 155.0));
assert_eq!(comments(&h).iter().filter(|a| a.subtype == "StrikeOut").count(), 1);
// Without the box: the comment turns blue, the tool keeps its colour.
let before = h.state().comment_prefs.style(CommentTool::StrikeOut);
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.state_mut().comment_props.as_mut().expect("open").edited.color = Some(blue);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(h.state().comment_prefs.style(CommentTool::StrikeOut).color, before.color);
// With it: green, and a 0% opacity is clamped so the next strikethrough stays visible.
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
{
let d = h.state_mut().comment_props.as_mut().expect("open");
d.edited.color = Some(green);
d.edited.opacity = 0.0;
}
h.get_by_label("Make Properties Default").click();
h.run_steps(1);
assert!(h.state().comment_props.as_ref().is_some_and(|d| d.make_default));
h.get_by_label("OK").click();
h.run_steps(3);
let st = h.state().comment_prefs.style(CommentTool::StrikeOut);
assert_eq!((st.color, st.opacity), (green, 0.1));
let fresh = {
let mut fresh = PdfCraftApp::new();
fresh.restore(&h.state().persist());
fresh.comment_prefs.style(CommentTool::StrikeOut).color
};
assert_eq!(fresh, green, "the default persisted");
drag_pt(&mut h, (18.0, 155.0), (150.0, 155.0));
let c = comments(&h);
let made = c.iter().rfind(|a| a.subtype == "StrikeOut").and_then(|a| a.color).expect("a colougreen strikethrough");
assert!(made.iter().zip(green).all(|(m, r)| (f64::from(*m) - r).abs() < 0.01), "the next strikethrough is green: {made:?}");
}
/// A text box with neither a border nor a fill is all text: Make Properties Default (and the
/// context menu's Make Current Properties Default) gives the tool the opacity its text is drawn
/// at, the one Comment Properties shows, not the comment's /CA.
#[test]
fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() {
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|_| {});
let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 };
let style = Style { width: 0.0, fill: None, ..Style::default_for(&shape) };
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
shape,
style,
contents: "Faint".into(),
author: "Tester".into()
})));
h.run_steps(2);
let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Faint")).map(|a| a.index).expect("the text box");
// As the automation's comment_edit sets it.
assert!(h.state_mut().apply_edit(Edit::PartOpacity { page: 0, index, fill: None, text: Some(0.5) }));
h.run_steps(2);
let opacity = |h: &Harness<'static, PdfCraftApp>| h.state().comment_prefs.style(CommentTool::TextBox).opacity;
assert!((opacity(&h) - 1.0).abs() < 1e-6);
h.state_mut().open_comment_props(0, index);
h.run_steps(2);
h.get_by_label("Make Properties Default").click();
h.run_steps(1);
h.get_by_label("OK").click();
h.run_steps(3);
assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h));
// The context menu's way, from a tool at 100 % again.
h.state_mut().comment_prefs.set_opacity(CommentTool::TextBox, 1.0);
h.state_mut().make_comment_default(0, index);
assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h));
}
/// Make Properties Default on a text box with a border colour of its own gives the Text box tool
/// that colour.
#[test]
fn a_text_box_made_default_gives_the_tool_its_border_colour() {
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|_| {});
let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 };
let style = Style { width: 1.0, ..Style::default_for(&shape) };
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
shape,
style,
contents: "Framed".into(),
author: "Tester".into()
})));
h.run_steps(2);
let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box");
let red = Some([0.89, 0.13, 0.13]);
assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: red }));
assert_ne!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red);
h.state_mut().open_comment_props(0, index);
h.run_steps(2);
h.get_by_label("Make Properties Default").click();
h.run_steps(1);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red);
}
/// The Text box tool's Border colour button shows the selected text box's own border colour
/// (Comment Properties may have given it one), and the tool's again with none selected.
#[test]
fn the_border_colour_button_shows_the_selected_text_boxs_colour() {
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
let mut h = harness(|app| app.set_option("quick", "freetext").unwrap());
let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 };
let style = Style { width: 1.0, ..Style::default_for(&shape) };
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
shape,
style,
contents: "Framed".into(),
author: "Tester".into()
})));
h.run_steps(2);
let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box");
// A border colour of its own, which the tool doesn't have.
assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: Some([0.0, 0.47, 0.84]) }));
h.run_steps(2);
// The colour the button's square is drawn in.
let shown = |h: &Harness<'static, PdfCraftApp>| {
let button = h.get_by_label("Border colour").rect();
h.output().shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.stroke.width == 3.0 && button.contains_rect(r.rect) => Some(r.stroke.color),
_ => None,
})
};
// A new text box comes selected.
h.state_mut().views[0].comments.selected = None;
h.run_steps(3);
let tool = shown(&h).expect("the button's square");
h.state_mut().views[0].comments.selected = Some((0, index));
h.run_steps(3);
let own = shown(&h).expect("the button's square");
assert!(own != tool && own.b() > 200 && own.r() < 30, "the selected text box's blue: {own:?}, the tool's {tool:?}");
h.state_mut().views[0].comments.selected = None;
h.run_steps(3);
assert_eq!(shown(&h), Some(tool), "the tool's colour again");
}
/// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines.
#[test]
fn an_arrows_default_does_not_restyle_lines() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "arrow").unwrap();
});
drag_pt(&mut h, (20.0, 40.0), (140.0, 80.0));
h.state_mut().set_option("quick", "select").unwrap();
h.state_mut().apply_edit(pdfcraft_engine::Edit::StyleAnnotation {
page: 0,
index: 0,
color: Some([0.0, 0.47, 0.84]),
opacity: Some(0.5),
width: Some(6.0),
endings: None,
});
h.run_steps(2);
h.get_by_label("More").click();
h.run_steps(2);
h.get_by_label("Make Current Properties Default").click();
h.run_steps(3);
let arrow = h.state().comment_prefs.style(CommentTool::Arrow);
let line = h.state().comment_prefs.style(CommentTool::Line);
assert_eq!((arrow.color, arrow.opacity, arrow.width), ([0.0, 0.47, 0.84], 0.5, 6.0));
assert_eq!((line.color, line.opacity, line.width), ([0.89, 0.13, 0.13], 1.0, 2.0), "plain lines keep their own default");
h.state_mut().set_option("quick", "arrow").unwrap();
drag_pt(&mut h, (20.0, 100.0), (140.0, 140.0));
h.state_mut().set_option("quick", "line").unwrap();
drag_pt(&mut h, (160.0, 40.0), (260.0, 80.0));
let c = comments(&h);
let arrows = c.iter().filter(|a| a.subtype == "Line").collect::<Vec<_>>();
assert!(arrows.len() >= 3, "{c:?}");
let next_arrow = &arrows[1];
let plain = arrows.last().unwrap();
assert_eq!(next_arrow.color, Some([0.0, 0.47, 0.84]));
assert_eq!(plain.color, Some([0.89, 0.13, 0.13]));
}
#[test]
fn highlighting_an_area_off_the_text() {
let mut h = harness(|app| app.set_option("quick", "highlight").unwrap());
// Blank space below the text line (text is at y 150).
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
let c = comments(&h);
assert_eq!(c.len(), 1, "{c:?}");
assert_eq!((c[0].subtype.as_str(), c[0].quads.len()), ("Highlight", 1));
let q = c[0].quads[0];
assert!((q[0] - 40.0).abs() < 3.0 && (q[1] - 100.0).abs() < 3.0, "{q:?}");
// Dragging over text still highlights the text (once the text layer is in).
support::wait_until(&mut h, "the page's text layer", |app| {
let v = &app.views[0];
v.glyph_screen_pos(0, 4).is_some() && v.glyph_screen_pos(0, 14).is_some()
});
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
drag(&mut h, a, b);
assert_eq!(comments(&h).len(), 2);
}
/// A Replace Text proposal is picked by its strikeout too, not only by its small caret: the
/// strikeout is grouped under the caret (`/RT /Group`), and a click on it selects the caret, the
/// proposal, so it can be deleted or restyled from either part.
#[test]
fn clicking_a_replace_text_strikeout_selects_the_proposal() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Caret /Rect [135 95 145 105] /C [0 0 1] /T (Bob) /IT /Replace /Contents (slow red) >> endobj
5 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [40 100 130 120] /QuadPoints [40 120 130 120 40 100 130 100] /C [1 0 0] /T (Bob) /IT /StrikeOutTextEdit /IRT 4 0 R /RT /Group >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut h = harness(move |app| {
app.open_bytes("replace.pdf", None, pdf.to_vec()).expect("opens");
app.close_tab(0);
});
h.run_steps(6);
let caret = comments(&h).into_iter().find(|a| a.subtype == "Caret").expect("the caret");
let p = at(&h, 70.0, 110.0);
click(&mut h, p);
assert_eq!(h.state().views[0].comments.selected, Some((caret.page, caret.index)), "the strikeout picks the proposal");
// Review on #5689 (CodeRabbit): a drag from the strikeout, or from the caret, moves neither:
// the proposal lies on the text it replaces, and the caret alone would leave the strikeout.
let caret_at = at(&h, 140.0, 100.0);
for from in [p, caret_at] {
drag(&mut h, from, from + egui::vec2(60.0, 40.0));
h.run_steps(3);
let now = comments(&h).into_iter().find(|a| a.subtype == "Caret").expect("the caret");
assert_eq!(now.rect, caret.rect, "the caret stays on the text");
}
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), None, "nothing was moved");
}
#[test]
fn replacing_text_strikes_it_and_adds_a_caret() {
let mut h = harness(|app| app.set_option("quick", "replace").unwrap());
// Review on #5689 (CodeRabbit): both parts take the Replace text tool's style.
h.state_mut().comment_prefs.set_color(pdfcraft_ui_egui::comments::CommentTool::ReplaceText, [0.1, 0.6, 0.2]);
drag_pt(&mut h, (20.0, 155.0), (120.0, 155.0));
assert!(h.state().views[0].comments.composer.is_some(), "the composer asks for the replacement");
field(&h, "Replacement text").type_text("slow red");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let c = comments(&h);
let strike = c.iter().find(|a| a.subtype == "StrikeOut").expect("a strikeout");
let caret = c.iter().find(|a| a.subtype == "Caret").expect("a caret");
assert_eq!(caret.contents.as_deref(), Some("slow red"));
assert_eq!((strike.color, caret.color), (Some([0.1, 0.6, 0.2]), Some([0.1, 0.6, 0.2])), "one style for the proposal");
// The caret is the group's primary, the strikeout its `/RT /Group` subordinate (ISO 32000-2 §12.5.6.2).
assert_eq!(strike.in_reply_to, caret.name, "the strikeout is grouped with the caret");
assert!(strike.is_group_member() && !caret.grouped && !caret.is_reply());
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Replace text"));
assert_eq!(h.state().quick_tool, QuickTool::Select);
// Both members are the Replace text tool's, not Strikethrough's or Insert text's (#3947), so
// Make Current Properties Default updates the Replace text tool.
assert_eq!(pdfcraft_ui_egui::comments::tool_for(strike), Some(pdfcraft_ui_egui::comments::CommentTool::ReplaceText));
assert_eq!(pdfcraft_ui_egui::comments::tool_for(caret), Some(pdfcraft_ui_egui::comments::CommentTool::ReplaceText));
// The panel's card is the caret's, the group's primary: it shows the replacement, and
// nothing is named Strikethrough.
h.state_mut().set_option("panel", "comments").unwrap();
h.run_steps(3);
// One card, the caret's, showing the replacement text; sorted by type, its group is named
// Replace text (not Insert text, and no Strikethrough).
assert_eq!(h.query_all_by_label("slow red").count(), 1, "one comment with the replacement text");
h.state_mut().views[0].comments.sort = pdfcraft_ui_egui::comments::SortBy::Type;
h.run_steps(3);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Replace text").is_some(), "the panel names the comment Replace text");
assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Insert text").is_none(), "not Insert text");
assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "Strikethrough").is_none(), "not Strikethrough");
}
#[test]
fn attaching_a_file_as_a_comment() {
let mut h = harness(|app| app.set_option("quick", "attach").unwrap());
h.state_mut().attach_override = Some(("notes.txt".into(), b"remember the milk".to_vec()));
click_pt(&mut h, (250.0, 180.0));
h.run_steps(3);
let c = comments(&h);
assert_eq!((c.len(), c[0].subtype.as_str(), c[0].contents.as_deref()), (1, "FileAttachment", Some("notes.txt")));
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert!(doc.info.attachments.iter().any(|a| a.name == "notes.txt"), "listed in the Attachments panel");
assert_eq!(s.quick_tool, QuickTool::Select);
}
/// One 300×200 page with a paperclip FileAttachment comment at (100, 100)–(120, 124) carrying
/// `inner.pdf`, and no /Contents: the card shows only "Attachment" (#2565). The cross-reference
/// table keeps the reader from mistaking the embedded PDF's objects for the outer ones.
fn attachment_comment_fixture() -> Vec<u8> {
let inner = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(),
"<< /Type /Annot /Subtype /FileAttachment /Rect [100 100 120 124] /Name /Paperclip /T (Reviewer) /M (D:20261014105200) /FS 5 0 R >>".into(),
"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(),
format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> 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.set_option("language", "en").unwrap();
app.open_bytes("annexed.pdf", None, attachment_comment_fixture()).expect("opens");
app.set_option("left", "closed").unwrap();
setup(&mut app);
app
});
h.run_steps(4);
crate::support::settle(&mut h);
h
}
fn open_tab_names(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
let s = h.state();
s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect()
}
#[test]
fn an_attachment_comment_opens_its_file_from_the_page_menu() {
let mut h = attachment_harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "select").unwrap();
});
assert_eq!(comments(&h)[0].subtype, "FileAttachment");
click_pt(&mut h, (110.0, 112.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the attachment comment is selected");
let p = at(&h, 110.0, 112.0);
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
h.get_by_label("Copy text");
// The Attachments panel is closed, so these items can only come from the comment's menu.
h.get_by_label("Save attachment…");
h.get_by_label("Open in a new tab").click();
h.run_steps(4);
assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]);
}
#[test]
fn an_attachment_comment_opens_its_file_from_the_card_menu() {
let mut h = attachment_harness(|app| app.set_option("panel", "comments").unwrap());
h.state_mut().views[0].comments.selected = Some((0, 0));
h.run_steps(2);
h.get_by_label("More").click();
h.run_steps(2);
h.get_by_label("Copy text");
h.get_by_label("Save attachment…");
h.get_by_label("Open in a new tab").click();
h.run_steps(4);
assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]);
}
/// Drag with the middle button (the mouse wheel) in small steps.
fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) {
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE };
h.event(egui::Event::PointerMoved(from));
h.run_steps(1);
h.event(button(from, true));
h.run_steps(1);
for k in 1..=4 {
h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0)));
h.run_steps(1);
}
h.event(button(to, false));
h.run_steps(3);
}
#[test]
fn a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing() {
// egui's drag responses accept any button, so the drawing tools used to draw with the wheel.
let mut h = harness(|app| {
app.set_option("zoom", "400").unwrap();
app.set_option("quick", "ink").unwrap();
});
let from = at(&h, 150.0, 100.0);
let top = h.state().views[0].page_screen_rect(0).expect("page 1 on screen").top();
middle_drag(&mut h, from, from - egui::vec2(0.0, 120.0));
assert!(comments(&h).is_empty(), "{:?}", comments(&h));
let s = h.state();
assert!(!s.session.get(s.views[0].id).unwrap().dirty, "scrolling never edits the PDF");
assert!(!s.views[0].middle_panning(), "releasing the wheel ends the pan");
let moved = s.views[0].page_screen_rect(0).expect("page 1 on screen").top();
if cfg!(target_os = "linux") {
// Linux auto-scrolls while the wheel is held and stops on release.
assert!(!s.views[0].auto_scrolling());
} else {
assert!((moved - (top - 120.0)).abs() < 1.0, "the page follows the pointer: {top} -> {moved}");
}
// The tool still draws with the primary button afterwards.
drag_pt(&mut h, (60.0, 120.0), (200.0, 80.0));
let kinds: Vec<String> = comments(&h).into_iter().map(|a| a.subtype).collect();
assert_eq!(kinds, ["Ink"]);
}
#[test]
fn erasing_part_of_a_drawing() {
let mut h = harness(|app| app.set_option("quick", "ink").unwrap());
drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0));
assert_eq!(comments(&h).len(), 1);
h.state_mut().set_option("quick", "eraser").unwrap();
drag_pt(&mut h, (150.0, 90.0), (150.0, 30.0));
let c = comments(&h);
assert_eq!(c.len(), 1);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Erase"));
// Rubbing along the rest removes the drawing.
drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0));
assert!(comments(&h).is_empty(), "{:?}", comments(&h));
}
#[test]
fn the_eraser_rubs_as_wide_as_its_thickness_setting() {
// #3747: the eraser's size changed nothing; it always rubbed 6 pixels either side.
let stroke_near = |width: f64| {
let mut h = harness(|app| app.set_option("quick", "ink").unwrap());
drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0));
assert_eq!(comments(&h).len(), 1);
let zoom = h.state().views[0].page_screen_rect(0).expect("page 1 on screen").width() / 300.0;
let app = h.state_mut();
app.comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::Eraser, width);
app.set_option("quick", "eraser").unwrap();
// Along the whole drawing, 20 pixels above it: out of reach of the default eraser
// (6 pixels), within reach of the largest (36).
let y = 60.0 + 20.0 / zoom;
drag_pt(&mut h, (30.0, y), (270.0, y));
comments(&h).len()
};
assert_eq!(stroke_near(2.0), 1, "the default eraser passes by");
assert_eq!(stroke_near(12.0), 0, "the largest rubs the drawing out");
}
/// The widths of the eraser preview's strokes drawn in the last frame.
fn eraser_preview_widths(h: &Harness<'static, PdfCraftApp>) -> Vec<f32> {
fn walk(shape: &egui::Shape, out: &mut Vec<f32>) {
let preview = egui::Color32::from_gray(128).gamma_multiply(0.35);
match shape {
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)),
egui::Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(preview) => out.push(p.stroke.width),
_ => {}
}
}
let mut out = Vec::new();
for clipped in &h.output().shapes {
walk(&clipped.shape, &mut out);
}
out
}
#[test]
fn the_eraser_preview_is_as_wide_as_it_rubs() {
// #3747: while the eraser is held, its preview stroke is as wide as what it rubs out
// (twice its radius, three pixels per point of thickness): 12 pixels by default, 72 at 12 pt.
for (width, across) in [(2.0, 12.0), (12.0, 72.0)] {
let mut h = harness(move |app| {
app.comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::Eraser, width);
app.set_option("quick", "eraser").unwrap();
});
let (from, to) = (at(&h, 60.0, 100.0), at(&h, 240.0, 100.0));
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=3 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
assert_eq!(eraser_preview_widths(&h), [across], "{width} pt: the preview while held");
h.drop_at(to);
h.run_steps(3);
assert!(eraser_preview_widths(&h).is_empty(), "{width} pt: gone once released");
}
}
#[test]
fn hovering_a_comment_shows_its_author_and_text() {
// A square comment by Ada over the first page's text.
let pdf = String::from_utf8_lossy(TEXT_FIXTURE).replace("/Contents 5 0 R /Resources", "/Contents 5 0 R /Annots [8 0 R] /Resources").replace(
"trailer",
"8 0 obj << /Type /Annot /Subtype /Square /Rect [50 50 150 120] /C [1 0 0] /T (Ada) /Contents (Check this figure) >> endobj\ntrailer",
);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("hover.pdf", None, pdf.into_bytes()).expect("opens");
app.set_option("left", "closed").unwrap();
// No Comments panel: the comment's text should only appear in the hover popup.
app.set_option("panel", "none").unwrap();
app
});
h.run_steps(4);
crate::support::settle(&mut h);
assert!(h.query_by_label_contains("Check this figure").is_none(), "no popup before hovering");
let p = at(&h, 100.0, 85.0);
h.hover_at(p);
h.run_steps(3);
h.get_by_label_contains("Check this figure");
h.get_by_label_contains("Ada");
// The tip is drawn above every window. A dialog must hide it, or the author floats over the dialog.
h.state_mut().set_option("dialog", "shortcuts").unwrap();
h.hover_at(p);
h.run_steps(3);
assert!(h.query_by_label_contains("Check this figure").is_none(), "a dialog covers the page hover");
}
#[test]
fn preference_hides_author_only_highlight_hover_previews() {
// A highlight annotation with author Ada and empty contents over the first page.
let pdf = String::from_utf8_lossy(TEXT_FIXTURE).replace("/Contents 5 0 R /Resources", "/Contents 5 0 R /Annots [8 0 R] /Resources").replace(
"trailer",
"8 0 obj << /Type /Annot /Subtype /Highlight /Rect [50 50 150 120] /C [1 1 0] /T (Ada) /QuadPoints [50 120 150 120 50 50 150 50] >> endobj\ntrailer",
);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("highlight_hover.pdf", None, pdf.into_bytes()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
app
});
h.run_steps(4);
crate::support::settle(&mut h);
let p = at(&h, 100.0, 85.0);
// By default, author-only hover is shown.
h.hover_at(p);
h.run_steps(3);
h.get_by_label("Ada");
// Enabling preference hides author-only highlight hover.
h.state_mut().set_option("hide-empty-highlight-hover", "true").unwrap();
h.hover_at(p);
h.run_steps(3);
assert!(h.query_by_label("Ada").is_none(), "author-only hover must be suppressed");
}
/// Contributor-original synthetic markup, with the first highlight ending on the next line.
fn markup_hover_fixture(subtype: &str, rotation: u16, extra: &str, refs: &str) -> Vec<u8> {
let content = "BT /F1 14 Tf 20 150 Td (Before the marked words) Tj 0 -40 Td (Marked words then other words) Tj ET\n";
String::from_utf8_lossy(TEXT_FIXTURE)
.replace("/Contents 5 0 R /Resources", &format!("/Contents 5 0 R /Rotate {rotation} /Annots [8 0 R {refs}] /Resources"))
.replace("/Length 55", &format!("/Length {}", content.len()))
.replace("BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET\n", content)
.replace(
"trailer",
&format!(
"8 0 obj << /Type /Annot /Subtype /{subtype} /Rect [20 105 250 165] \
/QuadPoints [120 165 250 165 120 145 250 145 20 125 100 125 20 105 100 105] \
/C [1 1 0] /T (Ada) /Contents (First multiline comment) >> endobj\n{extra}\ntrailer"
),
)
.into_bytes()
}
fn markup_hover_harness(pdf: Vec<u8>, view_rotation: u16) -> Harness<'static, PdfCraftApp> {
// Give the fixture a complete xref so byte comparisons do not measure repair-on-save.
let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(pdf)).expect("synthetic fixture parses");
let pdf = pdfcraft_cos::write_full(&doc, &Default::default()).expect("writes complete fixture");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("markup.pdf", None, pdf).expect("opens synthetic markup");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
// Small enough to show the whole page in every rotation: a point hovered below the window
// is not over the page.
app.set_option("zoom", "100").unwrap();
app.views[0].rotation = view_rotation;
app
});
h.run_steps(4);
crate::support::settle(&mut h);
h
}
/// Includes the document's crop/rotation and the independent Rotate View transform.
fn hover_markup_at(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
let p = {
let app = h.state();
let v = &app.views[0];
let info = &app.session.get(v.id).unwrap().info;
let page = &info.pages[0];
let xf = pdfcraft_ui_egui::canvas::PageXform {
rect: v.page_screen_rect(0).expect("page on screen"),
rot: v.rotation,
pw: page.width,
ph: page.height,
};
let p = xf.user_rect(info, 0, [x, y, x, y]).center();
assert!(v.viewport_rect().contains(p), "user point ({x}, {y}) is on screen, at {p:?}");
p
};
h.hover_at(p);
h.run_steps(3);
}
#[test]
fn multiline_markup_hover_stays_on_marked_lines() {
// #3541: /Rect encloses both lines, including text before/after the selection and the gap.
for subtype in ["Highlight", "Underline", "StrikeOut", "Squiggly"] {
for (page_rotation, view_rotation) in [(0, 0), (90, 0), (180, 0), (270, 0), (0, 90), (0, 180), (0, 270), (90, 270)] {
let pdf = markup_hover_fixture(subtype, page_rotation, "", "");
let mut h = markup_hover_harness(pdf, view_rotation);
let original = h.state().session.get(h.state().views[0].id).unwrap().saved_bytes();
for (x, y, marked) in
[(160.0, 155.0, true), (60.0, 115.0, true), (60.0, 155.0, false), (180.0, 115.0, false), (60.0, 135.0, false), (60.0, 75.0, false)]
{
hover_markup_at(&mut h, x, y);
assert_eq!(
h.query_by_label_contains("First multiline comment").is_some(),
marked,
"{subtype}, page/view rotation {page_rotation}/{view_rotation}, user point ({x}, {y})"
);
}
let app = h.state();
let id = app.views[0].id;
let doc = app.session.get(id).unwrap();
assert!(!doc.dirty, "hovering never edits the document");
assert_eq!(doc.can_undo(), None);
assert_eq!(app.session.save_bytes(id).unwrap().as_slice(), original.as_slice());
}
}
}
#[test]
fn neighboring_markup_hover_changes_to_its_own_comment() {
// #3620: the second highlight is in the first one's /Rect, but outside its marked lines.
let other = "9 0 obj << /Type /Annot /Subtype /Highlight /Rect [160 105 240 125] \
/QuadPoints [160 125 240 125 160 105 240 105] /Contents (Neighbor comment) >> endobj";
let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, other, "9 0 R"), 0);
for (x, y, first) in [(160.0, 155.0, true), (180.0, 115.0, false), (60.0, 115.0, true)] {
hover_markup_at(&mut h, x, y);
assert_eq!(h.query_by_label_contains("First multiline comment").is_some(), first);
assert_eq!(h.query_by_label_contains("Neighbor comment").is_some(), !first);
}
}
#[test]
fn overlapping_markup_hover_shows_the_topmost_comment() {
// A later annotation can also cover a genuinely marked part of the older highlight.
let other = "9 0 obj << /Type /Annot /Subtype /Highlight /Rect [150 145 200 165] \
/QuadPoints [150 165 200 165 150 145 200 145] /Contents (Topmost comment) >> endobj";
let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, other, "9 0 R"), 0);
hover_markup_at(&mut h, 170.0, 155.0);
h.get_by_label_contains("Topmost comment");
assert!(h.query_by_label_contains("First multiline comment").is_none());
hover_markup_at(&mut h, 230.0, 155.0);
h.get_by_label_contains("First multiline comment");
assert!(h.query_by_label_contains("Topmost comment").is_none());
}
#[test]
fn overlapping_markup_hover_uses_annotation_index_with_different_top_edges() {
// Inspection sorts by position, while /Annots determines paint order. The later
// highlight wins the overlap whether its upper edge is above or below the first's.
for top in [175, 160] {
let other = format!(
"9 0 obj << /Type /Annot /Subtype /Highlight /Rect [150 145 200 {top}] \
/QuadPoints [150 {top} 200 {top} 150 145 200 145] /Contents (Topmost comment) >> endobj"
);
let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, &other, "9 0 R"), 0);
hover_markup_at(&mut h, 170.0, 155.0);
h.get_by_label_contains("Topmost comment");
assert!(h.query_by_label_contains("First multiline comment").is_none(), "later annotation with top {top} owns the overlap");
hover_markup_at(&mut h, 230.0, 155.0);
h.get_by_label_contains("First multiline comment");
assert!(h.query_by_label_contains("Topmost comment").is_none());
}
}
#[test]
fn markup_hover_skips_popup_containers() {
// /Popup holds the parent's comment window; it is not a second comment in paint order.
let popup = "9 0 obj << /Type /Annot /Subtype /Popup /Parent 8 0 R /Rect [150 145 200 165] >> endobj";
let mut h = markup_hover_harness(markup_hover_fixture("Highlight", 0, popup, "9 0 R"), 0);
hover_markup_at(&mut h, 170.0, 155.0);
h.get_by_label_contains("First multiline comment");
assert!(h.query_by_label("Popup").is_none());
}
#[test]
fn markup_hover_without_quads_keeps_the_rectangle_fallback() {
for quads in ["", "/QuadPoints [20 105 250 165]"] {
let pdf = markup_hover_fixture("Highlight", 0, "", "");
let pdf = String::from_utf8(pdf)
.unwrap()
.replace("/QuadPoints [120 165 250 165 120 145 250 145 20 125 100 125 20 105 100 105]", quads)
.into_bytes();
let mut h = markup_hover_harness(pdf, 0);
assert!(comments(&h)[0].quads.is_empty());
hover_markup_at(&mut h, 180.0, 115.0);
h.get_by_label_contains("First multiline comment");
hover_markup_at(&mut h, 270.0, 115.0);
assert!(h.query_by_label_contains("First multiline comment").is_none());
}
}
#[test]
fn the_squiggly_tool_marks_selected_text() {
let mut h = harness(|_| {});
h.state_mut().views[0].select_text(0, 4, 8);
assert!(h.state_mut().execute("comment.squiggly"));
h.run_steps(2);
let c = comments(&h);
assert_eq!(c.len(), 1, "{c:?}");
assert_eq!((c[0].subtype.as_str(), c[0].quads.len()), ("Squiggly", 1));
// It sits with the other text markup in the Highlight ▸ flyout, and edits as itself.
assert!(pdfcraft_ui_egui::comments::GROUPS[1].contains(&pdfcraft_ui_egui::comments::CommentTool::Squiggly));
assert_eq!(pdfcraft_ui_egui::comments::tool_for(&c[0]), Some(pdfcraft_ui_egui::comments::CommentTool::Squiggly));
}
#[test]
fn the_opacity_set_for_a_tool_goes_into_its_new_comments() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|app| app.comment_prefs.set_opacity(CommentTool::StrikeOut, 0.5));
// Clamped: an invisible comment can't be found again.
h.state_mut().comment_prefs.set_opacity(CommentTool::Underline, 0.0);
assert!((h.state().comment_prefs.style(CommentTool::Underline).opacity - 0.1).abs() < 1e-9);
h.state_mut().views[0].select_text(0, 4, 8);
assert!(h.state_mut().execute("comment.strikeout"));
h.run_steps(2);
let c = comments(&h);
assert_eq!(c.len(), 1, "{c:?}");
// The new comment carries /CA 0.5 (read back from the document's bytes).
let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.as_ref().clone();
let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
let opacities: Vec<f64> = doc
.object_numbers()
.into_iter()
.filter_map(|n| doc.try_get(n).ok())
.filter_map(|o| o.as_dict().cloned())
.filter(|d| d.name(b"Subtype") == Some(b"StrikeOut"))
.filter_map(|d| d.get(b"CA").and_then(|v| v.as_f64()))
.collect();
assert_eq!(opacities, [0.5]);
// The quick bar offers the control.
h.state_mut().set_option("quick", "strikeout").unwrap();
h.run_steps(2);
h.get_by_label("Opacity");
}
/// #225: comment tools open the Comments panel until the user closes it; then it stays closed,
/// across restarts, until they open it again.
#[test]
fn a_closed_comments_panel_stays_closed_when_picking_comment_tools() {
use pdfcraft_ui_egui::RightPanel;
let mut h = harness(|_| {});
assert_eq!(h.state().right, None);
assert!(h.state_mut().execute("comment.highlight"));
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Comments), "Acrobat opens Comments with the tools");
h.get_by_label("Close").click();
h.run_steps(2);
assert_eq!(h.state().right, None);
for tool in ["select", "comment.underline", "comment.note", "comment.highlight"] {
if tool == "select" {
h.state_mut().quick_tool = QuickTool::Select;
} else {
assert!(h.state_mut().execute(tool));
}
h.run_steps(2);
assert_eq!(h.state().right, None, "{tool} reopened the closed Comments panel");
}
let mut again = PdfCraftApp::new();
again.restore(&h.state().persist());
assert!(again.comments_panel_closed, "the choice survives a restart");
// Opening it again from the rail lets the tools open it once more.
h.get_by_label("Comments").click();
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Comments));
assert!(!h.state().comments_panel_closed);
// What the rail and View menu call; "Bookmarks" labels two controls on screen.
h.state_mut().choose_right_panel(Some(RightPanel::Bookmarks));
h.state_mut().choose_right_panel(None);
h.run_steps(2);
assert!(!h.state().comments_panel_closed, "switching to and closing another panel isn't closing Comments");
assert!(h.state_mut().execute("comment.strikeout"));
h.run_steps(2);
assert_eq!(h.state().right, Some(RightPanel::Comments));
}
/// #1908: Comment properties of a text box has a Font size control; changing it redraws the box
/// with the new /DA size as one undo step, and shapes without text have no such control.
#[test]
fn text_box_properties_change_the_font_size() {
use pdfcraft_engine::{Edit, NewAnnotation, Shape};
let mut h = harness(|_| {});
let tb = NewAnnotation {
page: 0,
shape: Shape::TextBox { rect: [40.0, 600.0, 240.0, 640.0], font_size: 12.0 },
style: Default::default(),
contents: "Hello".into(),
author: "Ada".into(),
};
h.state_mut().apply_edit(Edit::AddAnnotation(tb));
h.run_steps(2);
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.get_by_label("Font size");
{
let d = h.state_mut().comment_props.as_mut().expect("open");
assert_eq!(d.original.font_size, Some(12.0));
d.edited.font_size = Some(30.0);
}
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.comment_props(0, 0).unwrap().font_size, Some(30.0));
assert_eq!(doc.can_undo(), Some("Change comment properties"));
}
#[test]
fn text_box_and_callout_properties_include_fill_and_thickness() {
use pdfcraft_engine::{Edit, LineEnding, NewAnnotation, Shape};
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|_| {});
for (tool, shape) in [
(CommentTool::TextBox, Shape::TextBox { rect: [40.0, 600.0, 240.0, 640.0], font_size: 12.0 }),
(
CommentTool::Callout,
Shape::Callout {
rect: [40.0, 500.0, 240.0, 540.0],
knee: [30.0, 460.0],
point: [10.0, 440.0],
font_size: 12.0,
ending: LineEnding::OpenArrow,
},
),
] {
let index = comments(&h).len();
h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
shape,
style: Default::default(),
contents: "Hello".into(),
author: "Ada".into(),
}));
h.run_steps(2);
h.state_mut().open_comment_props(0, index);
h.run_steps(2);
h.get_by_label("Fill colour");
{
let draft = h.state_mut().comment_props.as_mut().unwrap();
assert!(draft.original.fillable && draft.original.width.is_some());
draft.edited.fill = Some([1.0; 3]);
draft.edited.width = Some(3.0);
}
h.get_by_label("OK").click();
h.run_steps(3);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
let p = doc.comment_props(0, index).unwrap();
assert_eq!((p.fill, p.width), (Some([1.0; 3]), Some(3.0)));
h.state_mut().make_comment_default(0, index);
assert_eq!(h.state().comment_prefs.style(tool).fill, Some([1.0; 3]));
assert_eq!(h.state().comment_prefs.style(tool).width, 3.0);
}
}
#[test]
fn text_comments_can_save_a_zero_width_default() {
use pdfcraft_engine::{Edit, NewAnnotation, Shape};
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|_| {});
let shape = Shape::TextBox { rect: [40.0, 600.0, 240.0, 640.0], font_size: 12.0 };
let mut style = pdfcraft_engine::Style::default_for(&shape);
style.width = 0.0;
h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: "Hello".into(), author: "Ada".into() }));
h.run_steps(2);
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
assert_eq!(h.state().comment_props.as_ref().unwrap().edited.width, Some(0.0));
h.get_by_label("OK").click();
h.run_steps(2);
h.state_mut().make_comment_default(0, 0);
assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).width, 0.0);
h.state_mut().comment_prefs.set_width(CommentTool::Callout, 0.0);
h.state_mut().comment_prefs.set_width(CommentTool::Rectangle, 0.0);
assert_eq!(h.state().comment_prefs.style(CommentTool::Callout).width, 0.0);
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).width, 0.5);
let mut restored = PdfCraftApp::new();
restored.restore(&h.state().persist());
assert_eq!(restored.comment_prefs.style(CommentTool::TextBox).width, 0.0);
assert_eq!(restored.comment_prefs.style(CommentTool::Callout).width, 0.0);
}
/// Moving a rectangle or an oval showed only its blue frame going while the shape stayed where
/// it was until release. Now the comment itself is drawn where it goes (the page beneath shows it
/// where it was until the move is applied), and the pointer frames change nothing.
#[test]
fn a_moved_comment_is_drawn_where_it_goes_before_release() {
let _gpu = crate::gpu();
let mut h = harness(|_| {});
let id = h.state().views[0].id;
for shape in
[pdfcraft_engine::Shape::Rectangle { rect: [30.0, 30.0, 70.0, 70.0] }, pdfcraft_engine::Shape::Oval { rect: [230.0, 30.0, 270.0, 70.0] }]
{
let style = pdfcraft_engine::Style {
color: [1.0, 0.0, 0.0],
opacity: 1.0,
width: 2.0,
fill: Some([1.0, 0.0, 0.0]),
fill_opacity: None,
text_opacity: None,
border_color: None,
border_shape: pdfcraft_engine::BorderShape::Rectangle,
};
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
shape,
style,
contents: String::new(),
author: "Tester".into(),
})));
}
h.state_mut().views[0].comments.selected = None;
crate::support::settle(&mut h);
let red = |v: [u8; 4]| v[0] > 200 && v[1] < 80 && v[2] < 80;
for ((x, y), (tx, ty)) in [((50.0, 50.0), (150.0, 120.0)), ((250.0, 50.0), (200.0, 150.0))] {
let generation = h.state().session.get(id).unwrap().edit_generation();
let (from, to) = (at(&h, x, y), at(&h, tx, ty));
// Beside the pointer, whose arrow is drawn over the page.
let (was, goes) = (at(&h, x - 10.0, y - 10.0), at(&h, tx - 10.0, ty - 10.0));
let px = |pixels: &image::RgbaImage, p: Pos2| pixels.get_pixel(p.x.round() as u32, p.y.round() as u32).0;
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
let mut shown = false;
for _ in 0..100 {
h.run_steps(2);
let pixels = h.render().unwrap();
if red(px(&pixels, goes)) {
shown = true;
assert!(red(px(&pixels, was)), "the page still shows it where it was");
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(shown, "the comment at ({x}, {y}) is drawn under the pointer before release");
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "pointer frames never edit the document");
h.drop_at(to);
h.run_steps(3);
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.can_undo(), Some("Move comment"));
crate::support::settle(&mut h);
let pixels = h.render().unwrap();
assert!(!red(px(&pixels, was)), "once applied it is gone from where it was");
assert!(red(px(&pixels, goes)), "and shown where it went");
}
}
/// Fill & Sign's typed text (a Typewriter FreeText) is drawn where it goes while it is dragged
/// too, not only its frame (#4619).
#[test]
fn moved_fill_and_sign_text_is_drawn_where_it_goes_before_release() {
let _gpu = crate::gpu();
let mut h = harness(|_| {});
h.state_mut().execute("sign.fill.text");
click_pt(&mut h, (40.0, 150.0));
h.event(egui::Event::Text("WWWWWW".into()));
h.run_steps(1);
click(&mut h, pos2(350.0, 850.0)); // Outside the field: Enter now inserts a line break.
h.run_steps(3);
let a = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("WWWWWW")).expect("the typed text");
assert!(a.fill_sign && a.subtype == "FreeText", "{a:?}");
h.state_mut().quick_tool = QuickTool::Select;
h.state_mut().views[0].comments.selected = None;
crate::support::settle(&mut h);
let (x, y) = ((a.rect[0] + a.rect[2]) / 2.0, (a.rect[1] + a.rect[3]) / 2.0);
let (dx, dy) = (120.0, -60.0);
let (from, to) = (at(&h, x, y), at(&h, x + dx, y + dy));
// Ink in the left of the box where it goes, inside its frame and away from the pointer, which
// is drawn over the page.
let goes = egui::Rect::from_two_pos(at(&h, a.rect[0] + dx, a.rect[1] + dy), at(&h, a.rect[2] + dx, a.rect[3] + dy)).shrink(3.0);
let inked = |pixels: &image::RgbaImage| {
(goes.left() as u32..(to.x - 12.0) as u32)
.any(|px| (goes.top() as u32..goes.bottom() as u32).any(|py| pixels.get_pixel(px, py).0[..3].iter().all(|c| *c < 100)))
};
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
let mut shown = false;
for _ in 0..100 {
h.run_steps(2);
if inked(&h.render().unwrap()) {
shown = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(shown, "the typed text is drawn under the pointer before release");
h.drop_at(to);
h.run_steps(3);
}
/// A comment kept upright on a turned page (NoRotate on a /Rotate 90 page) is drawn where it goes
/// while it is dragged too: its layer is cut from the box it is drawn in, not from its /Rect.
#[test]
fn an_upright_comment_on_a_turned_page_is_drawn_where_it_goes_before_release() {
let _gpu = crate::gpu();
let pdf = String::from_utf8_lossy(TEXT_FIXTURE)
.replace("/Contents 5 0 R /Resources", "/Contents 5 0 R /Rotate 90 /Annots [8 0 R] /Resources")
.replace(
"trailer",
"8 0 obj << /Type /Annot /Subtype /Square /Rect [40 60 160 90] /F 20 /C [1 0 0] /IC [1 0 0] /BS << /W 2 >> >> endobj\ntrailer",
)
.into_bytes();
let mut h = harness_with_bytes(pdf, |_| {});
crate::support::settle(&mut h);
let id = h.state().views[0].id;
let drawn = {
let app = h.state();
let v = &app.views[0];
let info = &app.session.get(v.id).unwrap().info;
let a = info.annotations.iter().find(|a| a.subtype == "Square").expect("the square");
assert!(a.no_rotate, "kept upright");
let page = &info.pages[0];
let xf =
pdfcraft_ui_egui::canvas::PageXform { rect: v.page_screen_rect(0).expect("on screen"), rot: v.rotation, pw: page.width, ph: page.height };
xf.user_rect(info, 0, a.shown_rect(page.rotation))
};
let red = |v: [u8; 4]| v[0] > 200 && v[1] < 80 && v[2] < 80;
let px = |pixels: &image::RgbaImage, p: Pos2| pixels.get_pixel(p.x.round() as u32, p.y.round() as u32).0;
// Beside the pointer, which is drawn over the page.
let (from, delta) = (drawn.center(), egui::vec2(160.0, 120.0));
let was = pos2(drawn.left() + 6.0, drawn.center().y);
let goes = was + delta;
assert!(red(px(&h.render().unwrap(), was)), "drawn in its upright box");
let generation = h.state().session.get(id).unwrap().edit_generation();
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + delta * (k as f32 / 4.0));
h.run_steps(1);
}
let mut shown = false;
for _ in 0..100 {
h.run_steps(2);
let pixels = h.render().unwrap();
if red(px(&pixels, goes)) {
shown = true;
assert!(red(px(&pixels, was)), "the page still shows it where it was");
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(shown, "the upright comment is drawn under the pointer before release");
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "pointer frames never edit the document");
h.drop_at(from + delta);
h.run_steps(3);
}
// Contributor-original PDF: a Square, a Circle grouped with it (`/RT /Group`) that keeps its own
// author, text and colour, an ordinary reply and a reply to the Circle.
const GROUP_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
5 0 obj << /Type /Annot /Subtype /Square /Rect [20 20 100 80] /NM (sq-primary) /T (Alice) /Contents (Primary) /C [1 0 0] >> endobj
6 0 obj << /Type /Annot /Subtype /Circle /Rect [180 120 260 180] /NM (circ-sub) /T (Bob) /Contents (Subordinate) /C [0 0 1] /IRT 5 0 R /RT /Group >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [120 20 140 40] /NM (reply) /T (Carol) /Contents (Plain reply) /IRT 5 0 R >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [120 50 140 70] /NM (sub-reply) /T (Dan) /Contents (Reply to the subordinate) /IRT 6 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// ISO 32000-2 §12.5.6.2: a group is one comment, its primary's. The panel lists the Square
/// with both replies (the one to the Circle joins its thread) and not the Circle's own author
/// or text; clicking the Circle on the page selects the group, which is the Square's card.
#[test]
fn a_group_is_one_comment_in_the_panel_and_on_the_page() {
let mut h = harness_with_bytes(GROUP_FIXTURE.to_vec(), |app| app.set_option("panel", "comments").unwrap());
h.get_by_label("Primary");
h.get_by_label("Plain reply");
h.get_by_label("Reply to the subordinate");
assert_eq!(h.query_all_by_label("Subordinate").count(), 0, "the subordinate's own text is ignored");
assert_eq!(h.query_all_by_label("Bob").count(), 0, "the subordinate's own author is ignored");
let p = at(&h, 220.0, 150.0);
click(&mut h, p);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "clicking the Circle selects its group");
h.state_mut().views[0].comments.selected = None;
let p = at(&h, 60.0, 50.0);
click(&mut h, p);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
// #4358 review: a right-click on the Circle selects the group too, so the menu is the
// comment's, and a double-click there opens the group's pop-up window, which is the Square's
// (comment.popups).
h.state_mut().views[0].comments.selected = None;
let p = at(&h, 220.0, 150.0);
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "a right-click on the Circle selects its group");
h.key_press(egui::Key::Escape);
h.run_steps(2);
h.state_mut().views[0].comments.selected = None;
crate::support::input::double_click(&mut h, p);
h.run_steps(4);
let c = comments(&h);
let primary = c.iter().find(|a| a.contents.as_deref() == Some("Primary")).expect("the primary");
assert_eq!(primary.popup.map(|w| w.open), Some(true), "a double-click on the Circle opens its group's window");
assert_eq!(h.state().views[0].comments.popup_rects.len(), 1, "one window");
}
/// As in Acrobat desktop, Delete deletes the group member clicked and keeps the rest of its
/// group, although ISO 32000-2 §12.5.6.2 lists deletion among the operations that act on the
/// entire group. The Circle goes with its reply and the Square stays; the Square goes with its
/// reply and the Circle stays, a comment of its own.
#[test]
fn delete_deletes_the_group_member_clicked() {
let shapes = |h: &Harness<'static, PdfCraftApp>| -> Vec<(String, bool)> {
let mut out: Vec<(String, bool)> = comments(h).iter().filter(|a| a.subtype != "Text").map(|a| (a.subtype.clone(), a.is_comment())).collect();
out.sort();
out
};
let mut h = harness_with_bytes(GROUP_FIXTURE.to_vec(), |_| {});
click_pt(&mut h, (220.0, 150.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the group is selected");
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(shapes(&h), [("Square".to_string(), true)], "the Circle goes, the Square stays");
let texts: Vec<String> = comments(&h).iter().filter_map(|a| a.contents.clone()).collect();
assert!(!texts.iter().any(|c| c == "Reply to the subordinate") && texts.iter().any(|c| c == "Plain reply"), "{texts:?}");
h.state_mut().undo();
h.run_steps(3);
click_pt(&mut h, (60.0, 50.0));
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(shapes(&h), [("Circle".to_string(), true)], "the Square goes, the Circle stays as a comment of its own");
}
/// CodeRabbit on #4358: the member a click on the page picked is forgotten once the selection is
/// made anywhere else. After clicking the Circle, choosing the group's card in the Comments panel
/// selects the comment the card shows, the Square, so Delete deletes the Square, not the Circle
/// the page click remembered.
#[test]
fn choosing_the_groups_card_makes_delete_act_on_the_card_not_the_member_clicked() {
let shapes = |h: &Harness<'static, PdfCraftApp>| -> Vec<String> {
let mut out: Vec<String> = comments(h).iter().filter(|a| a.subtype != "Text").map(|a| a.subtype.clone()).collect();
out.sort();
out
};
let mut h = harness_with_bytes(GROUP_FIXTURE.to_vec(), |app| app.set_option("panel", "comments").unwrap());
click_pt(&mut h, (220.0, 150.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the Circle's click selects the group");
// The selected card's header row, with its author.
h.get_by_label("Alice").click();
h.run_steps(3);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the card selects the same group");
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(shapes(&h), ["Circle".to_string()], "the card's comment, the Square, goes; the Circle stays");
}
/// One 300×200 page with a callout: text box [150 100 250 140], knee (120, 120) on the box's
/// left, arrow point (60, 60).
const CALLOUT_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /FreeText /IT /FreeTextCallout /Rect [50 50 250 140] /RD [100 50 0 0]
/CL [60 60 120 120 150 120] /LE /OpenArrow /DA (/Helv 10 Tf 0 g) /Contents (Note) /F 4 >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn callout(h: &Harness<'static, PdfCraftApp>) -> Annotation {
comments(h).into_iter().find(|a| a.subtype == "FreeText").expect("the callout")
}
fn near_pt(p: [f32; 2], x: f32, y: f32) -> bool {
(p[0] - x).abs() < 1.5 && (p[1] - y).abs() < 1.5
}
fn last_edit(h: &Harness<'static, PdfCraftApp>) -> Option<String> {
h.state().session.get(h.state().views[0].id).unwrap().can_undo().map(str::to_owned)
}
/// #1152: a selected callout's three parts change on their own, as in Acrobat ("Callout text
/// boxes have three parts: a text box, a knee line, and an end-point line. You can resize each
/// part by dragging a handle. The knee line can be resized in one direction only", Acrobat DC
/// Help, 11/28/2019, p. 357).
#[test]
fn a_callouts_arrow_knee_and_text_box_are_dragged_on_their_own() {
let mut h = harness_with_bytes(CALLOUT_FIXTURE.to_vec(), |app| app.set_option("quick", "select").unwrap());
let c = callout(&h);
assert_eq!((c.points.clone(), c.text_box), (vec![[60.0, 60.0], [120.0, 120.0]], Some([150.0, 100.0, 250.0, 140.0])));
click_pt(&mut h, (200.0, 120.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, c.index)));
// The knee line is level, so the knee moves sideways only, and not into the box.
drag_pt(&mut h, (120.0, 120.0), (95.0, 160.0));
let c = callout(&h);
assert!(near_pt(c.points[1], 95.0, 120.0) && near_pt(c.points[0], 60.0, 60.0), "{:?}", c.points);
assert_eq!(last_edit(&h).as_deref(), Some("Reshape comment"));
drag_pt(&mut h, (95.0, 120.0), (200.0, 120.0));
assert!(near_pt(callout(&h).points[1], 138.0, 120.0), "stopped 12 pt short of the box: {:?}", callout(&h).points);
drag_pt(&mut h, (138.0, 120.0), (110.0, 120.0));
// The arrow point moves alone.
drag_pt(&mut h, (60.0, 60.0), (40.0, 30.0));
let c = callout(&h);
assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 110.0, 120.0), "{:?}", c.points);
assert_eq!(c.text_box, Some([150.0, 100.0, 250.0, 140.0]));
// The text box resizes from its own handles (not /Rect's); its left side, where the line meets
// it, stays, so the line doesn't change.
drag_pt(&mut h, (250.0, 100.0), (270.0, 80.0));
let c = callout(&h);
let tb = c.text_box.expect("a text box");
assert!((tb[0] - 150.0).abs() < 1.5 && (tb[1] - 80.0).abs() < 1.5 && (tb[2] - 270.0).abs() < 1.5 && (tb[3] - 140.0).abs() < 1.5, "{tb:?}");
assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 110.0, 120.0), "{:?}", c.points);
assert_eq!(last_edit(&h).as_deref(), Some("Resize comment"));
// Dragged from inside, the box moves alone: the arrow stays, the knee comes along.
drag_pt(&mut h, (200.0, 120.0), (220.0, 130.0));
let c = callout(&h);
let tb = c.text_box.expect("a text box");
assert!((tb[0] - 170.0).abs() < 1.5 && (tb[3] - 150.0).abs() < 1.5, "{tb:?}");
assert!(near_pt(c.points[0], 40.0, 30.0) && near_pt(c.points[1], 130.0, 130.0), "{:?}", c.points);
// Dragged by its leader line, the whole callout moves.
drag_pt(&mut h, (85.0, 75.0), (95.0, 75.0));
let c = callout(&h);
assert!(near_pt(c.points[0], 50.0, 30.0) && near_pt(c.points[1], 140.0, 130.0), "{:?}", c.points);
assert_eq!(last_edit(&h).as_deref(), Some("Move comment"));
// Each drag was one undo step.
h.state_mut().undo();
h.run_steps(2);
assert!(near_pt(callout(&h).points[0], 40.0, 30.0));
}
/// #2137 review: a pop-up is edited under comment permission, not change permission: in a
/// document whose security allows comments but not changes, an open pop-up still takes typing
/// and closes.
#[test]
fn a_pop_up_is_editable_where_comments_are_allowed_but_changes_are_not() {
let plain = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /T (Ada) /Popup 5 0 R >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Parent 4 0 R /Rect [60 60 260 160] /Open true >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(plain.to_vec())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
// Everything but bit 4, modifying the document.
permissions: -1 ^ 8,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
let mut h = harness(move |app| {
app.open_bytes("restricted.pdf", None, bytes).expect("opens");
// Only the restricted document, so it is the first view.
app.close_tab(0);
});
h.run_steps(6);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
field(&h, "Add a comment");
let close = h.query_all_by_label("Close").find(|n| window.contains(n.rect().center())).expect("the pop-up's ×");
close.click();
h.run_steps(4);
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.popup.map(|p| p.open), Some(false), "closed");
}
/// #2137 review: a pop-up lists the comment's text replies only, as the Comments panel does. A
/// review status (`/StateModel /Review`) and a checkmark (`/StateModel /Marked`, the private
/// reply Mark writes) are replies too, but not ones to read in the thread.
#[test]
fn a_pop_up_lists_text_replies_but_not_status_or_checkmark_replies() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /T (Ada) /NM (note-1) /Contents (Please check) /Popup 5 0 R >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Parent 4 0 R /Rect [60 40 260 160] /Open true >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Bob) /NM (reply-1) /IRT 4 0 R /Contents (Agreed with this) >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Bob) /NM (reply-2) /IRT 4 0 R /State /Accepted /StateModel /Review /Contents (Accepted set by Bob) >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 150 40 170] /F 32 /T (Ada) /NM (reply-3) /IRT 4 0 R /State /Marked /StateModel /Marked /Contents (Marked set by Ada) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut h = harness(move |app| {
app.open_bytes("thread.pdf", None, pdf.to_vec()).expect("opens");
app.close_tab(0);
});
h.run_steps(6);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
let in_window = |text: &str| h.query_all_by(|n| shows_text(n, text)).any(|n| window.contains(n.rect().center()));
assert!(in_window("Agreed with this"), "the text reply");
assert!(!in_window("Accepted set by Bob"), "not the review status");
assert!(!in_window("Marked set by Ada"), "not the checkmark");
}
/// A Replace Text proposal: a strikeout and a caret grouped by `/IRT` and `/RT /Group`, with the
/// pop-up `open`. `caret_first` writes the caret as the group's primary, holding the replacement,
/// with the strikeout grouped under it; otherwise the strikeout is the primary and the
/// replacement is on the caret grouped under it, as PdfCraft used to write it.
fn replace_text_pdf(caret_first: bool, open: bool) -> Vec<u8> {
let primary = if caret_first { 4 } else { 5 };
let strike = format!(
"<< /Type /Annot /Subtype /StrikeOut /Rect [40 100 140 120] /QuadPoints [40 120 140 120 40 100 140 100] /C [1 0 0] /T (Ada) /IT /StrikeOutTextEdit{} >>",
if caret_first { " /IRT 4 0 R /RT /Group" } else { " /Popup 6 0 R" }
);
let caret = format!(
"<< /Type /Annot /Subtype /Caret /Rect [135 95 145 105] /C [0 0 1] /T (Bob) /Contents (slow red){} >>",
if caret_first { " /IT /Replace /Popup 6 0 R" } else { " /IRT 5 0 R /RT /Group" }
);
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 4 0 R 6 0 R] >> endobj
4 0 obj {caret} endobj
5 0 obj {strike} endobj
6 0 obj << /Type /Annot /Subtype /Popup /Parent {primary} 0 R /Rect [160 20 290 150] /Open {open} >> endobj
trailer << /Root 1 0 R >>
%%EOF",
)
.into_bytes()
}
/// A Replace Text proposal's window shows the replacement as the comment's text, and the other
/// member of the group is part of the comment, not a reply panel (ISO 32000-2 §12.5.6.2: a group's
/// members are one unit). Both layouts: the caret as the primary, and the strikeout as the primary
/// with the replacement on the grouped caret, as `main` writes it (#5688).
#[test]
fn a_replace_text_window_shows_the_replacement_as_its_text_not_as_a_reply() {
for caret_first in [true, false] {
let pdf = replace_text_pdf(caret_first, true);
let mut h = harness(move |app| {
app.open_bytes("replace.pdf", None, pdf).expect("opens");
app.close_tab(0);
});
h.run_steps(6);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
let boxes: Vec<String> = h
.query_all_by(|n| n.role() == Role::MultilineTextInput)
.filter(|n| window.contains(n.rect().center()))
.map(|n| n.value().unwrap_or_default())
.collect();
assert_eq!(boxes, ["slow red"], "caret first: {caret_first}: one box, the replacement");
}
}
/// A locked Replace Text proposal, as `main` writes it (the strikeout primary and locked, the
/// replacement on the grouped caret), shows the replacement in its window read-only: locking the
/// comment locks the text the window shows for it.
#[test]
fn a_locked_replace_text_window_shows_the_replacement_read_only() {
let pdf = String::from_utf8(replace_text_pdf(false, true))
.unwrap()
.replace("/IT /StrikeOutTextEdit /Popup", "/IT /StrikeOutTextEdit /F 128 /Popup")
.into_bytes();
let mut h = harness(move |app| {
app.open_bytes("replace.pdf", None, pdf).expect("opens");
app.close_tab(0);
});
h.run_steps(6);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the open pop-up shows");
let inside = |n: &egui_kittest::Node<'_>| window.contains(n.rect().center());
let boxes = h.query_all_by(|n| n.role() == Role::MultilineTextInput).filter(inside).count();
assert_eq!(boxes, 0, "no box to type in while the comment is locked");
assert!(h.query_all_by_label("slow red").any(|n| inside(&n)), "the replacement is shown");
}
/// A double-click on a Replace Text proposal opens the group's one window, the primary's, in
/// either layout. (A double-click on the member grouped under it opens the same
/// window once members are picked on the page; main hit-tests only the primary.)
#[test]
fn a_double_click_on_a_replace_text_opens_its_one_window() {
for (caret_first, on_caret) in [(true, true), (false, false)] {
let pdf = replace_text_pdf(caret_first, false);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("replace.pdf", None, pdf.clone()).expect("opens");
app.set_option("left", "closed").unwrap();
app
});
h.run_steps(4);
crate::support::settle(&mut h);
h.run_steps(45);
let target = if on_caret { (140.0, 98.0) } else { (60.0, 110.0) };
let p = at(&h, target.0, target.1);
h.hover_at(p);
h.run_steps(1);
for _ in 0..2 {
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(1);
}
h.run_steps(4);
let primary_subtype = if caret_first { "Caret" } else { "StrikeOut" };
let c = comments(&h);
let primary = c.iter().find(|a| a.subtype == primary_subtype).expect("the primary");
assert_eq!(primary.popup.map(|w| w.open), Some(true), "caret first: {caret_first}, on the caret: {on_caret}: the group's window opened");
assert_eq!(h.state().views[0].comments.popup_rects.len(), 1, "one window");
}
}
/// A double-click on a sticky note opens its pop-up window on the page, where its text is read
/// and edited; the title bar drags it (saved as the pop-up's place, one undo step); × closes it
/// (comment.popups).
#[test]
fn a_double_click_opens_a_notes_pop_up_which_drags_and_closes() {
// Frames a sixtieth apart, so two clicks land within egui's double-click time.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("author", "Tester").unwrap();
app.set_option("quick", "note").unwrap();
app
});
h.run_steps(4);
crate::support::settle(&mut h);
click_pt(&mut h, (60.0, 160.0));
field(&h, "Add a comment").type_text("Please check");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.popup.map(|p| p.open), Some(false));
assert!(h.state().views[0].comments.popup_rects.is_empty(), "closed pop-ups are not shown");
// Well after the Post click, so egui does not count the next clicks with it.
h.run_steps(45);
// Double-click the icon.
let icon = at(&h, (note.rect[0] + note.rect[2]) / 2.0, (note.rect[1] + note.rect[3]) / 2.0);
h.hover_at(icon);
h.run_steps(1);
for _ in 0..2 {
h.drag_at(icon);
h.run_steps(1);
h.drop_at(icon);
h.run_steps(1);
}
h.run_steps(4);
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.popup.map(|p| p.open), Some(true), "the pop-up opened");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Open comment pop-up"));
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is on screen");
// Its title names the kind of comment and its author.
h.get_by_label("Note – Tester");
// Drag the title bar 80 px right and 40 px down: the pop-up's place on the page moves.
let before = note.popup.unwrap().rect;
let bar = pos2(window.left() + 30.0, window.top() + 12.0);
drag(&mut h, bar, bar + egui::vec2(80.0, 40.0));
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
let after = note.popup.unwrap().rect;
// 80 × 40 screen pixels is about 20 × 10 pt at this zoom.
assert!(after[0] > before[0] + 10.0 && after[1] < before[1] - 5.0, "moved right and down on the page: {before:?} -> {after:?}");
assert!(((after[2] - after[0]) - (before[2] - before[0])).abs() < 0.5, "the same width");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up"));
// Close it.
h.get_by_label("Close").click();
h.run_steps(4);
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.popup.map(|p| p.open), Some(false));
assert!(h.state().views[0].comments.popup_rects.is_empty());
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Close comment pop-up"));
}
// Contributor-original PDF: two notes sharing one /NM (as PyMuPDF names them), both with an open
// pop-up; the second has a reply by reference and a reply to that reply, which has no /NM.
const POPUPS_SHARING_A_NAME: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /NM (fitz-A0) /T (Alpha) /Contents (First note) /Popup 5 0 R /Open true >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 110 160 190] /Parent 4 0 R /Open true >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A0) /T (Beta) /Contents (Second note) /Popup 7 0 R /Open true >> endobj
7 0 obj << /Type /Annot /Subtype /Popup /Rect [180 -250 290 90] /Parent 6 0 R /Open true >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply by reference) >> endobj
9 0 obj << /Type /Annot /Subtype /Text /Rect [20 40 40 60] /IRT 8 0 R /T (Delta) /Contents (Reply to the reply) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// A pop-up reads its comment's thread by `/IRT` reference, as the Comments panel does: the
/// replies show once, in the second note's window only, at any depth, though both notes share
/// one `/NM`.
#[test]
fn a_pop_up_lists_its_replies_by_reference_when_names_repeat() {
let h = harness_with_bytes(POPUPS_SHARING_A_NAME.to_vec(), |_| {});
let windows = h.state().views[0].comments.popup_rects.clone();
assert_eq!(windows.len(), 2, "both notes' pop-ups are open: {windows:?}");
let (_, second) = windows.iter().find(|(key, _)| *key == (0, 2)).copied().expect("the second note's pop-up");
for text in ["Reply by reference", "Reply to the reply"] {
// The Comments panel lists the thread too; count only what the windows show.
let shown: Vec<Pos2> =
h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect().center()).filter(|c| windows.iter().any(|(_, w)| w.contains(*c))).collect();
assert_eq!(shown.len(), 1, "{text:?} shows in one window: {shown:?}");
assert!(shown.iter().all(|c| second.contains(*c)), "{text:?} is in the second note's window");
}
}
// Contributor-original PDF: one blue note with its pop-up open beside it.
const BLUE_NOTE_WITH_OPEN_POP_UP: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [0 0 1] /T (Alpha) /Contents (A note) /Popup 5 0 R /Open true >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Rect [120 20 260 120] /Parent 4 0 R /Open true >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// What the connectors of comments in colour `color` painted in the last frame: each wedge
/// (its point, the two ends of its base, its colour: `color`, faded).
fn connectors(h: &Harness<'static, PdfCraftApp>, color: egui::Color32) -> Vec<(Pos2, [Pos2; 2], egui::Color32)> {
fn walk(shape: &egui::Shape, color: egui::Color32, out: &mut Vec<(Pos2, [Pos2; 2], egui::Color32)>) {
match shape {
egui::Shape::Mesh(m) => {
if let [tip, b0, b1] = m.vertices.as_slice()
&& tip.color.a() > 0
&& tip.color.a() < 255
&& tip.color.to_opaque() == color
{
out.push((tip.pos, [b0.pos, b1.pos], tip.color));
}
}
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, color, out)),
_ => {}
}
}
let mut out = Vec::new();
for clipped in &h.output().shapes {
walk(&clipped.shape, color, &mut out);
}
out
}
/// A comment's connector (comment.connector-lines): a faint wedge in the comment's colour from
/// the edge of the comment nearest its open pop-up to a base on the window's edge around the
/// point nearest the comment; shown
/// while the pointer is on the comment or on the window or the comment is selected, and not
/// otherwise.
#[test]
fn a_connector_line_joins_a_comment_to_its_open_pop_up_while_the_pointer_is_on_either() {
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {});
let blue = egui::Color32::from_rgb(0, 0, 255);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
let mark = egui::Rect::from_two_pos(at(&h, 20.0, 180.0), at(&h, 40.0, 160.0));
let on_edge = |r: egui::Rect, p: Pos2| {
r.expand(1.0).contains(p) && [p.x - r.left(), r.right() - p.x, p.y - r.top(), r.bottom() - p.y].iter().any(|d| d.abs() < 1.0)
};
// Away from both: no connector.
h.hover_at(at(&h, 280.0, 190.0));
h.run_steps(2);
assert!(connectors(&h, blue).is_empty(), "no connector with the pointer elsewhere");
// On the note or on its window: one wedge, from the note's edge to the window's.
for (what, pointer) in [("the note", mark.center()), ("the window", window.center())] {
h.hover_at(pointer);
h.run_steps(2);
let found = connectors(&h, blue);
assert_eq!(found.len(), 1, "one connector with the pointer on {what}: {found:?}");
let (tip, [b0, b1], base) = found[0];
assert!(on_edge(mark, tip), "its point is on the note's edge {mark:?}: {tip:?}");
// The nearest point of the note: its lower right corner.
assert!(tip.distance(mark.right_bottom()) < 1.0, "{tip:?}");
assert!(on_edge(window, b0) && on_edge(window, b1), "its base is on the window's edge {window:?}: {b0:?} {b1:?}");
// The base lies along one side, around the window's upper left corner, and is wider than a line.
assert!((b0.x - b1.x).abs() < 0.5 || (b0.y - b1.y).abs() < 0.5, "along one side: {b0:?} {b1:?}");
assert!(b0.distance(b1) >= 10.0, "a base, not a line: {b0:?} {b1:?}");
assert!(b0.distance(window.left_top()).min(b1.distance(window.left_top())) < 1.0, "at the corner: {b0:?} {b1:?}");
// The wedge is faint, in the comment's hue.
assert!(base.a() < 255 && base.a() > 0 && base.to_opaque() == blue, "the wedge is faint blue: {base:?}");
}
// Selected, the note keeps its connector with the pointer elsewhere.
h.state_mut().views[0].comments.selected = Some((0, 0));
h.hover_at(at(&h, 280.0, 190.0));
h.run_steps(2);
assert_eq!(connectors(&h, blue).len(), 1, "a selected comment shows its connector");
}
/// A note with an open pop-up and a thread of replies: "Second" and "Third" answer the note,
/// "Second, first answer" answers "Second" (`/IRT` to a reply).
const NOTE_WITH_A_THREAD: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /C [1 0.85 0.2] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 -250 280 150] /Parent 4 0 R /Open true >> endobj
6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second) /IRT 4 0 R /F 30 >> endobj
7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Second, first answer) /IRT 6 0 R /F 30 >> endobj
8 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Third) /IRT 4 0 R /F 30 >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// A pop-up window (comment.popup-windows) is laid out as the readers' are: a title bar with the
/// comment's kind and author; the comment's text; its replies as panels in thread order, an
/// answer to a reply inside that reply's panel; and a Reply button under each, which opens a box
/// that posts an answer to that comment or reply.
#[test]
fn a_pop_up_threads_its_replies_and_takes_a_new_one() {
let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {});
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
h.get_by_label("Note – Alpha");
// Each reply's text box, inside the window.
let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| {
let found: Vec<egui::Rect> = h.query_all_by(|n| shows_text(n, text)).map(|n| n.rect()).filter(|r| window.contains_rect(*r)).collect();
assert_eq!(found.len(), 1, "{text:?} once in the window: {found:?}");
found[0]
};
let (second, answer, third) = (rect(&h, "Second"), rect(&h, "Second, first answer"), rect(&h, "Third"));
assert!(second.bottom() < answer.top() && answer.bottom() < third.top(), "thread order: {second:?} {answer:?} {third:?}");
assert!(
answer.left() > second.left() + 4.0 && answer.right() < second.right() - 4.0,
"the answer sits inside its reply's panel: {second:?} {answer:?}"
);
assert!((third.left() - second.left()).abs() < 1.0, "replies to the note line up: {second:?} {third:?}");
// The Reply buttons, top to bottom: the note's, then each reply's.
let replies = |h: &Harness<'static, PdfCraftApp>| {
let mut b: Vec<egui::Rect> =
h.query_all_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("Reply")).map(|n| n.rect()).collect();
b.sort_by(|x, y| x.top().total_cmp(&y.top()));
b
};
assert_eq!(replies(&h).len(), 4, "one under the note and each reply");
// The note's Reply: Enter starts a new line in the box, as in the readers' pop-ups; Post posts.
let before = comments(&h).len();
let at = replies(&h)[0].center();
click(&mut h, at);
field(&h, "Add a reply").type_text("Fourth");
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert_eq!(comments(&h).len(), before, "Enter does not post");
h.event(egui::Event::Text("line two".into()));
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let all = comments(&h);
let note = all.iter().find(|a| a.contents.as_deref() == Some("First")).expect("the note");
let reply = all.iter().find(|a| a.contents.as_deref() == Some("Fourth\nline two")).expect("the reply was added, on two lines");
assert_eq!(reply.parent, Some((note.page, note.index)));
assert_eq!(reply.author.as_deref(), Some("Tester"));
assert!(h.state().views[0].comments.popup_reply.is_none(), "the box closes");
// A reply's own Reply answers that reply; Ctrl+Enter posts too, as in the comment composer.
let second_key = all.iter().find(|a| a.contents.as_deref() == Some("Second")).map(|a| (a.page, a.index)).expect("the reply");
let at = replies(&h)[1].center();
click(&mut h, at);
field(&h, "Add a reply").type_text("Fifth");
h.run_steps(2);
h.key_press_modifiers(egui::Modifiers::CTRL, egui::Key::Enter);
h.run_steps(3);
let fifth = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("Fifth")).expect("Ctrl+Enter posts");
assert_eq!(fifth.parent, Some(second_key), "it answers the reply");
// Cancel closes the box without posting.
let before = comments(&h).len();
let at = replies(&h)[0].center();
click(&mut h, at);
field(&h, "Add a reply").type_text("Never mind");
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(comments(&h).len(), before);
assert!(h.state().views[0].comments.popup_reply.is_none());
}
/// A note with an open pop-up and `replies` replies to it.
fn note_with_replies(replies: usize) -> Vec<u8> {
let annots: String = (0..replies + 2).map(|i| format!("{} 0 R ", i + 4)).collect();
let mut pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [{annots}] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (First) /Popup 5 0 R /Open true >> endobj
5 0 obj << /Type /Annot /Subtype /Popup /Rect [60 10 280 150] /Parent 4 0 R /Open true >> endobj
"
);
for i in 0..replies {
pdf.push_str(&format!(
"{} 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Beta) /Contents (Reply {i}) /IRT 4 0 R /F 30 >> endobj\n",
i + 6
));
}
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
pdf.into_bytes()
}
/// A pop-up window keeps its height however long its thread grows: the text and replies scroll
/// inside it, as in the readers' pop-ups, rather than the window growing down the screen.
#[test]
fn a_long_thread_scrolls_inside_its_pop_up() {
let height = |replies: usize| {
let h = harness_with_bytes(note_with_replies(replies), |_| {});
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
window.height()
};
let (short, long) = (height(1), height(25));
assert!((short - long).abs() < 2.0, "the same height with 1 reply and with 25: {short} {long}");
assert!(long < 600.0, "within the screen: {long}");
}
/// Dragging a pop-up window's lower right corner resizes it, and the new size is saved with the
/// comment (the pop-up's `/Rect`) at the same place, in one undo step; the window shows it.
#[test]
fn a_pop_ups_corner_resizes_it() {
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {});
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
let before = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect;
let corner = window.right_bottom() - egui::vec2(3.0, 3.0);
drag(&mut h, corner, corner + egui::vec2(80.0, 60.0));
h.run_steps(3);
let after = comments(&h).into_iter().find(|a| a.subtype == "Text").and_then(|a| a.popup).expect("its pop-up").rect;
// Wider and taller on the page, its upper left corner where it was.
assert!(after[2] - after[0] > before[2] - before[0] + 5.0, "wider: {before:?} -> {after:?}");
assert!(after[3] - after[1] > before[3] - before[1] + 5.0, "taller: {before:?} -> {after:?}");
assert!((after[0] - before[0]).abs() < 0.5 && (after[3] - before[3]).abs() < 0.5, "the same place: {before:?} -> {after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move comment pop-up"));
let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open");
// The window shows the saved size, in points on screen whatever the zoom.
assert!((resized.width() - (after[2] - after[0])).abs() < 3.0, "as wide as saved: {after:?} {resized:?}");
assert!((resized.height() - (after[3] - after[1])).abs() < 3.0, "as tall as saved: {after:?} {resized:?}");
assert!(resized.width() > window.width() + 60.0 && resized.height() > window.height() + 40.0, "bigger on screen: {window:?} -> {resized:?}");
assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen");
}
/// A pop-up window's box holding `text` (a comment's or a reply's).
fn shows_text(n: &egui_kittest::kittest::AccessKitNode<'_>, text: &str) -> bool {
n.role() == Role::MultilineTextInput && n.value().as_deref() == Some(text)
}
/// On a page turned a quarter (View ▸ Rotate View), a resized pop-up window stays where it is on
/// screen and shows the size it was dragged to.
#[test]
fn a_pop_up_resized_on_a_turned_page_stays_in_place() {
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.views[0].rotation = 90);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
let corner = window.right_bottom() - egui::vec2(3.0, 3.0);
drag(&mut h, corner, corner + egui::vec2(80.0, 60.0));
h.run_steps(3);
let (_, resized) = h.state().views[0].comments.popup_rects.first().copied().expect("still open");
assert!((resized.left_top() - window.left_top()).length() < 1.0, "in the same place on screen: {window:?} -> {resized:?}");
assert!((resized.width() - window.width() - 80.0).abs() < 3.0, "80 px wider: {window:?} -> {resized:?}");
// Taller by the drag, less what its text had already stretched it.
assert!(resized.height() > window.height() + 50.0, "taller: {window:?} -> {resized:?}");
}
/// #2137 review: the composer's Post, clicked while a pop-up's box holds unsaved text, also
/// saves that text as the box loses the focus. Both land in one frame and both are kept.
#[test]
fn posting_from_the_composer_keeps_a_pop_ups_unsaved_text() {
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |app| app.set_option("quick", "note").unwrap());
// Low on the left, so the composer stays clear of the window.
click_pt(&mut h, (30.0, 40.0));
// The composer opens with its editor focused.
h.run_steps(2);
h.event(egui::Event::Text("New note".into()));
h.run_steps(2);
let box_at = h.query_all_by(|n| shows_text(n, "A note")).next().expect("the pop-up's box").rect().right_center() - egui::vec2(4.0, 0.0);
click(&mut h, box_at);
h.event(egui::Event::Text(" edited".into()));
h.run_steps(2);
assert!(h.state().views[0].comments.composer.is_some(), "the composer is still open");
let post = h.get_by_label("Post").rect().center();
click(&mut h, post);
h.run_steps(3);
let all = comments(&h);
assert!(
all.iter().any(|a| a.contents.as_deref() == Some("New note")),
"the composer's note: {:?}",
all.iter().map(|a| &a.contents).collect::<Vec<_>>()
);
assert!(
all.iter().any(|a| a.contents.as_deref() == Some("A note edited")),
"the pop-up's text: {:?}",
all.iter().map(|a| &a.contents).collect::<Vec<_>>()
);
}
/// #2137 review: what a pop-up holds for a comment (a reply being typed, unsaved text) follows
/// that comment when a deletion moves it, and is dropped when an undo or a page edit leaves its
/// (page, index) naming another; other edits keep it.
#[test]
fn a_pop_ups_unsaved_work_follows_its_comment_or_is_dropped() {
use pdfcraft_engine::Edit;
let mut h = harness_with_bytes(NOTE_WITH_A_THREAD.to_vec(), |_| {});
let key = |h: &Harness<'static, PdfCraftApp>, text: &str| {
comments(h).into_iter().find(|a| a.contents.as_deref() == Some(text)).map(|a| (a.page, a.index)).expect(text)
};
let (second, third) = (key(&h, "Second"), key(&h, "Third"));
h.state_mut().views[0].comments.popup_reply = Some((third.0, third.1, "draft".into()));
// Another edit, on another comment: the draft stays.
assert!(h.state_mut().apply_edit(Edit::SetAnnotationContents { page: second.0, index: second.1, text: "Second!".into() }));
h.run_steps(2);
assert_eq!(h.state().views[0].comments.popup_reply, Some((third.0, third.1, "draft".into())));
// Deleting "Second" (and its answer) moves "Third" up: the draft goes with it.
assert!(h.state_mut().apply_edit(Edit::DeleteAnnotation { page: second.0, index: second.1 }));
h.run_steps(2);
let moved = key(&h, "Third");
assert_ne!(moved, third, "Third moved up");
assert_eq!(h.state().views[0].comments.popup_reply, Some((moved.0, moved.1, "draft".into())), "the draft is still Third's");
// Undo puts the thread back: the draft is dropped rather than left on another reply.
h.state_mut().undo();
h.run_steps(2);
assert_eq!(h.state().views[0].comments.popup_reply, None);
// A page inserted before it: the comment's (page, index) now names nothing of it.
let note = key(&h, "First");
h.state_mut().views[0].comments.popup_text = Some((note.0, note.1, "unsaved".into()));
assert!(h.state_mut().apply_edit(Edit::InsertBlankPage { at: 0, width: 300.0, height: 200.0 }));
h.run_steps(2);
assert_eq!(h.state().views[0].comments.popup_text, None);
}
/// #2137 review: text typed into a pop-up and not yet saved is saved when × closes the window:
/// the click takes the focus from the box and closes it in one frame, one undo step for both.
#[test]
fn closing_a_pop_up_saves_its_typed_text() {
let mut h = harness_with_bytes(BLUE_NOTE_WITH_OPEN_POP_UP.to_vec(), |_| {});
let box_at = h.query_all_by(|n| shows_text(n, "A note")).next().expect("the pop-up's box").rect().right_center() - egui::vec2(4.0, 0.0);
click(&mut h, box_at);
h.event(egui::Event::Text(" for you".into()));
h.run_steps(2);
let (_, window) = h.state().views[0].comments.popup_rects.first().copied().expect("the pop-up is open");
let close = h.query_all_by_label("Close").map(|n| n.rect().center()).find(|c| window.contains(*c)).expect("the window's ×");
click(&mut h, close);
h.run_steps(3);
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!(note.contents.as_deref(), Some("A note for you"), "the typed text is saved");
assert_eq!(note.popup.map(|p| p.open), Some(false), "and the window closed");
assert!(h.state().views[0].comments.popup_text.is_none());
h.state_mut().undo();
h.run_steps(2);
let note = comments(&h).into_iter().find(|a| a.subtype == "Text").expect("the note");
assert_eq!((note.contents.as_deref(), note.popup.map(|p| p.open)), (Some("A note"), Some(true)), "one undo step for both");
}
/// The selection bar stays inside the pages' area: its place was worked out for a 520 pt bar,
/// so longer labels (German here) pushed it over the side panel next to a selection on the right.
#[test]
fn the_selection_bar_stays_inside_the_pages_area() {
let mut h = harness(|app| app.set_option("language", "de").unwrap());
// Narrower than the harness, as with a side panel open.
h.set_size(egui::vec2(1100.0, 900.0));
h.run_steps(4);
// The rightmost glyph of "The quick brown fox": the bar opens at its left.
let last = (0..64).filter_map(|i| h.state().views[0].glyph_screen_pos(0, i).map(|p| (i, p.x))).max_by(|a, b| a.1.total_cmp(&b.1)).unwrap().0;
h.state_mut().views[0].select_text(0, last, last);
h.run_steps(4);
let viewport = h.state().views[0].viewport_rect();
// The bar's whole area, frame included, not only its first and last buttons.
let id = egui::Id::unique(("text-selection-actions", h.state().views[0].view_key()));
let bar = h.ctx.memory(|m| m.area_rect(id)).expect("the selection bar is shown");
// The bar opens at the selection's left edge: past the area's right edge, without the clamp.
let anchor = h.state().views[0].glyph_screen_rect(0, last).unwrap().left();
assert!(anchor + bar.width() > viewport.right(), "selection at {anchor}, bar {} wide, area {viewport:?}", bar.width());
assert!(viewport.contains_rect(bar), "bar {bar:?} inside {viewport:?}");
}
/// `n` pages with one comment each, "Card 1" to "Card n".
fn one_comment_per_page(n: usize) -> Vec<u8> {
let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 300 200] >>", kids.join(" ")));
for i in 0..n {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{} 0 R] >>", 4 + 2 * i));
objs.push(format!("<< /Type /Annot /Subtype /Square /Rect [20 20 80 80] /C [1 0 0] /T (Ada) /Contents (Card {}) >>", i + 1));
}
let mut out = b"%PDF-1.7\n".to_vec();
for (i, o) in objs.iter().enumerate() {
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
out
}
/// #5488: the panel laid out every card on every frame, which put a few hundred comments over the
/// frame budget. Cards out of view now only take up their height, and the selected one is still
/// drawn wherever it is, so it can scroll into view.
#[test]
fn the_comments_panel_lays_out_only_the_cards_in_view() {
let mut h = harness_with_bytes(one_comment_per_page(120), |app| app.set_option("panel", "comments").unwrap());
h.run_steps(4);
let drawn = h.query_all_by_label_contains("Card ").count();
assert!((1..40).contains(&drawn), "{drawn} of 120 cards laid out");
h.state_mut().views[0].comments.selected = Some((100, 0));
h.state_mut().views[0].comments.reveal = true;
h.run_steps(4);
assert!(h.query_by_label("Card 101").is_some(), "the selected card is drawn and scrolled to");
assert!(h.query_by_label("Card 1").is_none(), "the top of the list scrolled out of view");
}
/// CodeRabbit on #5583: a card's measured height belongs to what it showed. Card 1, far above the
/// cards in view, grows by two replies; the cards in view move down by its growth, not stay where
/// card 1's old height would put them.
#[test]
fn a_card_out_of_view_that_grows_moves_the_cards_below_it() {
let mut h = harness_with_bytes(one_comment_per_page(120), |app| app.set_option("panel", "comments").unwrap());
h.run_steps(4);
h.state_mut().views[0].comments.selected = Some((60, 0));
h.state_mut().views[0].comments.reveal = true;
h.run_steps(4);
assert!(h.query_by_label("Card 1").is_none(), "card 1 is out of view");
let before = h.get_by_label("Card 61").rect().top();
for text in ["A first reply", "A second reply"] {
h.state_mut().apply_edit(pdfcraft_engine::Edit::ReplyToAnnotation { page: 0, index: 0, text: text.into(), author: "Bo".into() });
}
h.run_steps(4);
let after = h.get_by_label("Card 61").rect().top();
assert!(after > before + 40.0, "the cards below a grown card move down: {before} → {after}");
}
/// CodeRabbit on #5583: a card with an editor open on it is drawn wherever it is, so the cards
/// below it make room for the editor rather than keep the card's height from before it opened.
#[test]
fn a_card_with_an_editor_open_out_of_view_is_drawn_at_its_size() {
let mut h = harness_with_bytes(one_comment_per_page(120), |app| app.set_option("panel", "comments").unwrap());
h.run_steps(4);
h.state_mut().views[0].comments.selected = Some((60, 0));
h.state_mut().views[0].comments.reveal = true;
h.run_steps(4);
assert!(h.query_by_label("Card 1").is_none(), "card 1 is out of view");
let before = h.get_by_label("Card 61").rect().top();
h.state_mut().views[0].comments.editing = Some((0, 0, "Card 1".into()));
h.run_steps(4);
let after = h.get_by_label("Card 61").rect().top();
assert!(after > before + 20.0, "the cards below make room for card 1's editor: {before} → {after}");
}
#[test]
fn freehand_highlighter_draws_translucent_yellow_ink() {
let mut h = harness(|app| app.set_option("quick", "freehand_highlight").unwrap());
drag_pt(&mut h, (40.0, 100.0), (240.0, 100.0));
let c = comments(&h);
assert_eq!(c.len(), 1, "{c:?}");
assert_eq!(c[0].subtype, "Ink");
assert_eq!(c[0].intent.as_deref(), Some("FreehandHighlight"));
let props = h.state().session.get(h.state().views[0].id).unwrap().comment_props(0, c[0].index).expect("ink properties");
assert_eq!(props.color, Some([1.0, 0.94, 0.0]));
assert!((props.opacity - 0.35).abs() < 0.001);
assert_eq!(props.width, Some(14.0));
assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight));
h.state_mut().comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight, 100.0);
drag_pt(&mut h, (40.0, 160.0), (240.0, 160.0));
let c = comments(&h);
assert_eq!(c.len(), 2);
let props = h.state().session.get(h.state().views[0].id).unwrap().comment_props(0, c.iter().map(|a| a.index).max().unwrap()).unwrap();
assert_eq!(props.width, Some(100.0));
assert_eq!(h.state().views[0].comments.selected, None);
}
#[test]
fn hotbar_picker_adds_and_runs_freehand_highlight() {
let mut h = harness(|_| {});
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
field(&h, "Search all tools…").click();
h.run_steps(2);
field(&h, "Search all tools…").type_text("freehand");
h.run_steps(3);
h.get_by_role_and_label(Role::CheckBox, "Freehand-Highlight").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "picker stays open after adding a tool");
h.get_by_role_and_label(Role::CheckBox, "Draw freehand").click();
h.run_steps(3);
h.get_by_label("Close menu").click();
h.run_steps(3);
let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_eq!(saved["hotbar"]["tools"], serde_json::json!(["comment.freehand_highlight", "comment.ink"]));
h.state_mut().set_option("hotbar-side", "bottom").unwrap();
h.run_steps(3);
// The new tool is available directly from its custom shortcut.
h.query_all_by_role_and_label(Role::Button, "Freehand-Highlight").last().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight));
}
/// #6548: resting on the hotbar's gear showed two stacked tooltips, "Hotbar settings" and
/// "Hotbar settings / add tools". It shows the longer one only and keeps its short name.
#[test]
fn hotbar_settings_button_shows_one_tooltip() {
let mut h = harness(|_| {});
h.run_steps(2);
h.get_by_label("Hotbar settings").hover();
// egui shows a tooltip once the pointer has rested `tooltip_delay` seconds of *input* time,
// and the harness advances that clock by a fixed step every frame, so the wait is counted in
// simulated seconds rather than slept: it cannot pass early or depend on the machine's speed.
let delay = f64::from(h.ctx.global_style().interaction.tooltip_delay);
let start = h.ctx.input(|i| i.time);
let elapsed = |h: &Harness<'static, PdfCraftApp>| h.ctx.input(|i| i.time) - start;
// The button's name; a second tooltip repeating it would make two. Checked after every
// frame, so one that shows only briefly is caught too.
let names = |h: &Harness<'static, PdfCraftApp>| h.query_all_by_label("Hotbar settings").count();
let mut frames = 0;
while h.query_all_by_label("Hotbar settings / add tools").next().is_none() {
assert!(elapsed(&h) < 4.0 * delay && frames < 100, "no tooltip after {:.2}s of rest", elapsed(&h));
h.step();
frames += 1;
assert_eq!(names(&h), 1, "a second tooltip at {:.2}s", elapsed(&h));
}
// Rest two more delays, so a second tooltip would have had its chance to open.
let shown = elapsed(&h);
while elapsed(&h) < shown + 2.0 * delay && frames < 100 {
h.step();
frames += 1;
assert_eq!(names(&h), 1, "a second tooltip at {:.2}s", elapsed(&h));
}
assert_eq!(h.query_all_by_label("Hotbar settings / add tools").count(), 1, "the tooltip shows");
assert_eq!(names(&h), 1, "the button's name, and no second tooltip");
}
#[test]
fn hotbar_picker_searches_translated_tool_labels() {
if crate::support::skip_without_catalogs("hotbar_picker_searches_translated_tool_labels") {
return;
}
let mut h = harness(|app| {
app.set_option("language", "fr").unwrap();
});
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
field(&h, "Search all tools…").click();
h.run_steps(2);
field(&h, "Search all tools…").type_text("Dessiner");
h.run_steps(3);
h.get_by_role_and_label(Role::CheckBox, "Dessiner à main levée").click();
h.run_steps(3);
let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_eq!(saved["hotbar"]["tools"], serde_json::json!(["comment.ink"]));
}
#[test]
fn hotbar_drag_moves_and_persists_position() {
let mut h = harness(|_| {});
let before: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
let from = h.get_by_label("Drag hotbar").rect().center();
drag(&mut h, from, from + egui::vec2(90.0, 90.0));
let after: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_ne!(before["hotbar"]["position"], after["hotbar"]["position"]);
let mut restored = PdfCraftApp::new();
restored.restore(&h.state().persist());
let saved: serde_json::Value = serde_json::from_str(&restored.persist()).unwrap();
assert_eq!(saved["hotbar"], after["hotbar"]);
}
#[test]
fn hotbar_vertical_size_and_docking() {
let mut h = harness(|app| {
app.set_option("hotbar-tools", "comment.ink,comment.freehand_highlight").unwrap();
app.set_option("hotbar-side", "right").unwrap();
});
for size in [24, 48, 64] {
h.state_mut().set_option("hotbar-size", &size.to_string()).unwrap();
h.run_steps(5);
let measured = h.ctx.data_mut(|d| d.get_temp::<egui::Vec2>(egui::Id::unique("custom-hotbar"))).unwrap();
assert!(measured.x <= size as f32 + 24.0, "vertical bar too wide: {measured:?}");
assert!(measured.x >= size as f32);
}
let from = h.get_by_label("Drag hotbar").rect().center();
drag(&mut h, from, from - egui::vec2(90.0, 90.0));
let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_eq!(saved["hotbar"]["position"], serde_json::json!([1.0, 0.5]));
assert_eq!(saved["hotbar"]["dock"], "right");
h.state_mut().set_option("hotbar-side", "float").unwrap();
h.run_steps(3);
let from = h.get_by_label("Drag hotbar").rect().center();
drag(&mut h, from, from - egui::vec2(90.0, 90.0));
let moved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_ne!(saved["hotbar"]["position"], moved["hotbar"]["position"]);
}
#[test]
fn hotbar_settings_layout_and_side_controls_are_reachable_by_label() {
let mut h = harness(|_| {});
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
fn vertical(h: &Harness<'static, PdfCraftApp>) -> serde_json::Value {
serde_json::from_str::<serde_json::Value>(&h.state().persist()).unwrap()["hotbar"]["vertical"].clone()
}
let before = vertical(&h);
h.get_by_role_and_label(Role::Switch, "Vertical layout").click();
h.run_steps(3);
assert_ne!(vertical(&h), before, "the layout switch toggles by its label");
h.get_by_role_and_label(Role::RadioButton, "Right").click();
h.run_steps(3);
let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_eq!(saved["hotbar"]["dock"], "right");
assert_eq!(saved["hotbar"]["vertical"], true, "docking at the side lays the bar out vertically");
h.get_by_role_and_label(Role::RadioButton, "Float").click();
h.run_steps(3);
let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert!(saved["hotbar"]["dock"].is_null());
}
#[test]
fn hotbar_settings_reopen_even_with_remembered_collapsed_state() {
let mut h = harness(|_| {});
let mut collapsed =
egui::collapsing_header::CollapsingState::load_with_default_open(&h.ctx, egui::Id::unique("hotbar-picker").with("collapsing"), false);
collapsed.set_open(false);
collapsed.store(&h.ctx);
h.get_by_label("Hotbar settings").click();
h.run_steps(5);
assert!(h.query_by_label("Close menu").is_some());
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "Hotbar settings always shows settings");
h.get_by_label("Close menu").click();
h.run_steps(3);
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some());
}
#[test]
fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() {
let mut h = harness(|app| {
app.set_option("hotbar-side", "right").unwrap();
app.set_option("hotbar-size", "48").unwrap();
});
h.get_by_label("Hotbar settings").click();
h.run_steps(5);
h.get_by_label("Full-side strip").click();
h.run_steps(3);
let enabled: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_eq!(enabled["hotbar"]["full_side"], true, "settings checkbox enables strip");
h.get_by_label("Close menu").click();
for side in ["right", "left", "top", "bottom"] {
h.state_mut().set_option("hotbar-side", side).unwrap();
h.run_steps(5);
let size = h.ctx.data_mut(|d| d.get_temp::<egui::Vec2>(egui::Id::unique("custom-hotbar"))).unwrap();
let (length, thickness) = if matches!(side, "left" | "right") { (size.y, size.x) } else { (size.x, size.y) };
assert!(length > 300.0, "strip must fill the edge: {side} {size:?}");
assert!(thickness <= 64.0, "strip must stay narrow: {size:?}");
h.get_by_label("Hotbar settings");
}
let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
assert_eq!(saved["hotbar"]["full_side"], true);
h.state_mut().set_option("hotbar-side", "float").unwrap();
h.run_steps(5);
let size = h.ctx.data_mut(|d| d.get_temp::<egui::Vec2>(egui::Id::unique("custom-hotbar"))).unwrap();
let (length, thickness) = if size.y > size.x { (size.y, size.x) } else { (size.x, size.y) };
assert!(thickness <= 64.0 && length < 550.0, "floating toolbar is compact: {size:?}");
}
#[test]
fn hotbar_docked_left_does_not_override_quick_tools() {
let mut h = harness(|app| {
app.set_option("hotbar-side", "left").unwrap();
});
h.run_steps(5);
// Controls in the docked bar share one layout, so none may overlap (#5890).
let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign: Add signature", "Hotbar settings"];
let rects: Vec<_> = labels.iter().map(|&label| h.get_by_label(label).rect()).collect();
for (i, a) in rects.iter().enumerate() {
for (j, b) in rects.iter().enumerate().skip(i + 1) {
let overlap = a.min.x < b.max.x && b.min.x < a.max.x && a.min.y < b.max.y && b.min.y < a.max.y;
assert!(!overlap, "{} overlaps {}: {a:?} / {b:?}", labels[i], labels[j]);
}
}
}
#[test]
fn hotbar_docked_in_compact_window_keeps_settings_button_visible() {
for side in ["left", "right"] {
let mut h = Harness::builder().with_size(egui::vec2(800.0, 480.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("hotbar-side", side).unwrap();
app.set_option("hotbar-size", "48").unwrap();
app
});
h.run_steps(5);
let settings_rect = h.get_by_label("Hotbar settings").rect();
assert!(settings_rect.max.y <= 480.0, "{side} hotbar settings button must stay within window height: {settings_rect:?}");
}
}
#[test]
fn hotbar_settings_menu_closes_on_click_outside() {
let mut h = harness(|app| {
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
});
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some());
// Clicking outside on the canvas dismisses the settings menu
h.event(egui::Event::PointerButton {
pos: egui::pos2(600.0, 300.0),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
});
h.event(egui::Event::PointerButton {
pos: egui::pos2(600.0, 300.0),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
});
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_none(), "clicking outside closes the hotbar settings menu");
// Reopening and clicking another tool outside dismisses the menu and activates that tool
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some());
h.get_by_label("Hand (H)").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_none());
assert_eq!(h.state().quick_tool, QuickTool::Hand);
}
#[test]
fn hotbar_picker_in_narrow_viewport_fits_within_screen_width() {
let mut h = Harness::builder().with_size(egui::vec2(270.0, 480.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app
});
h.get_by_label("Hotbar settings").click();
h.run_steps(5);
let close_btn = h.get_by_label("Close menu");
assert!(close_btn.rect().max.x <= 270.0, "Close menu button must stay within viewport width: {:?}", close_btn.rect());
}
#[test]
fn hotbar_floating_picker_does_not_overlap_rendered_bar() {
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app
});
let settings_btn = h.get_by_label("Hotbar settings");
let settings_rect = settings_btn.rect();
settings_btn.click();
h.run_steps(5);
let picker_close = h.get_by_label("Close menu");
let picker_rect = picker_close.rect();
assert!(
picker_rect.min.x >= settings_rect.max.x || picker_rect.max.x <= settings_rect.min.x,
"picker ({:?}) must not overlap the settings button ({:?}) on the floating bar",
picker_rect,
settings_rect
);
}
fn edit_composer_preserves_only_typewriter_whitespace(kind: u8) {
let shots = std::env::var_os("PDFCRAFT_COMPOSER_SHOTS").is_some();
let _gpu = shots.then(crate::gpu);
use pdfcraft_engine::{Edit, NewAnnotation, NoteIcon, Shape, Style};
let mut h = harness(move |app| {
app.set_option("layout", "single").unwrap();
app.set_option("panel", "comments").unwrap();
app.quick_tool = QuickTool::Select;
// Both pages use index 0, with opposite intents. The lookup must use page AND index.
for page in 0..2 {
let typewriter = if page == 0 { kind != 0 } else { kind == 0 };
let shape = if typewriter {
Shape::Typewriter { rect: [40.0, 40.0, 180.0, 90.0], font_size: 10.0 }
} else if kind == 2 {
Shape::Note { at: [80.0, 80.0], icon: NoteIcon::Comment }
} else {
Shape::TextBox { rect: [40.0, 40.0, 180.0, 90.0], font_size: 10.0 }
};
let style = Style::default_for(&shape);
assert!(app.apply_edit(Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: "Original".into(), author: "Tester".into() })));
}
});
h.state_mut().views[0].go_to_page(1);
h.run_steps(4);
support::settle(&mut h);
let point = {
let s = h.state();
let v = &s.views[0];
let doc = s.session.get(v.id).unwrap();
let a = doc.info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap();
let page = v.page_screen_rect(1).expect("the second page is visible");
let info = &doc.info.pages[1];
let p = info.user_to_view((a.rect[0] + a.rect[2]) / 2.0, (a.rect[1] + a.rect[3]) / 2.0);
page.min + egui::vec2(p[0], p[1]) * (page.width() / info.width)
};
support::input::double_click(&mut h, point);
h.run_steps(3);
if kind == 2 {
// A double-click on a sticky note opens its pop-up window, as in Acrobat; its edit
// composer is the page menu's "Edit text…".
let popup_open = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
doc.info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap().popup.is_some_and(|p| p.open)
};
assert!(h.state().views[0].comments.composer.is_none(), "a note's double-click opens no composer");
assert!(popup_open(&h), "a note's double-click opens its pop-up");
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(3);
h.get_all_by_label("Edit text…").last().unwrap().click();
h.run_steps(3);
}
let c = h.state().views[0].comments.composer.as_ref().expect("the edit composer is open");
assert_eq!(c.page, 1);
assert_eq!(c.kind, pdfcraft_ui_egui::comments::ComposerKind::Edit(0));
h.input_mut().events.push(egui::Event::Text(" edited\n\n".into()));
let ctx = h.ctx.clone();
let mut raw = std::mem::take(h.input_mut());
h.state_mut().prepare_raw_input(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
h.run_steps(2);
assert_eq!(h.state().views[0].comments.composer.as_ref().unwrap().text, "Original edited\n\n");
if shots {
h.render().unwrap().save(format!("/tmp/5954-composer-{kind}.png")).unwrap();
}
h.get_all_by_label("Post").last().unwrap().click();
h.run_steps(3);
assert!(h.state().views[0].comments.composer.is_none());
let id = h.state().views[0].id;
let bytes = h.state().session.save_bytes(id).unwrap();
// Check the raw saved string: ordinary-comment reader trimming must not hide a bad post.
let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
let pages = pdfcraft_model::pages(&cos);
let annots = cos.resolve(pages[1].dict.get(b"Annots").unwrap());
let annot = cos.resolve(&annots.as_array().unwrap()[0]);
let contents = cos.resolve(annot.as_dict().unwrap().get(b"Contents").unwrap());
let expected = if kind == 0 { "Original edited\n\n" } else { "Original edited" };
assert_eq!(contents.as_string().unwrap().to_text(), expected, "only a typewriter edit preserves trailing blank lines");
let mut reopened = pdfcraft_engine::Session::new();
let again = reopened.open("edited.pdf", None, bytes, None).unwrap();
let saved = reopened.get(again).unwrap().info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap();
assert_eq!(saved.contents.as_deref(), Some(expected));
}
#[test]
fn typewriter_edit_composer_keeps_trailing_blank_lines() {
edit_composer_preserves_only_typewriter_whitespace(0);
}
#[test]
fn other_edit_composers_keep_trimming_trailing_whitespace() {
for kind in [1, 2] {
edit_composer_preserves_only_typewriter_whitespace(kind);
}
}
/// ⌘-click (Ctrl on Linux and Windows) adds comments to the selection and takes one out again;
/// Delete removes every selected comment as one undo step.
#[test]
fn ctrl_click_selects_several_comments_and_delete_removes_them_all() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] {
drag_pt(&mut h, a, b);
}
assert_eq!(comments(&h).len(), 3);
h.state_mut().set_option("quick", "select").unwrap();
h.run_steps(2);
click_pt(&mut h, (40.0, 40.0));
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 0)]);
click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND);
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND);
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 0), (0, 1)], "the handles go to the one clicked last");
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)));
// ⌘-click on a selected comment takes it out again.
click_pt_with(&mut h, (40.0, 40.0), egui::Modifiers::COMMAND);
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 1)]);
// Delete: both go, as one step.
h.key_press(egui::Key::Delete);
h.run_steps(3);
let left = comments(&h);
assert_eq!(left.len(), 1, "{left:?}");
assert!((left[0].rect[0] - 20.0).abs() < 1.0, "the unselected first rectangle stays: {:?}", left[0].rect);
assert!(h.state().views[0].comments.selection().is_empty());
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comments"));
h.state_mut().undo();
assert_eq!(comments(&h).len(), 3);
// A plain click selects one alone again.
click_pt(&mut h, (120.0, 40.0));
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]);
// On the page ⇧-click adds one too, and takes it out again (a range is the panel's).
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT);
assert_eq!(h.state().views[0].comments.selection().len(), 2, "{:?}", h.state().views[0].comments.selection());
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT);
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]);
}
/// Dragging one of several selected comments moves them all, whichever of them is grabbed: the
/// grabbed one takes the handles and the one that had them stays selected.
#[test]
fn dragging_any_selected_comment_moves_the_whole_selection() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] {
drag_pt(&mut h, a, b);
}
h.state_mut().set_option("quick", "select").unwrap();
h.run_steps(2);
click_pt(&mut h, (40.0, 40.0));
click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND);
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND);
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)), "the last one clicked has the handles");
// Drag the first one (not the one with the handles) 30 points up.
let before = comments(&h);
drag_pt(&mut h, (40.0, 40.0), (40.0, 70.0));
let after = comments(&h);
assert_eq!(after.len(), 3);
for (a, b) in before.iter().zip(&after) {
assert!((b.rect[1] - a.rect[1] - 30.0).abs() < 1.5, "every selected rectangle moved up 30 pt: {:?} from {:?}", b.rect, a.rect);
}
let selection = h.state().views[0].comments.selection();
assert_eq!(selection.len(), 3, "all three stay selected: {selection:?}");
assert_eq!(selection[0], (0, 0), "the grabbed one has the handles");
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments"));
// A locked one in the selection stays where it is; the others still move (it would refuse
// the whole step otherwise, and nothing would move).
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::LockAnnotation { page: 0, index: 1, locked: true }));
h.run_steps(2);
assert_eq!(h.state().views[0].comments.selection().len(), 3);
let before = comments(&h);
drag_pt(&mut h, (40.0, 70.0), (40.0, 90.0));
let after = comments(&h);
// By index: the list isn't kept in index order.
let bottom = |list: &[Annotation], i: usize| list.iter().find(|a| a.index == i).map(|a| a.rect[1]).expect("still there");
for i in 0..3 {
let moved = bottom(&after, i) - bottom(&before, i);
if i == 1 {
assert!(moved.abs() < 0.01, "the locked one stays: moved {moved}");
} else {
assert!((moved - 20.0).abs() < 1.5, "rectangle {i} moved up 20 pt: moved {moved}");
}
}
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments"));
}
/// Copied comments on the pointer take the click whatever tool is on: with Fill & Sign's Text
/// tool or a stamp tool still on, the click places the bundle and opens no text box or stamp.
#[test]
fn copied_comments_take_the_click_from_the_tool_that_is_on() {
use pdfcraft_ui_egui::fill_sign::FillTool;
let mut h = harness(|_| {});
let id = h.state().views[0].id;
let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
let style = pdfcraft_engine::Style::default_for(&shape);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
shape,
style,
contents: String::new(),
author: "Tester".into(),
})));
h.run_steps(2);
h.state_mut().views[0].comments.select_only((0, 0));
assert!(h.state_mut().execute("comment.copy"));
let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.annotations.len();
for (tool, at) in [(QuickTool::Fill(FillTool::Text), (150.0, 100.0)), (QuickTool::Stamp(pdfcraft_engine::StampKind::Approved), (200.0, 160.0))] {
h.state_mut().quick_tool = tool;
h.run_steps(2);
let before = count(&h);
assert!(h.state_mut().execute("comment.paste"));
click_pt(&mut h, at);
assert_eq!(count(&h), before + 1, "{tool:?}: the copy, and nothing else");
assert!(h.state().views[0].fill_text.is_none(), "{tool:?}: no text box opens");
assert!(h.state().views[0].comments.gesture.is_none());
let placed = h.state().session.get(id).unwrap().info.annotations.iter().max_by_key(|a| a.index).map(|a| a.rect).expect("placed");
assert!(((placed[0] + placed[2]) / 2.0 - at.0).abs() < 1.0, "{tool:?}: centred on the click: {placed:?}");
assert_eq!(h.state().quick_tool, tool, "the tool stays on for the next click");
}
// Afterwards the tool takes its clicks again.
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text);
h.run_steps(2);
click_pt(&mut h, (100.0, 200.0));
assert!(h.state().views[0].fill_text.is_some(), "Fill & Sign's Text tool opens its box again");
}
/// Two boxes on page 1 of the active document, and a reply to the first.
fn add_two_boxes_and_a_reply(h: &mut Harness<'static, PdfCraftApp>) {
for rect in [[20.0, 20.0, 60.0, 60.0], [100.0, 20.0, 140.0, 60.0]] {
let shape = pdfcraft_engine::Shape::Rectangle { rect };
let style = pdfcraft_engine::Style::default_for(&shape);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
page: 0,
shape,
style,
contents: "Box".into(),
author: "Tester".into(),
})));
}
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::ReplyToAnnotation { page: 0, index: 0, text: "Reply".into(), author: "Tester".into() }));
h.run_steps(2);
}
/// Copied comments paste into another document on its current page, each with its replies, as
/// new comments (a second paste gives a second set); the copies are selected afterwards.
#[test]
fn copied_comments_paste_into_another_document() {
let mut h = harness(|app| {
app.open_bytes("other.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.active = Some(0);
});
let (source, target) = (h.state().views[0].id, h.state().views[1].id);
add_two_boxes_and_a_reply(&mut h);
// Adding a comment selects it; with nothing selected there is nothing to copy.
h.state_mut().views[0].comments.clear_selection();
assert!(!h.state_mut().execute("comment.copy"), "nothing selected: nothing to copy");
{
let cv = &mut h.state_mut().views[0].comments;
cv.select_only((0, 0));
cv.also.push((0, 1));
}
assert!(h.state_mut().execute("comment.copy"));
assert_eq!(h.state().comment_clipboard.as_ref().map(|c| c.count), Some(2));
// Into the other document: Edit ▸ Paste comments hangs the bundle on the pointer, a click
// places it, centred there (the two boxes span 20..140 × 20..60: centre 80, 40).
h.state_mut().active = Some(1);
h.run_steps(2);
assert!(h.state_mut().execute("comment.paste"));
let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(target).unwrap().info.annotations.len();
assert_eq!(count(&h), 0, "nothing placed yet");
assert!(matches!(h.state().views[1].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Place { count: 2, .. })));
click_pt(&mut h, (150.0, 100.0));
let pasted = h.state().session.get(target).unwrap().info.annotations.clone();
assert_eq!(pasted.len(), 3, "two comments and the reply: {pasted:?}");
assert!(pasted.iter().all(|a| a.page == 0), "on the page clicked: {pasted:?}");
let parent = pasted.iter().find(|a| a.in_reply_to.is_none() && (a.rect[0] - 90.0).abs() < 1.0).expect("the first box, moved by (70, 60)");
assert!((parent.rect[1] - 80.0).abs() < 1.0 && (parent.rect[2] - 130.0).abs() < 1.0, "{:?}", parent.rect);
assert_eq!(pasted.iter().find(|a| a.in_reply_to.is_some()).and_then(|r| r.in_reply_to.clone()), parent.name, "the reply follows its copy");
assert_eq!(h.state().views[1].comments.selection().len(), 2, "the copies are selected");
assert!(h.state().views[1].comments.gesture.is_none());
assert_eq!(h.state().session.get(target).unwrap().can_undo(), Some("Paste comments"));
assert_eq!(h.state().session.get(source).unwrap().info.annotations.len(), 3, "the source is untouched");
// The page menu's Paste comments places the bundle where the menu was opened: page 2 here.
h.state_mut().views[1].comments.paste_request = Some((1, [100.0, 100.0]));
h.run_steps(3);
assert_eq!(count(&h), 6);
let second: Vec<_> =
h.state().session.get(target).unwrap().info.annotations.iter().filter(|a| a.page == 1 && a.in_reply_to.is_none()).cloned().collect();
assert_eq!(second.len(), 2);
let centre_x = (second.iter().map(|a| a.rect[0]).fold(f32::MAX, f32::min) + second.iter().map(|a| a.rect[2]).fold(f32::MIN, f32::max)) / 2.0;
assert!((centre_x - 100.0).abs() < 1.0, "centred at the menu's point: {second:?}");
// ⌘V (a Paste event) hangs the bundle on the pointer again; Escape lets it go.
h.event(egui::Event::Paste(String::new()));
h.run_steps(3);
assert!(h.state().views[1].comments.gesture.is_some());
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().views[1].comments.gesture.is_none());
assert_eq!(count(&h), 6, "nothing placed");
h.state_mut().undo();
assert_eq!(count(&h), 3);
// XFDF arriving from the system clipboard pastes too, even without the app's clipboard.
let xfdf = h.state().comment_clipboard.clone().unwrap().xfdf;
h.state_mut().comment_clipboard = None;
assert!(!h.state_mut().execute("comment.paste"), "nothing copied: disabled");
h.event(egui::Event::Paste(xfdf.clone()));
h.run_steps(3);
click_pt(&mut h, (200.0, 120.0));
assert_eq!(count(&h), 6);
h.event(egui::Event::Paste("just text".into()));
h.run_steps(3);
assert!(h.state().views[1].comments.gesture.is_none(), "plain text is not a paste of comments");
// Nor when comments were copied earlier: a word copied in another app since is that word.
h.state_mut().comment_clipboard = Some(pdfcraft_ui_egui::CommentClip { xfdf, count: 2 });
h.event(egui::Event::Paste("a word from elsewhere".into()));
h.run_steps(3);
assert!(h.state().views[1].comments.gesture.is_none(), "the old comments don't hang on the pointer");
// Nothing legible on the system clipboard: the app's own copied comments.
h.event(egui::Event::Paste(String::new()));
h.run_steps(3);
assert!(h.state().views[1].comments.gesture.is_some());
}
/// Connected lines (and polygons) finish from the right-click menu too, as other editors do;
/// the menu also takes the last point back and cancels the drawing.
#[test]
fn connected_lines_are_completed_from_the_context_menu() {
let mut h = harness(|app| app.set_option("quick", "polyline").unwrap());
for p in [(20.0, 150.0), (60.0, 130.0), (100.0, 150.0)] {
click_pt(&mut h, p);
}
assert_eq!(comments(&h).len(), 0, "still drawing");
let right_click = |h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)| {
let p = at(h, p.0, p.1);
h.hover_at(p);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
};
right_click(&mut h, (120.0, 100.0));
h.get_by_label("Complete").click();
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 1, "{c:?}");
assert_eq!(c[0].subtype, "PolyLine");
// A polygon needs three points: with two, Complete is greyed; Undo last point and Cancel drawing work.
h.state_mut().set_option("quick", "polygon").unwrap();
for p in [(150.0, 150.0), (200.0, 150.0)] {
click_pt(&mut h, p);
}
assert_eq!(drawn_points(&h), 2);
right_click(&mut h, (220.0, 100.0));
assert!(h.get_by_label("Complete").accesskit_node().is_disabled());
h.get_by_label("Undo last point").click();
h.run_steps(3);
assert_eq!(drawn_points(&h), 1);
right_click(&mut h, (220.0, 100.0));
h.get_by_label("Cancel drawing").click();
h.run_steps(3);
assert_eq!(drawn_points(&h), 0);
assert!(h.state().views[0].comments.gesture.is_none());
assert_eq!(comments(&h).len(), 1, "nothing else was drawn");
// On the second page, with the first on screen in the same frame: Complete reaches the page
// the drawing is on (the first page passes first and must not take the request).
h.state_mut().set_option("quick", "polyline").unwrap();
h.state_mut().set_option("zoom", "50").unwrap();
crate::support::settle(&mut h);
assert!(h.state().views[0].page_screen_rect(0).is_some(), "page 1 is on screen too");
let at2 = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| {
let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height())
};
for (x, y) in [(20.0, 100.0), (60.0, 80.0)] {
let p = at2(&h, x, y);
click(&mut h, p);
}
assert_eq!(drawn_points(&h), 2);
let p = at2(&h, 150.0, 150.0);
h.hover_at(p);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
h.get_by_label("Complete").click();
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 2, "{c:?}");
assert!(c.iter().any(|a| a.page == 1 && a.subtype == "PolyLine"), "{c:?}");
assert!(h.state().views[0].comments.gesture.is_none());
}
/// Properties on one of several selected comments changes all of them, each where it applies: the
/// colour goes to every one, the line width to the shapes, the font size to the text box alone;
/// a locked one stays as it is. One undo step. Colouring the selection works the same way.
#[test]
fn properties_change_every_selected_comment_where_it_applies() {
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
let mut h = harness(|_| {});
let shapes = [
Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] },
Shape::Oval { rect: [80.0, 20.0, 120.0, 60.0] },
Shape::TextBox { rect: [140.0, 20.0, 200.0, 40.0], font_size: 12.0 },
Shape::Rectangle { rect: [220.0, 20.0, 260.0, 60.0] },
];
for shape in shapes {
let style = Style::default_for(&shape);
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
shape,
style,
contents: "x".into(),
author: "Tester".into()
})));
}
assert!(h.state_mut().apply_edit(Edit::LockAnnotation { page: 0, index: 3, locked: true }));
{
let c = &mut h.state_mut().views[0].comments;
c.selected = Some((0, 0));
c.also = vec![(0, 1), (0, 2), (0, 3)];
}
h.run_steps(2);
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.get_by_label("Changes here apply to all 4 selected comments");
{
let d = h.state_mut().comment_props.as_mut().expect("open");
assert_eq!(d.others.len(), 3);
d.edited.color = Some([0.0, 0.0, 1.0]);
d.edited.width = Some(4.0);
}
h.get_by_label("OK").click();
h.run_steps(3);
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap()
};
for index in [0, 1] {
let p = props(&h, index);
assert_eq!((p.color, p.width), (Some([0.0, 0.0, 1.0]), Some(4.0)), "shape {index}");
}
assert_eq!(props(&h, 2).color, Some([0.0, 0.0, 1.0]), "the text box's text colour");
let locked = props(&h, 3);
assert_ne!(locked.color, Some([0.0, 0.0, 1.0]), "the locked rectangle stays as it was");
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Change comment properties"), "one undo step");
// From the text box: its font size changes, and the shapes, which have none, keep their look.
h.state_mut().views[0].comments.selected = Some((0, 2));
h.state_mut().views[0].comments.also = vec![(0, 0), (0, 1)];
h.state_mut().open_comment_props(0, 2);
h.run_steps(2);
h.state_mut().comment_props.as_mut().expect("open").edited.font_size = Some(20.0);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(props(&h, 2).font_size, Some(20.0));
assert_eq!((props(&h, 0).font_size, props(&h, 0).width), (None, Some(4.0)));
// Colouring the selection (the context menu's Colour with several selected).
h.state_mut().colour_selected_comments([1.0, 0.0, 0.0]);
h.run_steps(2);
for index in [0, 1, 2] {
assert_eq!(props(&h, index).color, Some([1.0, 0.0, 0.0]), "comment {index}");
}
assert_ne!(props(&h, 3).color, Some([1.0, 0.0, 0.0]));
}
/// Comment Properties' text opacity, set with several comments selected, goes to the other text
/// boxes whose text shows an opacity of its own (a border or a fill), and leaves a text-only box,
/// whose one opacity is the whole comment's, as it was.
#[test]
fn part_opacity_changes_every_selected_comment_that_shows_the_part() {
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
let mut h = harness(|_| {});
for (n, width) in [1.0, 1.0, 0.0].into_iter().enumerate() {
let x = 20.0 + 100.0 * n as f64;
let shape = Shape::TextBox { rect: [x, 20.0, x + 80.0, 40.0], font_size: 12.0 };
let style = Style { width, ..Style::default_for(&shape) };
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
shape,
style,
contents: "x".into(),
author: "Tester".into()
})));
}
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap()
};
let text_only = props(&h, 2);
{
let c = &mut h.state_mut().views[0].comments;
c.selected = Some((0, 0));
c.also = vec![(0, 1), (0, 2)];
}
h.run_steps(2);
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
h.state_mut().comment_props.as_mut().expect("open").edited.text_opacity = Some(0.4);
h.get_by_label("OK").click();
h.run_steps(3);
for index in [0, 1] {
let text = props(&h, index).text_opacity.expect("a text box's text opacity");
assert!((text - 0.4).abs() < 1e-3, "bordered box {index}: {text}");
}
let after = props(&h, 2);
assert_eq!((after.text_opacity, after.opacity), (text_only.text_opacity, text_only.opacity), "the text-only box keeps its look");
}
/// Escape with a Fill & Sign tool on and several comments selected deselects them all first,
/// then drops the tool.
#[test]
fn escape_with_a_fill_tool_deselects_every_comment_then_drops_the_tool() {
use pdfcraft_ui_egui::fill_sign::FillTool;
let mut h = harness(|_| {});
add_two_boxes_and_a_reply(&mut h);
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text);
{
let c = &mut h.state_mut().views[0].comments;
c.selected = Some((0, 0));
c.also = vec![(0, 1)];
}
h.run_steps(2);
h.key_press(egui::Key::Escape);
h.run_steps(2);
let c = &h.state().views[0].comments;
assert!(c.selected.is_none() && c.also.is_empty(), "the whole selection goes");
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text), "the tool stays for now");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!(h.state().quick_tool, QuickTool::Select);
}
/// A square drawn and selected with the Select tool.
fn selected_square() -> Harness<'static, PdfCraftApp> {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
click_pt(&mut h, (250.0, 20.0));
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
h
}
/// Delete or Backspace with a dialog open is the dialog's: the comment selected behind it stays.
/// Both deleted it.
#[test]
fn delete_under_a_dialog_leaves_the_selected_comment() {
use pdfcraft_ui_egui::Dialog;
for (dialog, key) in [(Dialog::Shortcuts, egui::Key::Delete), (Dialog::About, egui::Key::Backspace)] {
let mut h = selected_square();
h.state_mut().dialog = Some(dialog);
h.run_steps(3);
h.key_press(key);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "{key:?} under a dialog deletes nothing");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "and the comment stays selected");
}
}
/// Escape with a dialog open closes the dialog and nothing else: the comment stays selected. It
/// was deselected too.
#[test]
fn escape_under_a_dialog_closes_it_and_keeps_the_selected_comment() {
let mut h = selected_square();
h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::Shortcuts);
h.run_steps(3);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes the dialog");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "and nothing else");
}
/// With a menu of the menu bar open, Delete is the menu's: the comment stays. It was deleted.
#[test]
fn delete_with_a_menu_open_leaves_the_selected_comment() {
let mut h = selected_square();
crate::support::open_menu(&mut h, "View");
assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open");
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment was still selected for the Delete");
}
/// Edit ▸ Delete removes the selected comment: the key a menu click stands for is not under the
/// menu it came from.
#[test]
fn edit_delete_removes_the_selected_comment() {
let mut h = selected_square();
crate::support::open_menu(&mut h, "Edit");
crate::support::menu_row(&h, "Delete").click();
h.run_steps(4);
assert!(comments(&h).is_empty());
}
/// The Escape that closes a dialog opened from a menu while a comment is being typed is the
/// dialog's: the typing stays, in the composer or posted. It was thrown away.
#[test]
fn escape_closing_a_dialog_over_a_comment_being_typed_keeps_the_typing() {
let mut h = harness(|app| app.set_option("quick", "note").unwrap());
click_pt(&mut h, (250.0, 180.0));
field(&h, "Add a comment").click();
h.run_steps(2);
h.event(egui::Event::Text("First line".into()));
h.run_steps(2);
crate::support::open_shortcuts_from_the_help_menu(&mut h);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "Escape closes the dialog");
let open = h.state().views[0].comments.composer.as_ref().is_some_and(|c| c.text.contains("First line"));
let posted = comments(&h).iter().any(|c| c.contents.as_deref().is_some_and(|t| t.contains("First line")));
assert!(open || posted, "the typing stays");
}
/// With a menu of the menu bar open, the arrows are the menu's: the selected comment stays put.
#[test]
fn an_arrow_with_a_menu_open_leaves_the_selected_comment_where_it_is() {
let mut h = selected_square();
let before = comments(&h)[0].rect;
crate::support::open_menu(&mut h, "View");
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert_eq!(comments(&h)[0].rect, before, "not moved");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment was still selected for the arrow");
}
/// Edit ▸ Delete from a native menu with the comment's own menu still open removes the comment:
/// the click never reaches the window, so Edit ▸ Delete closes the popup, as a click anywhere else
/// would, before its key joins the pages'. The popup held the Delete.
#[test]
fn edit_delete_with_a_popup_open_removes_the_selected_comment() {
let mut h = selected_square();
let p = at(&h, 90.0, 70.0);
h.hover_at(p);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
assert!(egui::Popup::is_any_open(&h.ctx), "the comment's menu is open");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(4);
assert!(comments(&h).is_empty(), "Edit ▸ Delete removed the comment");
}
/// A native menu's click never reaches the window, so Edit ▸ Delete closes the Hotbar tools window,
/// as it closes egui's popups, and removes the selected comment. The window held the Delete.
#[test]
fn edit_delete_from_a_native_menu_with_the_hotbar_window_open_removes_the_comment() {
let mut h = selected_square();
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "the Hotbar tools window is open");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is still selected");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(4);
assert!(h.query_by_label("Close menu").is_none(), "Edit ▸ Delete closed the Hotbar tools window");
assert!(comments(&h).is_empty(), "Edit ▸ Delete removed the comment");
}
/// Edit ▸ Delete goes to a text field that has the keyboard, as the user's Delete does: with the
/// Hotbar tools window's search field focused, a native Edit ▸ Delete leaves the window open and
/// deletes in the field. Closed, the window took the field with it, and the Delete removed the
/// selected comment. (A native click on Edit ▸ Copy, Cut, Paste or Select all reaches a focused
/// field as craft-menu's tap of the shortcut, not as a menu command.)
#[test]
fn edit_delete_from_a_native_menu_into_the_hotbar_window_search_keeps_the_window() {
let mut h = selected_square();
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "the Hotbar tools window is open");
let search = |h: &Harness<'static, PdfCraftApp>, text: &str| h.query_all_by_role(Role::TextInput).any(|n| n.value().as_deref() == Some(text));
assert!(search(&h, ""), "the search field is empty");
h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some("")).expect("the search field").click();
h.run_steps(2);
h.event(egui::Event::Text("pen".into()));
h.run_steps(2);
h.key_press(egui::Key::ArrowLeft);
h.run_steps(2);
assert!(search(&h, "pen"), "typed into the search field");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "Edit ▸ Delete closed the Hotbar tools window");
assert!(search(&h, "pe"), "Edit ▸ Delete deleted in the search field");
assert_eq!(comments(&h).len(), 1, "Edit ▸ Delete removed the comment");
}
/// Full screen hides the hotbar and closes its tools window, which doesn't come back with the
/// hotbar: back from full screen, a Delete on the pass that shows the hotbar again removes the
/// selected comment with nothing over it. The window came back, drawn after that pass's keys.
#[test]
fn full_screen_closes_the_hotbar_tools_window() {
let mut h = selected_square();
h.get_by_label("Hotbar settings").click();
h.run_steps(3);
assert!(h.query_by_label("Close menu").is_some(), "the Hotbar tools window is open");
assert!(h.state_mut().execute("view.full_screen"));
h.run_steps(4);
assert!(h.state().full_screen, "full screen");
// Back, as a native menu's click is, between passes.
assert!(h.state_mut().execute("view.full_screen"));
h.key_press(egui::Key::Delete);
h.run_steps(4);
assert!(!h.state().full_screen, "back from full screen");
assert!(h.query_by_label("Close menu").is_none(), "the Hotbar tools window came back");
assert!(comments(&h).is_empty(), "Delete removed the comment");
}
/// A square drawn and selected, then the update prompt a check at start shows when it finds a
/// newer release (the check waits until the square is selected).
fn selected_square_under_the_update_prompt() -> Harness<'static, PdfCraftApp> {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let found = Arc::new(AtomicBool::new(false));
let answer = found.clone();
let mut h = harness(move |app| {
app.set_startup_update_check(true);
app.set_update_source(Arc::new(move || {
// Not forever: a test that fails before the square is selected leaves no thread behind.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while !answer.load(Ordering::SeqCst) {
if std::time::Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
Ok(Some(craft_update::Release {
tag: "v99.0.0".into(),
version: craft_update::parse_version("v99.0.0"),
html_url: "https://example.invalid/release".into(),
..Default::default()
}))
}));
app.set_option("quick", "square").unwrap();
});
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
h.state_mut().set_option("quick", "select").unwrap();
click_pt(&mut h, (250.0, 20.0));
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
found.store(true, Ordering::SeqCst);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while h.query_by_label_contains("is available").is_none() {
h.run_steps(2);
assert!(std::time::Instant::now() < deadline, "timed out waiting for the update prompt");
std::thread::sleep(std::time::Duration::from_millis(5));
}
h.run_steps(2);
h
}
/// The update prompt is over the window, in full screen too: Delete under it leaves the selected
/// comment, and the Escape that closes it (Later) leaves it selected. Both acted on the comment.
#[test]
fn keys_under_the_update_prompt_leave_the_selected_comment() {
for full_screen in [false, true] {
let mut h = selected_square_under_the_update_prompt();
if full_screen {
assert!(h.state_mut().execute("view.full_screen"));
h.run_steps(4);
assert!(h.state().full_screen && h.query_by_label_contains("is available").is_some(), "full screen, the prompt over it");
}
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "Delete under the update prompt deleted the comment (full screen: {full_screen})");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.query_by_label_contains("is available").is_none(), "Escape closed the prompt");
assert_eq!(h.state().full_screen, full_screen, "the Escape that closed the prompt is the prompt's alone");
let selected = h.state().views[0].comments.selected;
assert_eq!(selected, Some((0, 0)), "Escape under the update prompt deselected the comment (full screen: {full_screen})");
}
}
/// The update prompt leaves the menus on, but covers the pages as a dialog does: Edit ▸ Delete from
/// a native menu under it leaves the selected comment, which the prompt hides.
#[test]
fn edit_delete_from_a_native_menu_under_the_update_prompt_leaves_the_comment() {
let mut h = selected_square_under_the_update_prompt();
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(4);
assert_eq!(comments(&h).len(), 1, "Edit ▸ Delete under the update prompt deleted the comment");
assert!(h.query_by_label_contains("is available").is_some(), "the prompt is still open");
}
/// "Open in Full Screen mode?", asked for a document that wants it, leaves the menus on as the
/// update prompt does, and covers the pages as it does, in full screen too: neither the user's
/// Delete nor Edit ▸ Delete from a native menu removes the comment selected in another document
/// under it, and the Escape that answers the prompt is the prompt's alone.
#[test]
fn edit_delete_from_a_native_menu_under_the_full_screen_prompt_leaves_the_comment() {
for full_screen in [false, true] {
let mut h = selected_square();
if full_screen {
assert!(h.state_mut().execute("view.full_screen"));
h.run_steps(4);
assert!(h.state().full_screen, "full screen");
}
let deck = String::from_utf8(TEXT_FIXTURE.to_vec()).unwrap().replace("/Type /Catalog", "/Type /Catalog /PageMode /FullScreen").into_bytes();
h.state_mut().open_bytes("deck.pdf", None, deck).expect("the deck opens");
h.run_steps(3);
assert!(h.state().pending_full_screen.is_some(), "the full-screen prompt is open");
let ctx = h.ctx.clone();
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::with_params("window.document", serde_json::json!({ "index": 0 })));
h.run_steps(3);
assert_eq!(h.state().active, Some(0), "Window ▸ the first document switched to it");
assert!(h.state().pending_full_screen.is_some(), "the full-screen prompt is still open");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is still selected");
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "Delete under the full-screen prompt deleted the comment (full screen: {full_screen})");
h.state_mut().run_menu_command(&ctx, &craft_menu::CommandRef::new("edit.delete"));
h.run_steps(4);
assert_eq!(comments(&h).len(), 1, "Edit ▸ Delete under the full-screen prompt deleted the comment (full screen: {full_screen})");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().pending_full_screen.is_none(), "Escape answered the prompt");
assert_eq!(h.state().full_screen, full_screen, "the Escape that answered the prompt is the prompt's alone");
}
}
/// The OCR Verify screen takes the pages' place, and the pages' own chords don't reach them under
/// it: ⌘A selects none of the hidden page's text, and ⌘= leaves its zoom. Both acted on the
/// hidden page, and stayed after the screen closed.
#[test]
fn chords_on_the_ocr_verify_screen_leave_the_hidden_page() {
let mut h = harness(|_| {});
h.state_mut().ocr_verify.open = true;
h.run_steps(3);
assert!(h.state().views[0].selected_text().is_none(), "nothing is selected");
let zoom = h.state().views[0].zoom;
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(3);
assert!(h.state().views[0].selected_text().is_none(), "⌘A on the OCR Verify screen selected the hidden page's text");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Equals);
h.run_steps(3);
assert_eq!(h.state().views[0].zoom, zoom, "⌘= on the OCR Verify screen zoomed the hidden page");
h.state_mut().ocr_verify.open = false;
h.run_steps(3);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(3);
assert!(h.state().views[0].selected_text().is_some(), "with the screen closed, ⌘A selects the page's text");
}
/// The comment card's "…" menu is drawn from the button on the selected card. Undo with the menu
/// open deselects the card, and egui kept the menu open with nowhere to show it: an open popup,
/// which held every page key, H included.
#[test]
fn undo_with_a_card_menu_open_leaves_the_page_keys() {
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
drag_pt(&mut h, (170.0, 180.0), (270.0, 120.0));
assert_eq!(comments(&h).len(), 2);
h.state_mut().set_option("quick", "select").unwrap();
h.state_mut().right = Some(pdfcraft_ui_egui::RightPanel::Comments);
h.run_steps(3);
click_pt(&mut h, (250.0, 20.0));
click_pt(&mut h, (90.0, 70.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)));
h.get_by_label("More").click();
h.run_steps(2);
assert!(h.query_by_label("Set status").is_some(), "the card's menu is shown");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z);
h.run_steps(3);
assert_eq!(comments(&h).len(), 1, "Undo took the second square back");
assert_eq!(h.state().views[0].comments.selected, None, "and deselected the first");
assert!(!egui::Popup::is_any_open(&h.ctx), "the menu closed with its card's button");
h.key_press(egui::Key::H);
h.run_steps(2);
assert_eq!(h.state().quick_tool, QuickTool::Hand, "H picks the Hand tool");
}
/// ⌘C with a menu of the menu bar open is the menu's: the selected comment isn't copied.
#[test]
fn copy_with_a_menu_open_copies_no_comment() {
let mut h = selected_square();
support::open_menu(&mut h, "View");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::C);
h.run_steps(3);
assert!(h.state().comment_clipboard.is_none(), "⌘C with a menu open copied the comment");
}
/// The OCR Verify screen takes the window: an arrow there leaves the comment selected on the
/// hidden page where it is. It moved it.
#[test]
fn an_arrow_on_the_ocr_verify_screen_leaves_the_selected_comment() {
let mut h = selected_square();
let before = comments(&h)[0].rect;
h.state_mut().ocr_verify.open = true;
h.run_steps(3);
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is still selected");
assert_eq!(comments(&h)[0].rect, before, "the hidden comment moved");
}
/// #5767: Make Properties Default on a text box or callout keeps its font size too, so the next
/// one (also after a restart) takes it, along with the colour.
#[test]
fn properties_dialog_makes_the_text_box_font_size_default() {
let blue = [0.0, 0.47, 0.84];
let mut h = harness(|_| {});
let font_size_of_last = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let last = doc.info.annotations.len() - 1;
doc.comment_props(0, last).unwrap()
};
for (quick, size, first, second, third) in
[("freetext", 24.0, (40.0, 60.0), (40.0, 120.0), (40.0, 100.0)), ("callout", 18.0, (150.0, 40.0), (150.0, 100.0), (150.0, 60.0))]
{
h.state_mut().set_option("quick", quick).unwrap();
if quick == "callout" {
drag_pt(&mut h, first, (first.0 + 60.0, first.1 + 20.0));
} else {
click_pt(&mut h, first);
}
post_text(&mut h, "First");
let index = comments(&h).len() - 1;
h.state_mut().open_comment_props(0, index);
h.run_steps(2);
{
let d = h.state_mut().comment_props.as_mut().expect("open");
d.edited.font_size = Some(size);
d.edited.color = Some(blue);
}
h.get_by_label("Make Properties Default").click();
h.run_steps(1);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(font_size_of_last(&h).font_size, Some(size));
// The next one takes the font size and the colour.
h.state_mut().set_option("quick", quick).unwrap();
if quick == "callout" {
drag_pt(&mut h, second, (second.0 + 60.0, second.1 + 20.0));
} else {
click_pt(&mut h, second);
}
post_text(&mut h, "Second");
let p = font_size_of_last(&h);
assert_eq!(p.font_size, Some(size), "{quick}: the next one takes the default font size");
assert_eq!(p.color, Some(blue), "{quick}: and the default colour");
// The default survives a restart.
let json = h.state().persist();
let mut fresh = harness(move |app| {
app.restore(&json);
app.set_option("quick", quick).unwrap();
});
if quick == "callout" {
drag_pt(&mut fresh, third, (third.0 + 60.0, third.1 + 20.0));
} else {
click_pt(&mut fresh, third);
}
post_text(&mut fresh, "Third");
assert_eq!(font_size_of_last(&fresh).font_size, Some(size), "{quick}: the default font size persisted");
}
}