mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 17:01:23 +00:00
* AcroForm: integrate persistent timesheet script workflows * AcroForm: address review caching, worker stacks and live metadata * M6: address second review of AcroForm script workflows * M6: handle top-level ASI boundaries and verify cyclic calculation results * M6: bound script workers and isolate pending completions across edits * M6: make form resets visible before subsequent script reads * M6: include calculated dependents in reset comparison baselines * M6: bound reset writeback work before native completion * M6: charge expanded reset selections and field name bytes * M6: bound string conversion bytes and charge reset widgets * M6: preserve unread defaults and bound prompt text conversion * M6: bound reset calls early and preserve calculated field identity * AcroForm: address grouped calculations, field identity and widget ownership * AcroForm: retire field instances and address native review edge cases * AcroForm: route comparison Save As and Print to the source document * M6: dispatch text widget mouse-up scripts * M6: honor fit bounds for form button icons * AcroForm: bound calculation work and preserve reset and save integrity * Harden AcroForm script requests and worker recovery * Keep form callbacks and focus consistent across edits * AcroForm: preserve reset ordering and bound edit callbacks * Fix reset appearance ordering and XFA focus after row removal * Keep XFA focus indexes and stopped reset appearances consistent * Document form script execution and reset helpers * Keep script workers bounded and reject partial callback edits * Keep form events available after a document script reaches VM limits * Keep main's first-value Simple calculation and restore the calculateNow helper test AFSimple_Calculate (JavaScript) and the native Simple calculation count each field's first value again, as #5304 landed them; summing every selection of a multiple-selection list is a separate behaviour change. Restores the document-helper calculateNow test and the AFSimple_Calculate doc comment, and adds native and JavaScript multiple-selection cases. * Patch the callback field snapshot in place instead of rebuilding it Each field callback rebuilt all field states (3,129 on the benchmark form), and the retained realm held the previous snapshot, so stamping instance tokens copied it again. The runner now keeps its last snapshot and patches only the values and flags that changed; ScriptSession::stamp_fields stamps it before dispatch, the worker copies only when a token changes, and the realm drops the job's snapshot when the job ends (field identity is checked against the cached keys instead). * An oversized job script is that script's error, not a refused edit Realm::run refused a script too long or too deep to run as an execution refusal, which made the engine reject the whole edit (setting a 300 KiB calculation script failed). As on main it is now a plain script error that keeps the value; the realm is not touched. * Read a leading #! line as a comment in the chain scan; follow main's refusal and conditional limits in tests The nesting guard from #5079 now checks brackets, prefixes and conditionals; the document worker's preflight still bounds arrow-function and right-associative chains and skips a leading #! line as the guard does. A refused script is an error, not a refused edit, so the preflight test now checks the edit goes through with the error reported. --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2622 lines
124 KiB
Rust
2622 lines
124 KiB
Rust
//! Filling in a form in the real shell (egui_kittest): typing, Tab, check boxes, radios, choices.
|
||
|
||
use egui_kittest::Harness;
|
||
use egui_kittest::kittest::Queryable;
|
||
use pdfcraft_ui_egui::PdfCraftApp;
|
||
|
||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
// The whole 300×400 pt page on screen.
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
crate::support::settle(&mut h);
|
||
h
|
||
}
|
||
|
||
fn harness_with_bytes(name: &'static str, bytes: Vec<u8>) -> 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(name, None, bytes).unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
crate::support::settle(&mut h);
|
||
h
|
||
}
|
||
|
||
fn value(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec<String> {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
|
||
}
|
||
|
||
/// Click the centre of a field's widget.
|
||
fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str, widget: usize) {
|
||
let p = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, widget).expect("on screen").center()
|
||
};
|
||
click_at(h, p);
|
||
}
|
||
|
||
fn click_at(h: &mut Harness<'static, PdfCraftApp>, p: egui::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);
|
||
}
|
||
|
||
/// Rest the pointer on the centre of a field's widget long enough for a tooltip.
|
||
fn rest_on_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||
let p = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center()
|
||
};
|
||
h.hover_at(p);
|
||
for _ in 0..40 {
|
||
h.step();
|
||
std::thread::sleep(std::time::Duration::from_millis(15));
|
||
}
|
||
}
|
||
|
||
/// #3854: resting on a field shows its tooltip (`/TU`); a field without one shows none.
|
||
#[test]
|
||
fn hovering_a_field_shows_its_tooltip() {
|
||
let mut h = harness();
|
||
assert!(h.query_by_label("Your name").is_none(), "no tooltip before the hover");
|
||
rest_on_field(&mut h, "name");
|
||
assert!(h.query_all_by_label("Your name").next().is_some(), "the name field's /TU shows on hover");
|
||
rest_on_field(&mut h, "city");
|
||
assert!(h.query_by_label("Your name").is_none(), "another field doesn't keep the tooltip");
|
||
// Nor shows one of its own: none has been shown since the pointer left `name` (well past the
|
||
// tooltip delay), so this isn't only the old label going away.
|
||
assert!(egui::Tooltip::seconds_since_last_tooltip(&h.ctx) > 0.3, "a field without /TU shows no tooltip");
|
||
}
|
||
|
||
/// Give the `city` field these properties, as the Properties dialog would.
|
||
fn set_city(h: &mut Harness<'static, PdfCraftApp>, props: pdfcraft_engine::FieldProps) {
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { name: "city".into(), props: Box::new(props) });
|
||
h.run_steps(3);
|
||
}
|
||
|
||
/// A read-only field still shows its tooltip: it can't be filled, but it can be identified.
|
||
#[test]
|
||
fn a_read_only_fields_tooltip_shows_on_hover() {
|
||
let mut h = harness();
|
||
set_city(&mut h, pdfcraft_engine::FieldProps { tooltip: Some("Where you live".into()), read_only: Some(true), ..Default::default() });
|
||
rest_on_field(&mut h, "city");
|
||
assert!(h.query_all_by_label("Where you live").next().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn read_only_widgets_are_skipped_by_tab_navigation() {
|
||
let bytes = pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 7 0 R 8 0 R 9 0 R] >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 120] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 10 0 R 7 0 R 8 0 R 9 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (first) /Rect [20 80 150 100] /P 3 0 R /F 4 >>",
|
||
"<< /FT /Tx /T (locked) /Kids [6 0 R 10 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Ff 1 /Rect [20 50 150 70] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /Rect [20 50 20 50] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (off-page) /Rect [220 50 240 70] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (last) /Rect [20 20 150 40] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 35 150 45] /P 3 0 R /F 4 >>",
|
||
]);
|
||
let mut h = harness_with_bytes("readonly-widget.pdf", bytes);
|
||
{
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
let locked = doc.form.iter().find(|field| field.name == "locked").unwrap();
|
||
assert!(!locked.read_only(), "the field itself is editable");
|
||
assert!(locked.widgets[0].read_only, "only its widget is read-only");
|
||
let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info)
|
||
.into_iter()
|
||
.filter_map(|(fi, wi)| doc.form.get(fi).map(|field| (field.name.as_str(), wi)))
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(order, vec![("first", 0), ("locked", 1), ("last", 0)], "read-only, empty and off-page widgets are excluded");
|
||
}
|
||
click_field(&mut h, "first", 0);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("locked".into(), 1)), "the writable widget in the same field remains in tab order");
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("last".into(), 0)));
|
||
}
|
||
|
||
/// A `/TU` of only spaces shows no tooltip (no empty box under the pointer).
|
||
#[test]
|
||
fn a_blank_tooltip_shows_nothing() {
|
||
let mut h = harness();
|
||
set_city(&mut h, pdfcraft_engine::FieldProps { tooltip: Some(" ".into()), ..Default::default() });
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
assert_eq!(doc.form.iter().find(|f| f.name == "city").unwrap().tooltip.as_deref(), Some(" "), "the blank /TU is in the file");
|
||
rest_on_field(&mut h, "city");
|
||
assert!(egui::Tooltip::seconds_since_last_tooltip(&h.ctx).is_infinite(), "no tooltip was shown");
|
||
}
|
||
|
||
/// A text field that doesn't scroll (`DoNotScroll`) takes no more than it shows, measured as its
|
||
/// appearance draws it: a long paste keeps what fits, typing into a full field adds nothing, and
|
||
/// a field without the flag takes it all.
|
||
#[test]
|
||
fn a_field_that_doesnt_scroll_stops_taking_text_when_full() {
|
||
let mut h = harness();
|
||
set_city(
|
||
&mut h,
|
||
pdfcraft_engine::FieldProps { flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], font_size: Some(12.0), ..Default::default() },
|
||
);
|
||
let text_box = |h: &Harness<'static, PdfCraftApp>| {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box")
|
||
};
|
||
let typing = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default();
|
||
click_field(&mut h, "city", 0);
|
||
h.event(egui::Event::Text("W".repeat(80)));
|
||
h.run_steps(2);
|
||
let kept = typing(&h);
|
||
let tb = text_box(&h);
|
||
assert!(!kept.is_empty() && kept.len() < 80, "{} of 80 kept", kept.len());
|
||
assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}W")), "as much as fits: {}", kept.len());
|
||
h.event(egui::Event::Text("WWW".into()));
|
||
h.run_steps(2);
|
||
assert_eq!(typing(&h), kept, "a full field takes no more");
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "city"), [kept]);
|
||
// A field without the flag takes the whole paste.
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("W".repeat(80)));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "name"), ["W".repeat(80)]);
|
||
}
|
||
|
||
/// A multi-line field that doesn't scroll, with an automatic size, takes lines until even the
|
||
/// smallest size it shrinks to can't show them all.
|
||
#[test]
|
||
fn a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full() {
|
||
let mut h = harness();
|
||
set_city(
|
||
&mut h,
|
||
pdfcraft_engine::FieldProps {
|
||
flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)],
|
||
multiline: Some(true),
|
||
font_size: Some(0.0),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let da = {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().da.clone()
|
||
};
|
||
assert!(da.contains(" 0 Tf"), "an automatic size: {da}");
|
||
click_field(&mut h, "city", 0);
|
||
h.event(egui::Event::Text("line\n".repeat(60)));
|
||
h.run_steps(2);
|
||
let kept = h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default();
|
||
let tb = {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box")
|
||
};
|
||
let lines = kept.lines().count();
|
||
assert!(lines > 1 && lines < 60, "{lines} of 60 lines kept");
|
||
assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}\nline")), "as many lines as fit at 4 pt: {lines}");
|
||
}
|
||
|
||
/// #4296: a field turned a quarter (`/MK /R` 90) is typed into where it is, with its text running
|
||
/// up the field as the page draws it: a click near the field's top puts the caret at the end of
|
||
/// the text, where the turned text ends, not at its start.
|
||
#[test]
|
||
fn a_turned_field_places_the_caret_along_its_turned_text() {
|
||
let mut h = harness();
|
||
// Tall and narrow, with room up it for the text.
|
||
let props = pdfcraft_engine::FieldProps { rect: Some((0, [240.0, 20.0, 260.0, 170.0])), font_size: Some(10.0), ..Default::default() };
|
||
set_city(&mut h, props);
|
||
set_city(&mut h, pdfcraft_engine::FieldProps { rotation: Some((0, 90)), ..Default::default() });
|
||
click_field(&mut h, "city", 0);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
|
||
h.event(egui::Event::Text("Paris".into()));
|
||
h.run_steps(2);
|
||
let field = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "city").unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
click_at(&mut h, field.left_top() + egui::vec2(3.0, 2.0));
|
||
h.event(egui::Event::Text("X".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "city"), ["ParisX"]);
|
||
}
|
||
|
||
#[test]
|
||
fn typing_into_a_text_field_and_tabbing_on() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
assert!(h.state().views[0].forms.focus.is_some(), "the editor opened");
|
||
h.event(egui::Event::Text("Ada Lovelace".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "name"), ["Ada Lovelace"]);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("agree"), "Tab moves to the next field");
|
||
// Escape leaves the check box. It also abandons a draft.
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(2);
|
||
click_field(&mut h, "city", 0);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
|
||
h.event(egui::Event::Text("Paris".into()));
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(3);
|
||
assert!(value(&h, "city").is_empty());
|
||
assert!(h.state().views[0].forms.focus.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn check_boxes_radios_and_choices() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "agree", 0);
|
||
assert_eq!(value(&h, "agree"), ["Yes"]);
|
||
click_field(&mut h, "agree", 0);
|
||
assert!(value(&h, "agree").is_empty());
|
||
click_field(&mut h, "size", 1);
|
||
assert_eq!(value(&h, "size"), ["L"]);
|
||
click_field(&mut h, "size", 0);
|
||
assert_eq!(value(&h, "size"), ["S"]);
|
||
click_field(&mut h, "country", 0);
|
||
egui_kittest::kittest::Queryable::get_by_label(&h, "France").click();
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "country"), ["fr"]);
|
||
// Clear form takes everything back; undo restores it.
|
||
assert!(h.state_mut().execute("form.clear"));
|
||
h.run_steps(2);
|
||
assert!(value(&h, "country").is_empty() && value(&h, "size").is_empty());
|
||
h.state_mut().undo();
|
||
h.run_steps(2);
|
||
assert_eq!(value(&h, "country"), ["fr"]);
|
||
}
|
||
|
||
fn focused(h: &Harness<'static, PdfCraftApp>) -> Option<(String, usize)> {
|
||
h.state().views[0].forms.focus.as_ref().map(|f| (f.name.clone(), f.widget))
|
||
}
|
||
|
||
/// A text field, a check box, another text field and a pair of radio buttons, one under the
|
||
/// other, then two empty pages.
|
||
fn check_box_harness() -> Harness<'static, PdfCraftApp> {
|
||
let bytes = pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R 9 0 R 13 0 R] /Count 3 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (first) /Rect [20 350 280 372] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [20 310 36 326] /P 3 0 R /F 4 /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (last) /Rect [20 270 280 292] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
"<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream",
|
||
"<< /Type /Page /Parent 2 0 R >>",
|
||
"<< /FT /Btn /T (pick) /Ff 32768 /V /Off /Kids [11 0 R 12 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /AS /Off /Rect [20 230 36 246] /P 3 0 R /F 4 /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>",
|
||
"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /AS /Off /Rect [60 230 76 246] /P 3 0 R /F 4 /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>",
|
||
"<< /Type /Page /Parent 2 0 R >>",
|
||
]);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("agree.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
h
|
||
}
|
||
|
||
#[test]
|
||
fn tab_and_space_fill_in_a_check_box_between_text_fields() {
|
||
// Issue #2223: Tab went from `first` straight to `last`, and Space could not tick `agree`.
|
||
let mut h = check_box_harness();
|
||
click_field(&mut h, "first", 0);
|
||
h.event(egui::Event::Text("Ada".into()));
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "first"), ["Ada"]);
|
||
assert_eq!(focused(&h), Some(("agree".into(), 0)), "Tab stops on the check box");
|
||
assert!(h.ctx.egui_wants_keyboard_input(), "the check box has the keyboard, not the page");
|
||
h.key_press(egui::Key::Space);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "agree"), ["Yes"], "Space ticks it");
|
||
assert_eq!(focused(&h), Some(("agree".into(), 0)), "and it keeps the focus");
|
||
h.key_press(egui::Key::Space);
|
||
h.run_steps(3);
|
||
assert!(value(&h, "agree").is_empty(), "Space again clears it");
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("last".into(), 0)));
|
||
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab comes back to it");
|
||
}
|
||
|
||
#[test]
|
||
fn tab_uses_the_first_remaining_widget_when_focus_leaves_the_order() {
|
||
let mut h = check_box_harness();
|
||
click_field(&mut h, "pick", 0);
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "pick".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 230.0, 336.0, 246.0])), ..Default::default() }),
|
||
});
|
||
h.run_steps(3);
|
||
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("pick".into(), 1)), "Tab replaces the off-page focus with the group's remaining widget");
|
||
}
|
||
|
||
#[test]
|
||
fn shift_tab_uses_the_previous_remaining_widget_when_focus_leaves_the_order() {
|
||
let mut h = check_box_harness();
|
||
click_field(&mut h, "last", 0);
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "last".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 270.0, 340.0, 292.0])), ..Default::default() }),
|
||
});
|
||
h.run_steps(3);
|
||
|
||
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab restores focus to the last stop before the removed widget");
|
||
}
|
||
|
||
#[test]
|
||
fn shift_tab_wraps_to_the_last_widget_when_focus_leaves_the_order() {
|
||
let mut h = check_box_harness();
|
||
click_field(&mut h, "first", 0);
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "first".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 350.0, 340.0, 372.0])), ..Default::default() }),
|
||
});
|
||
h.run_steps(3);
|
||
|
||
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("pick".into(), 1)), "Shift+Tab wraps to the last remaining stop");
|
||
}
|
||
|
||
#[test]
|
||
fn tab_and_shift_tab_follow_widget_order_across_pages() {
|
||
let mut h = check_box_harness();
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||
page: 1,
|
||
rect: [20.0, 350.0, 180.0, 372.0],
|
||
kind: pdfcraft_engine::NewField::Text { multiline: false },
|
||
name: Some("second_page".into()),
|
||
});
|
||
h.run_steps(4);
|
||
let order = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info)
|
||
.into_iter()
|
||
.filter_map(|(fi, wi)| doc.form.get(fi).map(|field| (field.name.clone(), wi)))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(order.first().map(|entry| entry.0.as_str()), Some("first"));
|
||
assert_eq!(order.last().map(|entry| entry.0.as_str()), Some("second_page"));
|
||
click_field(&mut h, "first", 0);
|
||
for expected in order.iter().skip(1) {
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert_eq!(focused(&h), Some(expected.clone()), "forward traversal: {order:?}");
|
||
}
|
||
for expected in order.iter().rev().skip(1) {
|
||
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert_eq!(focused(&h), Some(expected.clone()), "reverse traversal: {order:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box() {
|
||
// Issue #2223: the click ticked the box but left no field focused, so Tab started over.
|
||
let mut h = check_box_harness();
|
||
click_field(&mut h, "first", 0);
|
||
h.event(egui::Event::Text("Ada".into()));
|
||
h.run_steps(1);
|
||
click_field(&mut h, "agree", 0);
|
||
assert_eq!(value(&h, "agree"), ["Yes"]);
|
||
assert_eq!(value(&h, "first"), ["Ada"], "the text typed before the click is kept");
|
||
assert_eq!(focused(&h), Some(("agree".into(), 0)), "the click focuses the check box");
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("last".into(), 0)), "so Tab goes on from there");
|
||
}
|
||
|
||
#[test]
|
||
fn page_keys_still_work_with_a_check_box_or_radio_button_focused() {
|
||
// The widget that takes Space for a focused box must not swallow the page keys.
|
||
let mut h = check_box_harness();
|
||
click_field(&mut h, "agree", 0);
|
||
assert_eq!(focused(&h), Some(("agree".into(), 0)));
|
||
h.key_press(egui::Key::End);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 2, "End goes to the last page");
|
||
assert!(h.state().views[0].page_screen_rect(0).is_none(), "where the check box is out of view");
|
||
// Scrolled away, the box lets go: back on its page, Space no longer toggles it.
|
||
h.key_press(egui::Key::Home);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 0);
|
||
assert_eq!(focused(&h), None, "the box let go of the focus");
|
||
h.key_press(egui::Key::Space);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "agree"), ["Yes"]);
|
||
// A radio button keeps the arrows for its group, so → selects B instead of turning the page.
|
||
click_field(&mut h, "pick", 0);
|
||
h.key_press(egui::Key::ArrowRight);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "pick"), ["B"]);
|
||
assert_eq!(h.state().views[0].current, 0, "the arrow stayed in the group");
|
||
// Once scrolled away, an arrow scrolls again instead of changing the choice.
|
||
h.key_press(egui::Key::End);
|
||
h.run_steps(4);
|
||
h.key_press(egui::Key::Home);
|
||
h.run_steps(4);
|
||
h.key_press(egui::Key::ArrowUp);
|
||
h.run_steps(3);
|
||
assert_eq!((focused(&h), value(&h, "pick")), (None, vec!["B".to_string()]));
|
||
}
|
||
|
||
#[test]
|
||
fn radio_buttons_take_tab_space_and_arrows() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "agree", 0);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("size".into(), 0)));
|
||
h.key_press(egui::Key::Space);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "size"), ["S"], "Space selects the focused button");
|
||
h.key_press(egui::Key::Space);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "size"), ["S"], "and doesn't turn it off again");
|
||
h.key_press(egui::Key::ArrowRight);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("size".into(), 1)), "an arrow moves within the group");
|
||
assert_eq!(value(&h, "size"), ["L"], "and selects the button it lands on");
|
||
h.key_press(egui::Key::ArrowLeft);
|
||
h.run_steps(3);
|
||
assert_eq!((focused(&h), value(&h, "size")), (Some(("size".into(), 0)), vec!["S".to_string()]));
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("country".into(), 0)), "past the group's second button");
|
||
}
|
||
|
||
#[test]
|
||
fn tabbing_into_a_filled_field_selects_it_so_typing_replaces() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "country", 0);
|
||
egui_kittest::kittest::Queryable::get_by_label(&h, "Canada").click();
|
||
h.run_steps(3);
|
||
click_field(&mut h, "city", 0);
|
||
h.event(egui::Event::Text("Paris".into()));
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "city"), ["Paris"]);
|
||
// name → (Tab) agree → (Tab) size, both buttons → (Tab) country → (Tab) city, whose text is
|
||
// selected on entry.
|
||
click_field(&mut h, "name", 0);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("agree".into(), 0)));
|
||
for _ in 0..3 {
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(3);
|
||
}
|
||
assert_eq!(focused(&h), Some(("country".into(), 0)));
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
|
||
h.event(egui::Event::Text("Lyon".into()));
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "city"), ["Lyon"]);
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() {
|
||
// Issue #2213: with the caret in one field, a drag over another field's text selected
|
||
// nothing; the field opened only on a click.
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("Ada Lovelace".into()));
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
click_field(&mut h, "city", 0);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
|
||
let (rect, id) = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "name").unwrap();
|
||
let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen");
|
||
(rect, egui::Id::unique(("form-field", s.views[0].view_key(), "name", 0usize)))
|
||
};
|
||
// From inside the first word ("Ad|a" at the field's auto size, 13.9 pt) to past the end of the text.
|
||
let (from, to) = (egui::pos2(rect.left() + 38.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y));
|
||
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.lerp(to, k as f32 / 4.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(to);
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("name"), "the press moved the focus");
|
||
let n = "Ada Lovelace".chars().count();
|
||
let range = egui::TextEdit::load_state(&h.ctx, id).and_then(|s| s.cursor.char_range()).expect("a selection in the field");
|
||
// "Ad|a": not the caret at the end that a newly focused editor gets on its own.
|
||
assert_eq!(range.secondary.index.0, 2, "anchored where the drag started: {range:?}");
|
||
assert_eq!(range.primary.index.0, n, "and extended to where it ended: {range:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn date_fields_offer_a_calendar() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness();
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||
page: 0,
|
||
rect: [50.0, 40.0, 200.0, 60.0],
|
||
kind: pdfcraft_engine::NewField::Date,
|
||
name: Some("due".into()),
|
||
});
|
||
h.run_steps(4);
|
||
click_field(&mut h, "due", 0);
|
||
let (y, m, _) = h.state().session.today();
|
||
let month =
|
||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][(m - 1) as usize];
|
||
h.get_by_label(&format!("{month} {y}"));
|
||
h.get_by_label("›").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("15").click();
|
||
h.run_steps(4);
|
||
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
|
||
assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "picked in the field's format");
|
||
}
|
||
|
||
/// The calendar used to keep the month it opened on, so a date typed while it was open showed
|
||
/// only after leaving the field and coming back.
|
||
#[test]
|
||
fn the_calendar_follows_a_complete_date_typed_while_it_is_open() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness();
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||
page: 0,
|
||
rect: [50.0, 40.0, 200.0, 60.0],
|
||
kind: pdfcraft_engine::NewField::Date,
|
||
name: Some("due".into()),
|
||
});
|
||
h.run_steps(4);
|
||
click_field(&mut h, "due", 0);
|
||
let (y, m, _) = h.state().session.today();
|
||
let month =
|
||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][(m - 1) as usize];
|
||
h.get_by_label(&format!("{month} {y}"));
|
||
// Half a date leaves the calendar where it is.
|
||
h.event(egui::Event::Text("03/1".into()));
|
||
h.run_steps(2);
|
||
h.get_by_label(&format!("{month} {y}"));
|
||
// The whole date moves it there.
|
||
h.event(egui::Event::Text("4/2025".into()));
|
||
h.run_steps(2);
|
||
h.get_by_label("March 2025");
|
||
// The arrows still turn the months, and the calendar stays on their month while the text
|
||
// doesn't change.
|
||
h.get_by_label("›").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("April 2025");
|
||
}
|
||
|
||
/// A date format with its fields side by side ("yyyymmdd") refused every typed date, and its
|
||
/// calendar opened on today's month.
|
||
#[test]
|
||
fn an_undelimited_date_field_takes_a_typed_date_and_its_calendar_opens_on_it() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness();
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||
page: 0,
|
||
rect: [50.0, 40.0, 200.0, 60.0],
|
||
kind: pdfcraft_engine::NewField::Date,
|
||
name: Some("due".into()),
|
||
});
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "due".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps {
|
||
format: Some(pdfcraft_engine::form_scripts::Format::Date("yyyymmdd".into())),
|
||
..Default::default()
|
||
}),
|
||
}));
|
||
h.run_steps(4);
|
||
click_field(&mut h, "due", 0);
|
||
h.event(egui::Event::Text("20250314".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().toast, None, "the typed date was not refused");
|
||
assert_eq!(value(&h, "due"), ["20250314"]);
|
||
click_field(&mut h, "due", 0);
|
||
h.get_by_label("March 2025");
|
||
}
|
||
|
||
/// #4274: the calendar had no way to pick today; from another month the only way back was the
|
||
/// arrows. Today is picked in each field's own format, whichever month the calendar shows, and
|
||
/// is one edit as a clicked day is.
|
||
#[test]
|
||
fn the_calendar_picks_today_in_the_fields_format_from_any_month() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness();
|
||
// 2023-11-14 on the injected clock.
|
||
let s = h.state_mut();
|
||
s.session = std::mem::take(&mut s.session).with_clock(|| 1_700_000_000);
|
||
assert_eq!(h.state().session.today(), (2023, 11, 14));
|
||
for (name, rect) in [("due", [50.0, 40.0, 200.0, 60.0]), ("start", [50.0, 80.0, 200.0, 100.0])] {
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { page: 0, rect, kind: pdfcraft_engine::NewField::Date, name: Some(name.into()) });
|
||
}
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "start".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps {
|
||
format: Some(pdfcraft_engine::form_scripts::Format::Date("yyyy-mm-dd".into())),
|
||
..Default::default()
|
||
}),
|
||
}));
|
||
h.run_steps(4);
|
||
// Two months on, and two months back: Today still picks the 14th of November.
|
||
for (name, arrow, shown, want) in [("due", "›", "January 2024", "11/14/2023"), ("start", "‹", "September 2023", "2023-11-14")] {
|
||
click_field(&mut h, name, 0);
|
||
h.get_by_label("November 2023");
|
||
for _ in 0..2 {
|
||
h.get_by_label(arrow).click();
|
||
h.run_steps(2);
|
||
}
|
||
h.get_by_label(shown);
|
||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||
h.render().unwrap().save(format!("{dir}/calendar-today-{name}.png")).unwrap();
|
||
}
|
||
h.get_by_label("Today").click();
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, name), [want], "today in {name}'s format");
|
||
assert!(h.state().views[0].forms.focus.is_none(), "picking closes the field, as a clicked day does");
|
||
assert!(h.query_by_label("Today").is_none(), "and its calendar");
|
||
}
|
||
// One edit each, as a clicked day is.
|
||
h.state_mut().undo();
|
||
h.run_steps(2);
|
||
assert_eq!(value(&h, "start"), Vec::<String>::new());
|
||
assert_eq!(value(&h, "due"), ["11/14/2023"]);
|
||
}
|
||
|
||
#[test]
|
||
fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() {
|
||
if crate::support::skip_without_catalogs("lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values") {
|
||
return;
|
||
}
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness();
|
||
h.state_mut().set_option("language", "lt").unwrap();
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||
page: 0,
|
||
rect: [50.0, 40.0, 200.0, 60.0],
|
||
kind: pdfcraft_engine::NewField::Date,
|
||
name: Some("due".into()),
|
||
});
|
||
h.run_steps(4);
|
||
click_field(&mut h, "due", 0);
|
||
let (y, m, _) = h.state().session.today();
|
||
let months = ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"];
|
||
h.get_by_label(&format!("{y} m. {}", months[(m - 1) as usize]));
|
||
for day in ["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"] {
|
||
h.get_by_label(day);
|
||
}
|
||
h.get_by_label("›").click();
|
||
h.run_steps(2);
|
||
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
|
||
h.get_by_label(&format!("{ny} m. {}", months[(nm - 1) as usize]));
|
||
h.get_by_label("15").click();
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "the PDF field keeps its own date format");
|
||
}
|
||
|
||
/// The value of `name` in a saved PDF, read back by opening it in a fresh app.
|
||
fn saved_value(path: &std::path::Path, name: &str) -> Vec<String> {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("saved.pdf", None, std::fs::read(path).unwrap()).unwrap();
|
||
app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
|
||
}
|
||
|
||
/// Step frames until the save has written `path`, instead of assuming it is there after a fixed
|
||
/// number of frames. The write is atomic, so the file appears only when it is complete.
|
||
fn wait_for_file(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) {
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||
while !path.exists() {
|
||
assert!(std::time::Instant::now() < deadline, "the save never wrote {}", path.display());
|
||
h.run_steps(1);
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
}
|
||
// One more frame so the app has seen its own save before the test looks at its state.
|
||
h.run_steps(1);
|
||
}
|
||
|
||
/// A fresh folder of its own for one test. The tests of this binary share one process id and run
|
||
/// in parallel, and two of them name their folder "same-frame": a number taken per call keeps one
|
||
/// test from clearing the folder another is saving into.
|
||
fn scratch(name: &str) -> std::path::PathBuf {
|
||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}-{n}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
dir
|
||
}
|
||
|
||
#[test]
|
||
fn saving_while_still_typing_in_a_field_saves_the_text() {
|
||
// Issue #166: ⌘S with the field still focused saved it blank.
|
||
let dir = scratch("save-focused");
|
||
let out = dir.join("saved.pdf");
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("Browser Form 123".into()));
|
||
h.run_steps(2);
|
||
assert!(h.state().views[0].forms.focus.is_some(), "still typing");
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
|
||
h.run_steps(3);
|
||
assert_eq!(saved_value(&out, "name"), ["Browser Form 123"], "the saved PDF has the typed text");
|
||
assert_eq!(value(&h, "name"), ["Browser Form 123"]);
|
||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||
assert!(!doc.dirty, "and the document is saved, not left with an unsaved change");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn tabbing_back_to_the_same_field_then_saving_keeps_the_committed_value() {
|
||
// A form with one text field wraps Tab back to that field. Multiple widgets of one
|
||
// field share its value too: opening the next widget must see the accepted edit.
|
||
for (case, shared_widgets, backwards, same_frame) in [
|
||
("single-forward", false, false, false),
|
||
("single-backward", false, true, false),
|
||
("shared-forward", true, false, false),
|
||
("shared-backward", true, true, false),
|
||
("same-frame", false, false, true),
|
||
] {
|
||
let dir = scratch(case);
|
||
let out = dir.join("saved.pdf");
|
||
let bytes = if shared_widgets {
|
||
pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
|
||
"<< /FT /Tx /T (qty) /V (4) /Kids [6 0 R 7 0 R] >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
"<< /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [20 350 280 372] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [20 250 280 272] /P 3 0 R /F 4 >>",
|
||
])
|
||
} else {
|
||
validated_form()
|
||
};
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("tab.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
click_field(&mut h, "qty", 0);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.event(egui::Event::Text("42".into()));
|
||
h.run_steps(2);
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
let modifiers = if backwards { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
|
||
if same_frame {
|
||
one_frame(&mut h, vec![key(egui::Key::Tab, modifiers), key(egui::Key::S, egui::Modifiers::COMMAND)]);
|
||
} else {
|
||
h.key_press_modifiers(modifiers, egui::Key::Tab);
|
||
h.run_steps(3);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
|
||
}
|
||
wait_for_file(&mut h, &out);
|
||
assert_eq!(saved_value(&out, "qty"), ["42"], "{case}: saving cannot restore the old value");
|
||
assert_eq!(value(&h, "qty"), ["42"], "{case}");
|
||
// One typing action is one history entry, even after saving. Escape closes the
|
||
// untouched editor before undoing the document rather than the TextEdit buffer.
|
||
h.key_press(egui::Key::Escape);
|
||
h.state_mut().undo();
|
||
assert_eq!(value(&h, "qty"), ["4"], "{case}: a single undo restores the original");
|
||
h.state_mut().redo();
|
||
assert_eq!(value(&h, "qty"), ["42"], "{case}: redo restores the committed value");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn closing_while_still_typing_in_a_field_asks_to_save() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("Not lost".into()));
|
||
h.run_steps(2);
|
||
h.state_mut().request_close_tab(0);
|
||
assert!(h.state().close_request.is_some(), "typed text is an unsaved change: the user is asked");
|
||
assert_eq!(h.state().views.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn close_all_saves_text_typed_in_a_tab_that_is_no_longer_active() {
|
||
let dir = scratch("close-all");
|
||
let out = dir.join("first.pdf");
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("Typed in tab one".into()));
|
||
h.run_steps(2);
|
||
// Another document becomes active while the first one's field still has its draft.
|
||
h.state_mut().open_bytes("other.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||
h.run_steps(2);
|
||
h.state_mut().close_all();
|
||
assert!(h.state().close_request.is_some(), "the first document has an unsaved change");
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
let ctx = h.ctx.clone();
|
||
h.state_mut().resolve_close(&ctx, Some(true));
|
||
assert_eq!(saved_value(&out, "name"), ["Typed in tab one"]);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// A key press as one event (kittest's `key_press` runs a frame per queued event).
|
||
fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event {
|
||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
||
}
|
||
|
||
/// Deliver `events` together, in one frame.
|
||
fn one_frame(h: &mut Harness<'static, PdfCraftApp>, events: Vec<egui::Event>) {
|
||
h.input_mut().events.extend(events);
|
||
h.run_steps(1);
|
||
}
|
||
|
||
/// Type `text` into field `name`, then press ⌘S in the same frame as `last` (more input).
|
||
fn type_then_save_in_one_frame(h: &mut Harness<'static, PdfCraftApp>, name: &str, text: &str, last: egui::Event, out: &std::path::Path) {
|
||
click_field(h, name, 0);
|
||
h.event(egui::Event::Text(text.into()));
|
||
h.run_steps(2);
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
one_frame(h, vec![last, key(egui::Key::S, egui::Modifiers::COMMAND)]);
|
||
h.run_steps(3);
|
||
}
|
||
|
||
#[test]
|
||
fn typing_and_saving_in_the_same_frame_saves_the_last_keystrokes() {
|
||
let dir = scratch("same-frame");
|
||
let out = dir.join("saved.pdf");
|
||
let mut h = harness();
|
||
type_then_save_in_one_frame(&mut h, "name", "Browser", egui::Event::Text(" Form 123".into()), &out);
|
||
assert_eq!(saved_value(&out, "name"), ["Browser Form 123"]);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn escape_and_save_in_the_same_frame_discards_the_draft() {
|
||
let dir = scratch("escape");
|
||
let out = dir.join("saved.pdf");
|
||
let mut h = harness();
|
||
let before = value(&h, "name");
|
||
type_then_save_in_one_frame(&mut h, "name", "Discard me", key(egui::Key::Escape, egui::Modifiers::NONE), &out);
|
||
assert_eq!(value(&h, "name"), before, "Escape cancels typing, even with ⌘S right after it");
|
||
assert!(h.state().views[0].forms.focus.is_none());
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unrelated_command_leaves_the_field_being_typed_in_alone() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("Still typing".into()));
|
||
h.run_steps(2);
|
||
assert!(h.state_mut().execute("view.theme.dark"));
|
||
h.run_steps(2);
|
||
let focus = h.state().views[0].forms.focus.clone().expect("the editor stays open");
|
||
assert_eq!(focus.text, "Still typing");
|
||
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "nothing was committed");
|
||
}
|
||
|
||
#[test]
|
||
fn exporting_form_data_while_typing_includes_the_text() {
|
||
let dir = scratch("export");
|
||
let out = dir.join("data.xfdf");
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("Exported value".into()));
|
||
h.run_steps(2);
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
h.state_mut().export_data_dialog(false, true);
|
||
assert!(std::fs::read_to_string(&out).unwrap().contains("Exported value"));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// One text field, `qty`, whose validation script refuses values over 100.
|
||
fn validated_form() -> Vec<u8> {
|
||
pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (qty) /V (4) /Rect [20 350 280 372] /P 3 0 R /F 4 /AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >> >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
])
|
||
}
|
||
|
||
#[test]
|
||
fn pressing_another_text_field_validates_the_refused_value_once() {
|
||
// The press commits `qty`; its release must not commit (and alert) a second time.
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("order.pdf", None, validated_form_with_note()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
click_field(&mut h, "qty", 0);
|
||
h.event(egui::Event::Text("500".into()));
|
||
h.run_steps(2);
|
||
let p = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "note").unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center()
|
||
};
|
||
h.hover_at(p);
|
||
h.run_steps(1);
|
||
h.drag_at(p);
|
||
h.run_steps(2);
|
||
assert!(h.state().toast.is_some(), "the press committed 4500 and the script refused it");
|
||
h.state_mut().toast = None;
|
||
h.drop_at(p);
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().toast, None, "the release did not validate it again");
|
||
let focus = h.state().views[0].forms.focus.clone().expect("the refused field stays open");
|
||
assert_eq!((focus.name.as_str(), focus.text.as_str()), ("qty", "4500"));
|
||
}
|
||
|
||
/// `validated_form` plus a plain text field, `note`, under `qty`.
|
||
fn validated_form_with_note() -> Vec<u8> {
|
||
pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (qty) /V (4) /Rect [20 350 280 372] /P 3 0 R /F 4 /AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >> >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /Rect [20 300 280 322] /P 3 0 R /F 4 >>",
|
||
])
|
||
}
|
||
|
||
fn pdf_from_objects(objs: &[&str]) -> Vec<u8> {
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offsets.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let xref = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offsets {
|
||
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{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
/// Fields that sit on text, where the text layer would show its I-beam: a checked box drawn
|
||
/// with an "X" (appearance text is page text), an empty check box and a text field over printed
|
||
/// words (character-sheet dots are set on printed "OOOOO"), and a printed word with no field.
|
||
fn fields_over_text() -> Vec<u8> {
|
||
let content = "BT /F1 12 Tf 20 250 Td (Strength) Tj ET BT /F1 12 Tf 20 200 Td (Background) Tj ET BT /F1 12 Tf 20 150 Td (Notes) Tj ET";
|
||
let check = "q BT /Helv 11 Tf 2 2 Td (X) Tj ET Q";
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R /ZaDb 9 0 R >> >> >> >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R /Resources << /Font << /F1 8 0 R >> >> /Annots [4 0 R 5 0 R 6 0 R] >>".to_string(),
|
||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Yes /AS /Yes /Rect [20 300 32 312] /P 3 0 R /F 4 /DA (/Helv 11 Tf 0 g) /AP << /N << /Yes 10 0 R >> >> >>".to_string(),
|
||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (strength) /V /Off /AS /Off /Rect [20 246 70 262] /P 3 0 R /F 4 /MK << /CA (l) >> /DA (/ZaDb 11 Tf 0 g) >>".to_string(),
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (background) /Rect [20 196 200 214] /P 3 0 R /F 4 >>".to_string(),
|
||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>".to_string(),
|
||
format!(
|
||
"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Resources << /Font << /Helv 8 0 R >> >> /Length {} >>\nstream\n{check}\nendstream",
|
||
check.len() + 1
|
||
),
|
||
];
|
||
pdf_from_objects(&objs.iter().map(String::as_str).collect::<Vec<_>>())
|
||
}
|
||
|
||
#[test]
|
||
fn buttons_over_text_show_the_pointing_hand_not_the_i_beam() {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("sheet.pdf", None, fields_over_text()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
let (agree, strength, background) = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let at = |name: &str| {
|
||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
(at("agree"), at("strength"), at("background"))
|
||
};
|
||
let cursor_at = |h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2| {
|
||
h.hover_at(p);
|
||
h.run_steps(2);
|
||
h.output().platform_output.cursor_icon
|
||
};
|
||
// "Notes" (baseline y 150) lies 50 pt below "Background" (y 200), with no field on it.
|
||
// Wait for the page text: the I-beam over it shows the text layer is live.
|
||
let scale = background.width() / 180.0;
|
||
let notes = egui::pos2(background.left() + 12.0 * scale, background.bottom() + (196.0 - 154.0) * scale);
|
||
let mut over_notes = egui::CursorIcon::Default;
|
||
for _ in 0..100 {
|
||
over_notes = cursor_at(&mut h, notes);
|
||
if over_notes == egui::CursorIcon::Text {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
}
|
||
assert_eq!(over_notes, egui::CursorIcon::Text, "printed text off any field shows the I-beam");
|
||
use egui::CursorIcon::{PointingHand, Text};
|
||
let over_fields = [agree, strength, background].map(|r| cursor_at(&mut h, r.center()));
|
||
assert_eq!(over_fields, [PointingHand, PointingHand, Text], "check boxes are buttons, not text to select; a text field keeps the I-beam");
|
||
// Clicking still toggles them.
|
||
click_field(&mut h, "agree", 0);
|
||
assert!(value(&h, "agree").is_empty());
|
||
click_field(&mut h, "strength", 0);
|
||
assert_eq!(value(&h, "strength"), ["Yes"]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_rejected_value_keeps_the_typing_and_stops_the_save() {
|
||
let dir = scratch("rejected");
|
||
let out = dir.join("saved.pdf");
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("order.pdf", None, validated_form()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
click_field(&mut h, "qty", 0);
|
||
h.event(egui::Event::Text("500".into()));
|
||
h.run_steps(2);
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
|
||
h.run_steps(4);
|
||
assert!(!out.exists(), "nothing is saved without the typed value");
|
||
assert_eq!(value(&h, "qty"), ["4"], "the field keeps its valid value");
|
||
let focus = h.state().views[0].forms.focus.clone().expect("the editor stays open");
|
||
assert_eq!(focus.text, "4500", "with the text (typed after the 4), so the user can fix it");
|
||
// And closing asks rather than dropping it.
|
||
h.state_mut().request_close_tab(0);
|
||
assert!(h.state().close_request.is_some());
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_save_shortcut_pressed_while_typing_saves_only_the_document_it_was_pressed_in() {
|
||
let dir = scratch("deferred-doc");
|
||
let out = dir.join("saved.pdf");
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("Tab one".into()));
|
||
h.run_steps(2);
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
one_frame(&mut h, vec![key(egui::Key::S, egui::Modifiers::COMMAND)]);
|
||
assert!(!out.exists(), "⌘S while typing waits a frame for the field");
|
||
// Before the deferred ⌘S runs, another document becomes active.
|
||
h.state_mut().open_bytes("other.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
|
||
h.run_steps(3);
|
||
assert!(!out.exists(), "the other document is not saved by a shortcut pressed in the first");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() {
|
||
refused_value_stops_save(egui::Key::Enter, "enter-refused");
|
||
}
|
||
|
||
#[test]
|
||
fn a_value_refused_on_tab_keeps_save_in_the_same_frame_from_running() {
|
||
refused_value_stops_save(egui::Key::Tab, "tab-refused");
|
||
}
|
||
|
||
fn refused_value_stops_save(commit_key: egui::Key, case: &str) {
|
||
let dir = scratch(case);
|
||
let out = dir.join("saved.pdf");
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("order.pdf", None, validated_form()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
click_field(&mut h, "qty", 0);
|
||
h.event(egui::Event::Text("500".into()));
|
||
h.run_steps(2);
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
one_frame(&mut h, vec![key(commit_key, egui::Modifiers::NONE), key(egui::Key::S, egui::Modifiers::COMMAND)]);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "qty"), ["4"]);
|
||
assert!(!out.exists(), "nothing is saved right after the field refused its value");
|
||
// The refused text is back in the field, so a later ⌘S stops again instead of saving 4.
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some("4500"));
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
|
||
h.run_steps(4);
|
||
assert!(!out.exists());
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn flattening_while_typing_flattens_the_typed_text() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("Flat Ada".into()));
|
||
h.run_steps(2);
|
||
assert!(h.state_mut().execute("form.flatten"));
|
||
h.run_steps(2);
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
assert!(doc.form.iter().all(|f| f.name != "name"), "the field is flattened");
|
||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||
let text = r
|
||
.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() })
|
||
.text
|
||
.map(|t| t.plain_text())
|
||
.unwrap_or_default();
|
||
assert!(text.contains("Flat Ada"), "into the page: {text:?}");
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
mod revert_tests {
|
||
use super::*;
|
||
use egui_kittest::kittest::Queryable;
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
|
||
struct RevertDir(PathBuf);
|
||
impl RevertDir {
|
||
fn new() -> Self {
|
||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||
for _ in 0..128 {
|
||
let path = std::env::temp_dir().join(format!("pdfcraft-revert-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||
match std::fs::create_dir(&path) {
|
||
Ok(()) => return Self(path),
|
||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||
Err(error) => panic!("creating Revert test directory: {error}"),
|
||
}
|
||
}
|
||
panic!("no unused Revert test directory after 128 attempts");
|
||
}
|
||
}
|
||
impl Drop for RevertDir {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn reverting_rejected_input_saves_the_original_field_and_appearance() {
|
||
let dir = RevertDir::new();
|
||
let path = dir.0.join("saved.pdf");
|
||
let mut h = harness();
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue {
|
||
name: "name".into(),
|
||
value: pdfcraft_engine::FieldValue::Text("Saved original".into()),
|
||
}));
|
||
h.state_mut().save_override = Some(path.to_string_lossy().into_owned());
|
||
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::InPlace));
|
||
let saved = std::fs::read(&path).unwrap();
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "name".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps {
|
||
validate: Some(pdfcraft_engine::form_scripts::Validate::Range { min: Some(0.0), max: Some(10.0) }),
|
||
..Default::default()
|
||
}),
|
||
}));
|
||
h.run_steps(3);
|
||
click_field(&mut h, "name", 0);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.event(egui::Event::Text("99".into()));
|
||
h.run_steps(2);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().unwrap().text, "99");
|
||
assert_eq!(std::fs::read(&path).unwrap(), saved, "rejection leaves the saved file untouched");
|
||
assert!(h.state_mut().execute("file.revert"));
|
||
h.run_steps(2);
|
||
h.get_all_by_label("Revert").last().expect("confirmation button").click();
|
||
h.run_steps(4);
|
||
assert!(h.state().views[0].forms.focus.is_none() && h.state().views[0].pending_edit.is_none());
|
||
assert_eq!(h.state().first_dirty(), None, "Revert discards rejected typing too");
|
||
assert_eq!(value(&h, "name"), ["Saved original"]);
|
||
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::InPlace));
|
||
assert_eq!(saved_value(&path, "name"), ["Saved original"]);
|
||
let bytes = std::fs::read(&path).unwrap();
|
||
assert_eq!(bytes, saved, "later Save cannot resurrect the discarded draft");
|
||
let mut renderer = pdfcraft_render::PageRenderer::new(std::sync::Arc::new(bytes), Default::default());
|
||
let output = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, ..Default::default() });
|
||
assert!(output.error.is_none(), "saved appearance is readable: {:?}", output.error);
|
||
assert!(output.text.unwrap().plain_text().contains("Saved original"), "saved AP still draws the original value");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn calculated_field_and_script_side_effect_on_another_page_re_render() {
|
||
let mut h = harness();
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 300.0, height: 400.0 }));
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||
page: 1,
|
||
rect: [20.0, 20.0, 100.0, 40.0],
|
||
kind: pdfcraft_engine::NewField::Text { multiline: false },
|
||
name: Some("page2_field".into()),
|
||
}));
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldScript {
|
||
name: "page2_field".into(),
|
||
event: "calculate".into(),
|
||
script: Some("event.value = 'from_' + getField('name').value;".into()),
|
||
}));
|
||
crate::support::settle(&mut h);
|
||
click_field(&mut h, "name", 0);
|
||
h.event(egui::Event::Text("hello".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(2);
|
||
assert_eq!(value(&h, "page2_field"), ["from_hello"]);
|
||
// Wait for all re-renders to complete.
|
||
crate::support::settle(&mut h);
|
||
assert_eq!(value(&h, "name"), ["hello"]);
|
||
assert_eq!(value(&h, "page2_field"), ["from_hello"]);
|
||
}
|
||
|
||
#[test]
|
||
fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() {
|
||
let mut h = harness();
|
||
// 1. Fresh tiles: edit puts field in unbaked, and after tiles arrive, unbaked clears.
|
||
click_field(&mut h, "name", 0);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.event(egui::Event::Text("GoodVal".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(2);
|
||
assert_eq!(value(&h, "name"), ["GoodVal"]);
|
||
crate::support::settle(&mut h);
|
||
assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)));
|
||
|
||
// 2. Rejected edit: typing rejected by validate script leaves unbaked clean.
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldScript {
|
||
name: "city".into(),
|
||
event: "validate".into(),
|
||
script: Some("event.rc = false;".into()),
|
||
}));
|
||
click_field(&mut h, "city", 0);
|
||
h.event(egui::Event::Text("BadVal".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(2);
|
||
assert!(!h.state().views[0].forms.unbaked.contains(&("city".to_string(), 0)));
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(2);
|
||
|
||
// 3. Zoom then edit: zoom to another scale, let tiles settle, then edit.
|
||
h.state_mut().views[0].zoom_step(true);
|
||
crate::support::settle(&mut h);
|
||
click_field(&mut h, "name", 0);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.event(egui::Event::Text("ZoomedVal".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(2);
|
||
assert_eq!(value(&h, "name"), ["ZoomedVal"]);
|
||
crate::support::settle(&mut h);
|
||
assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0)));
|
||
}
|
||
|
||
/// A field whose /P is a page number past the document's end (#1307) is listed under
|
||
/// "Unplaced" instead of panicking while the Fields panel labels its page.
|
||
#[test]
|
||
fn the_fields_panel_lists_a_field_on_a_missing_page_as_unplaced() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let bytes = pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 10 100 30] /P 99 /F 4 >>",
|
||
]);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("orphan.pdf", None, bytes).unwrap();
|
||
app.set_option("panel", "fields").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
h.get_by_label("Unplaced");
|
||
assert_eq!(h.query_all_by_label("orphan").count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn list_box_clicks_select_directly_and_ctrl_click_toggles_multi() {
|
||
let bytes = pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (ListOne) /Opt [(Red) (Green) (Blue)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (ListMulti) /Ff 2097152 /Opt [(Mon) (Tue) (Wed) (Thu) (Fri)] /Rect [50 100 200 170] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
]);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("lists.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "100").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
|
||
// Click row 1 (Green) in ListOne
|
||
let r_one = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "ListOne").unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
let zoom_one = r_one.height() / 70.0;
|
||
let p_green = egui::pos2(r_one.center().x, r_one.min.y + 19.0 * zoom_one);
|
||
h.hover_at(p_green);
|
||
h.run_steps(1);
|
||
h.drag_at(p_green);
|
||
h.run_steps(1);
|
||
h.drop_at(p_green);
|
||
h.run_steps(3);
|
||
|
||
assert_eq!(value(&h, "ListOne"), ["Green"], "clicking row 1 in ListOne selected Green");
|
||
|
||
// Click row 1 (Tue) in ListMulti
|
||
let r_multi = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "ListMulti").unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
let zoom_multi = r_multi.height() / 70.0;
|
||
let p_tue = egui::pos2(r_multi.center().x, r_multi.min.y + 19.0 * zoom_multi);
|
||
h.hover_at(p_tue);
|
||
h.run_steps(1);
|
||
h.drag_at(p_tue);
|
||
h.run_steps(1);
|
||
h.drop_at(p_tue);
|
||
h.run_steps(3);
|
||
|
||
assert_eq!(value(&h, "ListMulti"), ["Tue"], "clicking row 1 in ListMulti selected Tue");
|
||
|
||
// Ctrl/Cmd-click row 3 (Thu) in ListMulti
|
||
let p_thu = egui::pos2(r_multi.center().x, r_multi.min.y + 42.0 * zoom_multi);
|
||
h.hover_at(p_thu);
|
||
h.run_steps(1);
|
||
h.event(egui::Event::PointerMoved(p_thu));
|
||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND));
|
||
h.event(egui::Event::PointerButton { pos: p_thu, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND });
|
||
h.run_steps(1);
|
||
h.event(egui::Event::PointerButton { pos: p_thu, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::COMMAND });
|
||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||
h.run_steps(3);
|
||
|
||
assert_eq!(value(&h, "ListMulti"), ["Tue", "Thu"], "Ctrl-clicking Thu toggled it on, keeping Tue");
|
||
}
|
||
|
||
/// The selected option positions of `name` (`/I`, or `/V` without a usable one).
|
||
fn selected(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec<usize> {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().selected.clone()
|
||
}
|
||
|
||
/// ISO 32000-2 Table 234: `/I` tells apart options that share an export value. A list box whose
|
||
/// One-A and One-B both export `1` shows the one `/I` names, and a click picks the row clicked:
|
||
/// `/V (1)` with `/I` naming it, so the pick survives a save and reopen.
|
||
#[test]
|
||
fn a_list_box_click_picks_one_of_two_options_with_the_same_export_value() {
|
||
let bytes = pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /V (1) /I [1] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
]);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("dup.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "100").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
assert_eq!(selected(&h, "dup"), [1], "One-B is selected, not One-A too");
|
||
|
||
let r = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "dup").unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
let zoom = r.height() / 70.0;
|
||
// Row 0 (One-A), then row 1 (One-B): each click changes the selection though /V stays (1).
|
||
click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom));
|
||
assert_eq!(value(&h, "dup"), ["1"]);
|
||
assert_eq!(selected(&h, "dup"), [0], "clicking One-A picks One-A");
|
||
click_at(&mut h, egui::pos2(r.center().x, r.min.y + 19.0 * zoom));
|
||
assert_eq!(selected(&h, "dup"), [1], "clicking One-B picks One-B again");
|
||
click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom));
|
||
assert_eq!(selected(&h, "dup"), [0]);
|
||
|
||
let dir = scratch("duplicate-exports");
|
||
let out = dir.join("saved.pdf");
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
|
||
wait_for_file(&mut h, &out);
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("saved.pdf", None, std::fs::read(&out).unwrap()).unwrap();
|
||
let reopened = app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "dup").unwrap().clone();
|
||
assert_eq!(reopened.value, ["1"], "/V is the one export value");
|
||
assert_eq!(reopened.selected, [0], "/I keeps One-A through the save");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Same-name list boxes are one field (ISO 32000-2 §12.7.3.1), and a twin can list the options
|
||
/// sharing an export value in another order: clicking One-B in the second gives both One-B, not
|
||
/// the option at the same position in the first.
|
||
#[test]
|
||
fn a_click_in_a_twin_list_box_with_options_in_another_order_picks_the_row_clicked() {
|
||
let bytes = pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Opt [[(1) (One-B)] [(1) (One-A)] [(2) (Two)]] /Rect [50 100 200 170] /P 3 0 R /F 4 >>",
|
||
]);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("twins.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "100").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
let twins = |h: &Harness<'static, PdfCraftApp>| -> Vec<Vec<usize>> {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().form.iter().filter(|f| f.name == "dup").map(|f| f.selected.clone()).collect()
|
||
};
|
||
let r = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().filter(|f| f.name == "dup").nth(1).unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
let zoom = r.height() / 70.0;
|
||
// Row 0 of the second list box: One-B.
|
||
click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom));
|
||
assert_eq!(value(&h, "dup"), ["1"]);
|
||
assert_eq!(twins(&h), [vec![1], vec![0]], "One-B in both: second in the first, first in the twin");
|
||
// The arrows step through the focused twin's own rows: ↓ from One-B there is One-A, which is
|
||
// first in the first field, not Two (the row after One-B in the first field).
|
||
h.key_press(egui::Key::ArrowDown);
|
||
h.run_steps(3);
|
||
assert_eq!(twins(&h), [vec![0], vec![1]], "One-A in both after ↓ in the twin");
|
||
h.key_press(egui::Key::ArrowUp);
|
||
h.run_steps(3);
|
||
assert_eq!(twins(&h), [vec![1], vec![0]], "back to One-B after ↑ in the twin");
|
||
}
|
||
|
||
/// ISO 32000-2 Table 233: a list box without MultiSelect selects "at most one item". Given
|
||
/// `/V [(TW) (JP)]` it shows Taiwan alone, and a click writes the one option clicked.
|
||
#[test]
|
||
fn a_single_select_list_box_given_two_values_shows_and_takes_one() {
|
||
let bytes = pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (pair) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
]);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("pair.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "100").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
assert_eq!((value(&h, "pair"), selected(&h, "pair")), (vec!["TW".to_string()], vec![0]), "Taiwan alone");
|
||
|
||
let r = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "pair").unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
let zoom = r.height() / 70.0;
|
||
// Row 1 (Japan), which /V also named: clicking it makes it the one value.
|
||
click_at(&mut h, egui::pos2(r.center().x, r.min.y + 19.0 * zoom));
|
||
assert_eq!((value(&h, "pair"), selected(&h, "pair")), (vec!["JP".to_string()], vec![1]));
|
||
}
|
||
|
||
/// Auto-size (`0 Tf`, inherited from the AcroForm) single-line and multiline fields, and a fixed 10 pt one.
|
||
fn auto_size_form() -> Vec<u8> {
|
||
pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (short) /Rect [20 350 220 364] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (notes) /Ff 4096 /Rect [20 270 140 320] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (fixed) /DA (/Helv 10 Tf 0 g) /Rect [20 220 120 234] /P 3 0 R /F 4 >>",
|
||
])
|
||
}
|
||
|
||
fn auto_size_harness() -> Harness<'static, PdfCraftApp> {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("auto.pdf", None, auto_size_form()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
h
|
||
}
|
||
|
||
/// The on-screen size for `text` in the field, and how wide (single-line) or tall (multiline,
|
||
/// wrapped at the box) that text is in the screen font, against the room inside the box (pt).
|
||
fn on_screen_fit(h: &Harness<'static, PdfCraftApp>, name: &str, text: &str) -> (f32, f32, f32) {
|
||
let s = h.state();
|
||
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().clone();
|
||
let w = f.widgets[0].clone();
|
||
let painter = egui::Painter::new(h.ctx.clone(), egui::LayerId::background(), egui::Rect::EVERYTHING);
|
||
let size = pdfcraft_ui_egui::forms_ui::field_text_size(&painter, &f, &w, text);
|
||
let (bw, bh) = ((w.rect[2] - w.rect[0]) as f32 - 6.0, (w.rect[3] - w.rect[1]) as f32 - 6.0);
|
||
let font = egui::FontId::proportional(size);
|
||
if f.has(pdfcraft_engine::field_flags::MULTILINE) {
|
||
(size, painter.layout(text.to_string(), font, egui::Color32::BLACK, bw).size().y, bh)
|
||
} else {
|
||
(size, painter.layout_no_wrap(text.to_string(), font, egui::Color32::BLACK).size().x, bw)
|
||
}
|
||
}
|
||
|
||
/// Text typed into an auto-size field shrinks to fit the box's width, while short text keeps a
|
||
/// larger size; a multiline auto-size field wraps and shrinks only as far as the box's height
|
||
/// needs, and a fixed 10 pt field keeps its size.
|
||
#[test]
|
||
fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() {
|
||
let mut h = auto_size_harness();
|
||
let long = "Weekly progress and diligence: CoBank refi, IL and Redwood Falls";
|
||
click_field(&mut h, "short", 0);
|
||
h.event(egui::Event::Text(long.into()));
|
||
h.run_steps(2);
|
||
let typed = h.state().views[0].forms.focus.clone().expect("the editor is open").text;
|
||
assert_eq!(typed, long);
|
||
let (size, used, room) = on_screen_fit(&h, "short", &typed);
|
||
assert!(size > 4.0 && size < 8.0 / 1.15, "a long value shrinks in the short box: {size} pt");
|
||
assert!(used <= room + 0.5, "and fits its width: {used} > {room}");
|
||
let (short_size, ..) = on_screen_fit(&h, "short", "Hi");
|
||
assert!(short_size > size, "short text keeps a larger size: {short_size}");
|
||
|
||
let (size, used, room) = on_screen_fit(&h, "notes", long);
|
||
assert!(used <= room + 0.5, "multiline text wraps and shrinks to the box height: {used} > {room} at {size} pt");
|
||
assert!(size >= 6.0, "without shrinking further than it must: {size} pt");
|
||
|
||
// An explicit size is kept, as in Acrobat: the text may then run past the box.
|
||
assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0);
|
||
}
|
||
|
||
/// Auto-size fields 500 pt wide: a four-cell comb 30 pt tall, and single-line fields 100 and 400 pt tall.
|
||
fn tall_auto_size_form() -> Vec<u8> {
|
||
pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (comb) /Ff 16777216 /MaxLen 4 /Rect [50 740 550 770] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [50 600 550 700] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (huge) /Rect [50 100 550 500] /P 3 0 R /F 4 >>",
|
||
])
|
||
}
|
||
|
||
/// The editor shows auto-size text at the size the page appearance draws it: a comb field keeps
|
||
/// the appearance's 12 pt cap, and a tall single-line field follows its height, at 100% zoom
|
||
/// too, up to the editor's screen-font bound.
|
||
#[test]
|
||
fn the_field_editor_sizes_auto_size_text_as_the_page_does() {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("tall.pdf", None, tall_auto_size_form()).unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let field = |name: &str| doc.form.iter().find(|f| f.name == name).unwrap().clone();
|
||
let painter = egui::Painter::new(h.ctx.clone(), egui::LayerId::background(), egui::Rect::EVERYTHING);
|
||
let screen = |name: &str, zoom: f32| {
|
||
let f = field(name);
|
||
pdfcraft_ui_egui::forms_ui::field_screen_font(&painter, &f, &f.widgets[0], "Hi", zoom)
|
||
};
|
||
// 100%: 96 screen points per 72 page points.
|
||
let full = 96.0 / 72.0;
|
||
|
||
let comb = field("comb");
|
||
let page = pdfcraft_engine::field_comb_auto_text_size(30.0 - 6.0);
|
||
assert_eq!(page, 12.0);
|
||
assert_eq!(pdfcraft_ui_egui::forms_ui::field_text_size(&painter, &comb, &comb.widgets[0], ""), 12.0, "a comb field");
|
||
assert_eq!(screen("comb", full), 12.0 * full);
|
||
|
||
let tall = (100.0 - 6.0) / 1.15;
|
||
assert!((screen("tall", full) - tall as f32 * full).abs() < 0.01, "a 100 pt field at 100%: {}", screen("tall", full));
|
||
assert_eq!(screen("huge", full), pdfcraft_ui_egui::forms_ui::MAX_SCREEN_FONT, "past the screen-font bound");
|
||
assert_eq!(screen("huge", 0.5), ((400.0 - 6.0) / 1.15) as f32 * 0.5, "below it at 50%");
|
||
}
|
||
|
||
/// Where a mode bar tab is, by its label.
|
||
fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
|
||
use egui_kittest::kittest::Queryable;
|
||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
|
||
}
|
||
|
||
/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each
|
||
/// tab it lies under: with the All tools panel closed, Edit, Convert and E-Sign (the name field
|
||
/// starts right of All tools).
|
||
fn mode_tab_cursors(
|
||
h: &mut Harness<'static, PdfCraftApp>,
|
||
what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect,
|
||
) -> [(&'static str, egui::CursorIcon); 3] {
|
||
let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen");
|
||
let y = what(h).center().y - page(h).top();
|
||
let tabs = mode_tab(h, "Convert").center().y;
|
||
h.state_mut().views[0].goto = Some((0, 0.0));
|
||
h.run_steps(4);
|
||
let top = page(h).top();
|
||
h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height()));
|
||
h.run_steps(4);
|
||
let under = what(h);
|
||
["Edit", "Convert", "E-Sign"].map(|label| {
|
||
let at = mode_tab(h, label).center();
|
||
assert!(under.contains(at), "{under:?} lies under {label} at {at:?}");
|
||
h.hover_at(at);
|
||
h.run_steps(2);
|
||
(label, h.output().platform_output.cursor_icon)
|
||
})
|
||
}
|
||
|
||
const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 3] =
|
||
[("Edit", egui::CursorIcon::PointingHand), ("Convert", egui::CursorIcon::PointingHand), ("E-Sign", egui::CursorIcon::PointingHand)];
|
||
|
||
/// A text field scrolled up under the mode bar leaves its tabs the hand (#2755). The field gave
|
||
/// them its I-beam, and its open editor floated over them and took their clicks.
|
||
#[test]
|
||
fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() {
|
||
let mut h = harness();
|
||
// The page across the window, so the name field reaches under the tabs.
|
||
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
|
||
h.run_steps(4);
|
||
let name = |h: &Harness<'static, PdfCraftApp>| {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "name").unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
assert_eq!(mode_tab_cursors(&mut h, name), HAND_ON_EVERY_TAB, "the field under the tabs");
|
||
// Open it, where it shows.
|
||
h.state_mut().views[0].goto = Some((0, 0.0));
|
||
h.run_steps(4);
|
||
click_field(&mut h, "name", 0);
|
||
assert!(h.state().views[0].forms.focus.is_some(), "the editor opened");
|
||
assert_eq!(mode_tab_cursors(&mut h, name), HAND_ON_EVERY_TAB, "the open field under the tabs");
|
||
let convert = mode_tab(&h, "Convert").center();
|
||
click_at(&mut h, convert);
|
||
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor");
|
||
}
|
||
|
||
/// The one-page PDF `guide_fixture` attaches as `Guide.pdf`.
|
||
const GUIDE_INNER: &str = "%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";
|
||
|
||
/// A page with a push button `go`, and three attached files: a one-page PDF filed under the key
|
||
/// `Guide.pdf` (its own file name `inner.pdf`), `notes.txt`, and `fake.pdf`, which holds text.
|
||
fn guide_fixture() -> Vec<u8> {
|
||
let inner = GUIDE_INNER;
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> /Names << /EmbeddedFiles << /Names [(Guide.pdf) 5 0 R (fake.pdf) 9 0 R (notes.txt) 7 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 /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 20 120 50] /P 3 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()),
|
||
"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 8 0 R >> >>".into(),
|
||
"<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream".into(),
|
||
"<< /Type /Filespec /F (fake.pdf) /UF (fake.pdf) /EF << /F 10 0 R >> >>".into(),
|
||
"<< /Type /EmbeddedFile /Length 12 >>\nstream\nnot a PDF at\nendstream".into(),
|
||
];
|
||
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
|
||
}
|
||
|
||
/// An attachment opens in a tab only when its bytes are a PDF, whatever its name says: a
|
||
/// `fake.pdf` holding text is offered to save instead. A request goes back to the tab opened from
|
||
/// that attachment, and not to another open PDF of the same name whose bytes start with it.
|
||
#[test]
|
||
fn a_form_button_opens_only_a_real_pdf_and_returns_to_the_tab_it_opened() {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("form.pdf", None, guide_fixture()).unwrap();
|
||
// Another document named like the attachment, its bytes the attachment's and an update.
|
||
app.open_bytes("inner.pdf", None, format!("{GUIDE_INNER}\n% updated later\n").into_bytes()).unwrap();
|
||
app.active = Some(0);
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
let tabs = |h: &Harness<'static, PdfCraftApp>| h.state().views.len();
|
||
let id = h.state().views[0].id;
|
||
// A text file named .pdf is offered to save, not opened.
|
||
let saved = std::env::temp_dir().join(format!("pdfcraft-fake-{}.pdf", std::process::id()));
|
||
let _ = std::fs::remove_file(&saved);
|
||
h.state_mut().pick_override = Some(vec![saved.to_string_lossy().into_owned()]);
|
||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'fake.pdf', nLaunch: 2});");
|
||
wait_for_file(&mut h, &saved);
|
||
assert_eq!(tabs(&h), 2, "no tab for a file that isn't a PDF: {:?}", h.state().toast);
|
||
assert_eq!(std::fs::read(&saved).ok().as_deref(), Some(&b"not a PDF at"[..]), "offered to save instead");
|
||
let _ = std::fs::remove_file(&saved);
|
||
// The real attached PDF opens in a tab of its own, though another inner.pdf starts with it.
|
||
h.state_mut().active = Some(0);
|
||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});");
|
||
crate::support::wait_until(&mut h, "the attachment to open", |app| app.active == Some(2));
|
||
assert_eq!(tabs(&h), 3, "a tab of its own");
|
||
assert_eq!(h.state().active, Some(2));
|
||
// Asked again, it goes back to the tab it opened.
|
||
h.state_mut().active = Some(0);
|
||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});");
|
||
crate::support::wait_until(&mut h, "the attachment tab to activate", |app| app.active == Some(2));
|
||
assert_eq!((tabs(&h), h.state().active), (3, Some(2)), "the tab it opened, not the other inner.pdf");
|
||
}
|
||
|
||
/// A button script's `exportDataObject` opens the attached PDF in a new tab, goes back to that
|
||
/// tab when asked again, and names a missing file in a toast, a long name shortened.
|
||
#[test]
|
||
fn a_form_button_opens_its_attached_instructions_in_a_new_tab() {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("form.pdf", None, guide_fixture()).unwrap();
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
let tabs = |h: &Harness<'static, PdfCraftApp>| {
|
||
let s = h.state();
|
||
s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect::<Vec<_>>()
|
||
};
|
||
let id = h.state().views[0].id;
|
||
// By its key in the document's attached files, as forms name it.
|
||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});");
|
||
crate::support::wait_until(&mut h, "the attachment to open", |app| app.active == Some(1));
|
||
assert_eq!(tabs(&h), ["form.pdf", "inner.pdf"], "the attached PDF opens in its own tab");
|
||
// Asked again from the form, it goes back to that tab rather than opening another.
|
||
h.state_mut().active = Some(0);
|
||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});");
|
||
crate::support::wait_until(&mut h, "the attachment tab to activate", |app| app.active == Some(1));
|
||
assert_eq!(tabs(&h), ["form.pdf", "inner.pdf"], "no second copy");
|
||
assert_eq!(h.state().active, Some(1), "its tab is shown");
|
||
// A file the document doesn't have is said so.
|
||
h.state_mut().active = Some(0);
|
||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'nope.pdf', nLaunch: 2});");
|
||
crate::support::wait_until(&mut h, "the missing attachment notice", |app| app.toast.as_ref().is_some_and(|t| t.0.contains("nope.pdf")));
|
||
assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains("nope.pdf")), "{:?}", h.state().toast);
|
||
assert_eq!(tabs(&h).len(), 2);
|
||
// A name of the form's own is shortened in the message, keeping its end. (Past a line's
|
||
// length, the script engine cuts it first, as it cuts every name a script passes.)
|
||
h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: new Array(3001).join('x') + '.pdf', nLaunch: 2});");
|
||
crate::support::wait_until(&mut h, "the shortened attachment notice", |app| app.toast.as_ref().is_some_and(|t| t.0.contains("x.pdf")));
|
||
let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||
assert!(toast.contains('…') && toast.contains("x.pdf"), "{toast}");
|
||
assert!(toast.chars().count() < 200, "{} characters", toast.chars().count());
|
||
}
|
||
|
||
/// Two combo boxes of Apple and Banana: `fruit` editable (Combo and Edit, ISO 32000-2 Table 233),
|
||
/// `veg` a drop-down list only.
|
||
fn combo_form() -> Vec<u8> {
|
||
pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (fruit) /Ff 393216 /Opt [(Apple) (Banana)] /Rect [20 350 280 372] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (veg) /Ff 131072 /Opt [(Apple) (Banana)] /Rect [20 300 280 322] /P 3 0 R /F 4 >>",
|
||
])
|
||
}
|
||
|
||
fn combo_harness() -> Harness<'static, PdfCraftApp> {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("combo.pdf", None, combo_form()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
crate::support::settle(&mut h);
|
||
h
|
||
}
|
||
|
||
/// An editable combo box takes a typed value that isn't among its options, and still takes a
|
||
/// pick from its list; the typed value is what Save writes.
|
||
#[test]
|
||
fn an_editable_combo_box_takes_a_typed_value() {
|
||
let dir = scratch("editable-combo");
|
||
let out = dir.join("saved.pdf");
|
||
let mut h = combo_harness();
|
||
click_field(&mut h, "fruit", 0);
|
||
assert_eq!(focused(&h), Some(("fruit".into(), 0)));
|
||
h.event(egui::Event::Text("Cherry".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "fruit"), ["Cherry"], "Enter commits the typed value");
|
||
assert!(h.state().views[0].forms.focus.is_none(), "and closes the field");
|
||
// Reopened, it shows the typed value, and the list still picks an option.
|
||
click_field(&mut h, "fruit", 0);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some("Cherry"));
|
||
h.get_by_label("Banana").click();
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "fruit"), ["Banana"], "a pick from the list");
|
||
// Typed again, Tab commits it, and Save writes it.
|
||
click_field(&mut h, "fruit", 0);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.event(egui::Event::Text("Damson".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "fruit"), ["Damson"], "Tab commits the typed value");
|
||
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
|
||
wait_for_file(&mut h, &out);
|
||
assert_eq!(saved_value(&out, "fruit"), ["Damson"]);
|
||
// Undo takes the typing back as one step.
|
||
h.state_mut().undo();
|
||
h.run_steps(2);
|
||
assert_eq!(value(&h, "fruit"), ["Banana"]);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// #4296: an editable combo box turned a quarter (`/MK /R` 90, ISO 32000-2 Table 192) is typed
|
||
/// into as a turned text field is: a click near the field's top puts the caret at the end of the
|
||
/// text, where the text running up the field ends, not at its start.
|
||
#[test]
|
||
fn a_turned_editable_combo_box_places_the_caret_along_its_turned_text() {
|
||
let pdf = pdf_from_objects(&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (fruit) /Ff 393216 /Opt [(Apple) (Banana)] /Rect [240 20 260 170] /MK << /R 90 >> /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
]);
|
||
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("turned-combo.pdf", None, pdf.clone()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
crate::support::settle(&mut h);
|
||
click_field(&mut h, "fruit", 0);
|
||
assert_eq!(focused(&h), Some(("fruit".into(), 0)));
|
||
h.event(egui::Event::Text("Paris".into()));
|
||
h.run_steps(2);
|
||
let field = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == "fruit").unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
click_at(&mut h, field.left_top() + egui::vec2(3.0, 2.0));
|
||
h.event(egui::Event::Text("X".into()));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "fruit"), ["ParisX"]);
|
||
}
|
||
|
||
/// A combo box without the Edit flag is a drop-down list only: typing changes nothing.
|
||
#[test]
|
||
fn a_combo_box_without_edit_takes_no_typing() {
|
||
let mut h = combo_harness();
|
||
click_field(&mut h, "veg", 0);
|
||
assert_eq!(focused(&h), Some(("veg".into(), 0)));
|
||
h.event(egui::Event::Text("Cherry".into()));
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(""), "no text box took the typing");
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(4);
|
||
assert!(value(&h, "veg").is_empty(), "nothing typed reaches the field");
|
||
// Tab went on to `fruit`, whose open list covers `veg`: close it first.
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(2);
|
||
click_field(&mut h, "veg", 0);
|
||
h.get_by_label("Apple").click();
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "veg"), ["Apple"], "its list still picks");
|
||
}
|
||
|
||
/// A button script's `ShowHideFileAttachment` opens the attachments panel, and running it again
|
||
/// closes it.
|
||
#[test]
|
||
fn a_form_button_shows_and_hides_the_attachments_panel() {
|
||
let mut h = harness();
|
||
h.state_mut().set_option("panel", "none").unwrap();
|
||
h.run_steps(2);
|
||
let id = h.state().views[0].id;
|
||
h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');");
|
||
crate::support::wait_until(&mut h, "attachments panel to open", |app| app.right == Some(pdfcraft_ui_egui::RightPanel::Attachments));
|
||
// Show/Hide, as its name says: asked again, it closes.
|
||
h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');");
|
||
crate::support::wait_until(&mut h, "attachments panel to close", |app| app.right.is_none());
|
||
}
|
||
|
||
/// Which widgets of field `name` are on (their `/AS` is their on state).
|
||
fn on_widgets(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec<bool> {
|
||
let s = h.state();
|
||
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().clone();
|
||
(0..f.widgets.len()).map(|i| f.widget_on(i)).collect()
|
||
}
|
||
|
||
/// Radio groups whose buttons share an on state: `plain` (Radio | NoToggleToOff, on states Yes,
|
||
/// Yes, No), `unison` (the same with RadiosInUnison) and `free` (Radio alone, S and M).
|
||
fn shared_state_radios() -> Harness<'static, PdfCraftApp> {
|
||
let kid = |n: u32, parent: u32, x: u32, state: &str| {
|
||
format!(
|
||
"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /AS /Off /Rect [{x} {} {} {}] /P 3 0 R /F 4 /AP << /N << /{state} 5 0 R /Off 5 0 R >> >> >>",
|
||
350 - n * 40,
|
||
x + 16,
|
||
366 - n * 40
|
||
)
|
||
};
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 10 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R 11 0 R 12 0 R 13 0 R 15 0 R 16 0 R] >>".into(),
|
||
"<< >>".into(),
|
||
"<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream".into(),
|
||
"<< /FT /Btn /T (plain) /Ff 49152 /V /Off /Kids [7 0 R 8 0 R 9 0 R] >>".into(),
|
||
kid(0, 6, 20, "Yes"),
|
||
kid(0, 6, 60, "Yes"),
|
||
kid(0, 6, 100, "No"),
|
||
"<< /FT /Btn /T (unison) /Ff 33603584 /V /Off /Kids [11 0 R 12 0 R 13 0 R] >>".into(),
|
||
kid(1, 10, 20, "Yes"),
|
||
kid(1, 10, 60, "Yes"),
|
||
kid(1, 10, 100, "No"),
|
||
"<< /FT /Btn /T (free) /Ff 32768 /V /Off /Kids [15 0 R 16 0 R] >>".into(),
|
||
kid(2, 14, 20, "S"),
|
||
kid(2, 14, 60, "M"),
|
||
];
|
||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||
let bytes = pdf_from_objects(&objs);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("radios.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
h
|
||
}
|
||
|
||
/// ISO 32000-2 Table 229: without RadiosInUnison, radio buttons sharing an on state are mutually
|
||
/// exclusive, so a click turns on only the button clicked; with it they turn on together.
|
||
/// NoToggleToOff keeps a clicked selected button on; without it the click deselects it.
|
||
#[test]
|
||
fn clicking_a_radio_button_that_shares_its_on_state() {
|
||
let mut h = shared_state_radios();
|
||
click_field(&mut h, "plain", 0);
|
||
assert_eq!((on_widgets(&h, "plain"), value(&h, "plain")), (vec![true, false, false], vec!["Yes".to_string()]), "only the clicked Yes");
|
||
click_field(&mut h, "plain", 1);
|
||
assert_eq!(on_widgets(&h, "plain"), [false, true, false], "the other Yes takes over");
|
||
click_field(&mut h, "plain", 1);
|
||
assert_eq!(on_widgets(&h, "plain"), [false, true, false], "NoToggleToOff: clicking it again changes nothing");
|
||
click_field(&mut h, "plain", 2);
|
||
assert_eq!((on_widgets(&h, "plain"), value(&h, "plain")), (vec![false, false, true], vec!["No".to_string()]));
|
||
click_field(&mut h, "unison", 0);
|
||
assert_eq!(on_widgets(&h, "unison"), [true, true, false], "RadiosInUnison: both Yes buttons");
|
||
click_field(&mut h, "free", 0);
|
||
assert_eq!(value(&h, "free"), ["S"]);
|
||
click_field(&mut h, "free", 0);
|
||
assert!(value(&h, "free").is_empty(), "without NoToggleToOff a second click deselects it");
|
||
// Undo takes the last click back; the exclusive pair stays as it was.
|
||
h.state_mut().undo();
|
||
h.run_steps(2);
|
||
assert_eq!(value(&h, "free"), ["S"]);
|
||
assert_eq!(on_widgets(&h, "plain"), [false, false, true]);
|
||
}
|
||
|
||
/// A radio button read-only on its own (its widget's `/Ff`, ISO 32000-2 Table 227) is still
|
||
/// focused by the arrows and Space, but neither tries to turn it on: the form would refuse the
|
||
/// edit and show a failure for a button the user can't change. A click says it is read-only.
|
||
#[test]
|
||
fn a_read_only_radio_button_is_focused_but_not_selected() {
|
||
let kid = |x: u32, state: &str, extra: &str| {
|
||
format!(
|
||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /AS /Off {extra}/Rect [{x} 350 {} 366] /P 3 0 R /F 4 /AP << /N << /{state} 5 0 R /Off 5 0 R >> >> >>",
|
||
x + 16
|
||
)
|
||
};
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(),
|
||
"<< >>".into(),
|
||
"<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream".into(),
|
||
"<< /FT /Btn /T (size) /Ff 32768 /V /Off /Kids [7 0 R 8 0 R] >>".into(),
|
||
kid(20, "S", ""),
|
||
kid(60, "L", "/Ff 1 "),
|
||
];
|
||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||
let bytes = pdf_from_objects(&objs);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("radios.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
click_field(&mut h, "size", 0);
|
||
assert_eq!(value(&h, "size"), ["S"]);
|
||
let toast = |h: &Harness<'static, PdfCraftApp>| h.state().toast.as_ref().map(|(m, _)| m.clone());
|
||
assert_eq!(toast(&h), None);
|
||
h.key_press(egui::Key::ArrowRight);
|
||
h.run_steps(3);
|
||
assert_eq!(focused(&h), Some(("size".into(), 1)), "the arrow still moves onto the read-only button");
|
||
assert_eq!((value(&h, "size"), toast(&h)), (vec!["S".to_string()], None), "and leaves the selection alone");
|
||
h.key_press(egui::Key::Space);
|
||
h.run_steps(3);
|
||
assert_eq!((value(&h, "size"), toast(&h)), (vec!["S".to_string()], None), "Space doesn't select it");
|
||
// A click says why nothing happens, as for any read-only field, and is no failed edit.
|
||
click_field(&mut h, "size", 1);
|
||
assert_eq!((value(&h, "size"), toast(&h)), (vec!["S".to_string()], Some("size is read-only".to_string())), "nor does a click");
|
||
}
|
||
|
||
/// Check box fields of two kids each: `pair` with the on states A and B, `same` with Yes twice.
|
||
fn check_box_kids() -> Harness<'static, PdfCraftApp> {
|
||
let kid = |parent: u32, y: u32, x: u32, state: &str| {
|
||
format!(
|
||
"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /AS /Off /Rect [{x} {y} {} {}] /P 3 0 R /F 4 /AP << /N << /{state} 5 0 R /Off 5 0 R >> >> >>",
|
||
x + 16,
|
||
y + 16
|
||
)
|
||
};
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 10 0 R 11 0 R] >>".into(),
|
||
"<< >>".into(),
|
||
"<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream".into(),
|
||
"<< /FT /Btn /T (pair) /V /Off /Kids [7 0 R 8 0 R] >>".into(),
|
||
kid(6, 350, 20, "A"),
|
||
kid(6, 350, 60, "B"),
|
||
"<< /FT /Btn /T (same) /V /Off /Kids [10 0 R 11 0 R] >>".into(),
|
||
kid(9, 300, 20, "Yes"),
|
||
kid(9, 300, 60, "Yes"),
|
||
];
|
||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||
let bytes = pdf_from_objects(&objs);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("kids.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
h
|
||
}
|
||
|
||
/// ISO 32000-2 §12.7.5.2.3: a check box field's value is the on state of the kid that is on, and
|
||
/// its kids can have different on states (Table 230). Clicking B after A ticks B and clears A;
|
||
/// clicking B again clears the field; kids sharing an on state tick together (EXAMPLE 2).
|
||
#[test]
|
||
fn clicking_check_box_kids_with_different_on_states() {
|
||
let mut h = check_box_kids();
|
||
click_field(&mut h, "pair", 0);
|
||
assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()]));
|
||
click_field(&mut h, "pair", 1);
|
||
assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![false, true], vec!["B".to_string()]), "B ticks and A clears");
|
||
click_field(&mut h, "pair", 1);
|
||
assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![false, false], vec![]), "clicking B again clears the field");
|
||
click_field(&mut h, "pair", 1);
|
||
click_field(&mut h, "pair", 0);
|
||
assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()]), "the tick moves back to A");
|
||
click_field(&mut h, "same", 0);
|
||
assert_eq!(on_widgets(&h, "same"), [true, true], "kids sharing Yes tick together");
|
||
click_field(&mut h, "same", 1);
|
||
assert_eq!(on_widgets(&h, "same"), [false, false], "and untick together");
|
||
// Space on the focused kid toggles that kid too.
|
||
click_field(&mut h, "pair", 1);
|
||
assert_eq!(value(&h, "pair"), ["B"]);
|
||
h.key_press(egui::Key::Space);
|
||
h.run_steps(3);
|
||
assert!(value(&h, "pair").is_empty(), "Space clears the ticked kid");
|
||
}
|
||
|
||
/// Click at `p` with `modifiers` held.
|
||
fn click_with(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2, modifiers: egui::Modifiers) {
|
||
h.event(egui::Event::ModifiersChanged(modifiers));
|
||
h.hover_at(p);
|
||
h.run_steps(1);
|
||
for pressed in [true, false] {
|
||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers });
|
||
h.run_steps(1);
|
||
}
|
||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||
h.run_steps(3);
|
||
}
|
||
|
||
/// A list box opened at 100% zoom on its own page, and its rectangle on screen.
|
||
fn list_box(objects: &[&str], name: &str) -> (Harness<'static, PdfCraftApp>, egui::Rect) {
|
||
let bytes = pdf_from_objects(objects);
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("list.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("zoom", "100").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
let r = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
};
|
||
(h, r)
|
||
}
|
||
|
||
/// A Shift-click picks the rows from the one clicked last: after Ctrl-clicking row 3 and then
|
||
/// row 1, Shift on row 4 picks rows 1 to 4, not 3 to 4. The rows share one export value, so the
|
||
/// value can't say which came last, and `/I` lists them in ascending order.
|
||
#[test]
|
||
fn a_shift_click_in_a_list_box_extends_from_the_row_clicked_last() {
|
||
let (mut h, r) = list_box(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (days) /Ff 2097152 /Opt [[(x) (Mon)] [(x) (Tue)] [(x) (Wed)] [(x) (Thu)] [(x) (Fri)] [(x) (Sat)]] /Rect [50 220 200 320] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
],
|
||
"days",
|
||
);
|
||
let zoom = r.height() / 100.0;
|
||
let row = |k: f32| egui::pos2(r.center().x, r.min.y + (8.0 + 11.5 * k) * zoom);
|
||
click_with(&mut h, row(3.0), egui::Modifiers::COMMAND);
|
||
click_with(&mut h, row(1.0), egui::Modifiers::COMMAND);
|
||
assert_eq!(selected(&h, "days"), [1, 3]);
|
||
click_with(&mut h, row(4.0), egui::Modifiers::SHIFT);
|
||
assert_eq!(selected(&h, "days"), [1, 2, 3, 4], "from row 1, the one clicked last");
|
||
}
|
||
|
||
/// A single-select list box holding an array `/V` is written as one value when its shown row is
|
||
/// clicked again, not left as an array because the row was already selected.
|
||
#[test]
|
||
fn clicking_the_shown_row_of_a_single_select_list_holding_an_array_writes_one_value() {
|
||
let (mut h, r) = list_box(
|
||
&[
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (pair) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||
],
|
||
"pair",
|
||
);
|
||
let field = |h: &Harness<'static, PdfCraftApp>| {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "pair").unwrap().clone()
|
||
};
|
||
assert!(field(&h).single_value_is_array, "loaded from an array");
|
||
let zoom = r.height() / 70.0;
|
||
click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom));
|
||
let f = field(&h);
|
||
assert_eq!((f.value, f.selected, f.single_value_is_array), (vec!["TW".to_string()], vec![0], false), "Taiwan, now one value");
|
||
}
|
||
|
||
/// Where the first widget of field `name` is on screen in the first view.
|
||
fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen")
|
||
}
|
||
|
||
/// Filled triangles painted inside `within`, at any depth of the frame's shapes.
|
||
fn triangles_in(h: &Harness<'static, PdfCraftApp>, within: egui::Rect) -> Vec<Vec<egui::Pos2>> {
|
||
/// Collects the filled three-point paths of `shape` that lie inside `within`, descending into
|
||
/// nested shape lists.
|
||
fn walk(shape: &egui::Shape, within: egui::Rect, out: &mut Vec<Vec<egui::Pos2>>) {
|
||
match shape {
|
||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, within, out)),
|
||
egui::Shape::Path(p) if p.points.len() == 3 && p.fill != egui::Color32::TRANSPARENT && p.points.iter().all(|q| within.contains(*q)) => {
|
||
out.push(p.points.clone())
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let mut out = Vec::new();
|
||
for c in &h.output().shapes {
|
||
walk(&c.shape, within, &mut out);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A drop-down draws one filled arrow at the right of its box, and a text field draws none.
|
||
#[test]
|
||
fn a_drop_down_shows_its_arrow_and_text_fields_do_not() {
|
||
let mut h = harness();
|
||
h.run_steps(2);
|
||
let country = field_rect(&h, "country");
|
||
let arrows = triangles_in(&h, country);
|
||
assert_eq!(arrows.len(), 1, "one arrow in the drop-down: {arrows:?}");
|
||
let tip = arrows[0].iter().fold(egui::Pos2::ZERO, |a, p| egui::pos2(a.x.max(p.x), a.y.max(p.y)));
|
||
assert!(tip.x > country.center().x, "at the right of the box");
|
||
assert!(triangles_in(&h, field_rect(&h, "city")).is_empty(), "a text field has none");
|
||
}
|
||
|
||
/// A drop-down's list opens on the field's left edge and no wider than the field, without
|
||
/// showing the field's name, and clicking an item chooses it.
|
||
#[test]
|
||
fn a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "country", 0);
|
||
let country = field_rect(&h, "country");
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "country").is_none(), "the field's name isn't shown");
|
||
let item = egui_kittest::kittest::Queryable::get_by_label(&h, "France").rect();
|
||
assert!((item.left() - country.left()).abs() <= 2.0 && item.right() <= country.right() + 1.0, "{item:?} in {country:?}");
|
||
egui_kittest::kittest::Queryable::get_by_label(&h, "France").click();
|
||
h.run_steps(4);
|
||
assert_eq!(value(&h, "country"), ["fr"]);
|
||
}
|
||
|
||
/// A drop-down with 30 options keeps its scroll bar in its own room, so the items stop short
|
||
/// of the field's right edge.
|
||
#[test]
|
||
fn a_long_drop_down_list_keeps_its_scroll_bar_beside_the_items() {
|
||
let mut h = harness();
|
||
let options: Vec<String> = (0..30).map(|i| format!("{i:02}00")).collect();
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "country".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps { options: Some(options), ..Default::default() }),
|
||
}));
|
||
h.run_steps(3);
|
||
click_field(&mut h, "country", 0);
|
||
let country = field_rect(&h, "country");
|
||
let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect();
|
||
// A scroll bar that stays shown takes its own room, so people see there is more below.
|
||
assert!(first.right() <= country.right() - 6.0, "{first:?} leaves room for the bar in {country:?}");
|
||
}
|
||
|
||
/// Where `text` is drawn in the last frame, if anywhere.
|
||
fn text_rect(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option<egui::Rect> {
|
||
/// The rectangle of the text shape in `shape` that reads exactly `text`, searching nested
|
||
/// shape lists.
|
||
fn walk(shape: &egui::Shape, text: &str) -> Option<egui::Rect> {
|
||
match shape {
|
||
egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| walk(s, text)),
|
||
egui::Shape::Text(t) if t.galley.text() == text => Some(egui::Rect::from_min_size(t.pos, t.galley.size())),
|
||
_ => None,
|
||
}
|
||
}
|
||
h.output().shapes.iter().find_map(|c| walk(&c.shape, text))
|
||
}
|
||
|
||
/// Gives the "country" drop-down 30 options, "0000" to "2900", more than its list shows at once.
|
||
fn long_country(h: &mut Harness<'static, PdfCraftApp>) {
|
||
let options: Vec<String> = (0..30).map(|i| format!("{i:02}00")).collect();
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "country".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps { options: Some(options), ..Default::default() }),
|
||
}));
|
||
h.run_steps(3);
|
||
}
|
||
|
||
/// A centered drop-down (quadding 1) centers the items of its open list too.
|
||
#[test]
|
||
fn a_drop_downs_list_aligns_its_items_as_the_field_does() {
|
||
let mut h = harness();
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "country".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps { quadding: Some(1), ..Default::default() }),
|
||
}));
|
||
h.run_steps(3);
|
||
click_field(&mut h, "country", 0);
|
||
let country = field_rect(&h, "country");
|
||
let france = text_rect(&h, "France").expect("drawn");
|
||
assert!((france.center().x - country.center().x).abs() <= 2.0, "centered as the field is: {france:?} in {country:?}");
|
||
}
|
||
|
||
/// The arrow keys move through an open drop-down's list without changing the value, Enter
|
||
/// chooses the current item and closes the list, and a reopened list starts from the value.
|
||
#[test]
|
||
fn the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses() {
|
||
let mut h = harness();
|
||
click_field(&mut h, "country", 0);
|
||
h.key_press(egui::Key::ArrowDown);
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::ArrowDown);
|
||
h.run_steps(1);
|
||
assert!(value(&h, "country").is_empty(), "the arrows only move through the list");
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "country"), ["fr"]);
|
||
assert!(h.state().views[0].forms.focus.is_none(), "Enter closes the list");
|
||
// Reopened, the list starts from the field's value.
|
||
click_field(&mut h, "country", 0);
|
||
h.key_press(egui::Key::ArrowUp);
|
||
h.run_steps(1);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "country"), ["ca"]);
|
||
}
|
||
|
||
/// Pressing the scroll bar's down button moves a long drop-down list on one row, and End goes
|
||
/// to the last item, which Enter then chooses.
|
||
#[test]
|
||
fn a_long_drop_down_list_scrolls_by_its_arrow_button_and_to_its_end() {
|
||
let mut h = harness();
|
||
long_country(&mut h);
|
||
click_field(&mut h, "country", 0);
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_none(), "nine rows show");
|
||
let country = field_rect(&h, "country");
|
||
let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect();
|
||
// The bar's down button, at the bottom of the bar beside the ninth row.
|
||
let bar = country.right() - first.right();
|
||
let down = egui::pos2(country.right() - bar / 2.0, first.top() + first.height() * 9.0 - bar / 2.0);
|
||
h.hover_at(down);
|
||
h.run_steps(1);
|
||
h.drag_at(down);
|
||
h.run_steps(1);
|
||
h.drop_at(down);
|
||
h.run_steps(2);
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_some(), "one row on");
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none());
|
||
h.key_press(egui::Key::End);
|
||
h.run_steps(2);
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "2900").is_some(), "End goes to the last item");
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "country"), ["2900"]);
|
||
}
|
||
|
||
/// The mouse wheel over an open long drop-down scrolls its list and leaves the page where it was.
|
||
#[test]
|
||
fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() {
|
||
let mut h = harness();
|
||
long_country(&mut h);
|
||
click_field(&mut h, "country", 0);
|
||
let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect();
|
||
let page = h.state().views[0].page_screen_rect(0);
|
||
h.hover_at(first.center() + egui::vec2(0.0, first.height() * 4.0));
|
||
h.run_steps(1);
|
||
for _ in 0..3 {
|
||
h.event(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Line,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
delta: egui::vec2(0.0, -1.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.run_steps(2);
|
||
}
|
||
h.run_steps(6);
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "the list scrolled down");
|
||
assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was");
|
||
}
|
||
|
||
/// Escape in an open drop-down closes its list and nothing else: the find bar, which Escape
|
||
/// closes when no field is open, stays open.
|
||
#[test]
|
||
fn escape_closes_an_open_drop_downs_list_and_leaves_the_page_alone() {
|
||
let mut h = harness();
|
||
long_country(&mut h);
|
||
h.state_mut().views[0].open_find();
|
||
h.run_steps(3);
|
||
assert!(h.state().views[0].find.is_some());
|
||
click_field(&mut h, "country", 0);
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_some(), "the list is open");
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(3);
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "Escape closed the list");
|
||
assert!(h.state().views[0].forms.focus.is_none(), "and the field");
|
||
assert!(h.state().views[0].find.is_some(), "the find bar stays open");
|
||
}
|
||
|
||
/// An auto-sized drop-down's list draws its items at the size the field shows its value at, so a
|
||
/// long chosen item fits the list as it fits the field instead of growing past it.
|
||
#[test]
|
||
fn an_auto_sized_drop_downs_list_shows_its_items_at_the_fields_text_size() {
|
||
let mut h = harness();
|
||
let long = "The Commonwealth of the Northern Mariana Islands";
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
|
||
name: "country".into(),
|
||
props: Box::new(pdfcraft_engine::FieldProps { options: Some(vec!["Canada".into(), long.into()]), ..Default::default() }),
|
||
}));
|
||
assert!(
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue {
|
||
name: "country".into(),
|
||
value: pdfcraft_engine::FieldValue::Choice(vec![long.into()])
|
||
})
|
||
);
|
||
h.run_steps(3);
|
||
click_field(&mut h, "country", 0);
|
||
let field = field_rect(&h, "country");
|
||
let item = text_rect(&h, long).expect("the long item in the open list");
|
||
assert!(item.width() <= field.width(), "the item fits as the field's value does: {} wide in a {} field", item.width(), field.width());
|
||
}
|
||
|
||
/// In single-page view, where the wheel turns pages, the wheel over an open drop-down's list
|
||
/// scrolls the list and leaves the page as it is; over the page itself it still turns it.
|
||
#[test]
|
||
fn in_single_page_view_the_wheel_over_an_open_list_scrolls_the_list_not_the_page() {
|
||
let mut h = harness();
|
||
long_country(&mut h);
|
||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 300.0, height: 400.0 }));
|
||
h.state_mut().set_option("continuous", "off").unwrap();
|
||
h.key_press(egui::Key::Home);
|
||
for _ in 0..60 {
|
||
h.run_steps(2);
|
||
if !h.state().render_pending() && h.state().views[0].page_screen_rect(0).is_some() {
|
||
break;
|
||
}
|
||
}
|
||
let wheel = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| {
|
||
h.hover_at(at);
|
||
h.run_steps(1);
|
||
for _ in 0..3 {
|
||
h.event(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Line,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
delta: egui::vec2(0.0, -1.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
h.run_steps(2);
|
||
}
|
||
h.run_steps(6);
|
||
};
|
||
// The control: over the page, the wheel turns it.
|
||
let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||
wheel(&mut h, page.center());
|
||
assert_eq!(h.state().views[0].current, 1, "over the page, the wheel turns it");
|
||
h.key_press(egui::Key::Home);
|
||
h.run_steps(6);
|
||
assert_eq!(h.state().views[0].current, 0);
|
||
// Over the open list, it scrolls the list instead.
|
||
click_field(&mut h, "country", 0);
|
||
let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect();
|
||
wheel(&mut h, first.center() + egui::vec2(0.0, first.height() * 4.0));
|
||
assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "the list scrolled down");
|
||
assert_eq!(h.state().views[0].current, 0, "the page didn't turn");
|
||
}
|
||
|
||
/// With a page taller than the window and the pointer resting on an open drop-down's list,
|
||
/// Home and the down arrow move through the list and leave the page where it was.
|
||
#[test]
|
||
fn an_open_drop_down_keeps_the_arrow_keys_and_home_from_moving_the_page() {
|
||
let mut h = harness();
|
||
long_country(&mut h);
|
||
// A page taller than the window, so the keys would scroll it.
|
||
h.state_mut().set_option("zoom", "300").unwrap();
|
||
for _ in 0..30 {
|
||
h.run_steps(2);
|
||
if !h.state().render_pending() {
|
||
break;
|
||
}
|
||
}
|
||
click_field(&mut h, "country", 0);
|
||
// The pointer rests on the list, as it does after reaching for it.
|
||
let row = egui_kittest::kittest::Queryable::get_by_label(&h, "0300").rect();
|
||
h.hover_at(row.center());
|
||
h.run_steps(2);
|
||
let page = h.state().views[0].page_screen_rect(0);
|
||
h.key_press(egui::Key::Home);
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::ArrowDown);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was");
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "country"), ["0100"], "one item down from the first");
|
||
}
|
||
|
||
/// When End scrolls a drop-down's list under a resting pointer, Enter chooses the item End
|
||
/// went to, not the one that came under the pointer.
|
||
#[test]
|
||
fn a_pointer_resting_on_a_list_the_keys_scroll_keeps_the_highlight_on_the_keys() {
|
||
let mut h = harness();
|
||
long_country(&mut h);
|
||
click_field(&mut h, "country", 0);
|
||
let row = egui_kittest::kittest::Queryable::get_by_label(&h, "0300").rect();
|
||
h.hover_at(row.center());
|
||
h.run_steps(2);
|
||
// End scrolls the list, so another item comes under the resting pointer.
|
||
h.key_press(egui::Key::End);
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(3);
|
||
assert_eq!(value(&h, "country"), ["2900"], "the item End went to, not the one under the pointer");
|
||
}
|