nigig-org/crates/apps/pdf/pdf-makepad/tests/phase4_exit_criterion.rs
andodeki 8bc94253ff feat(pdf): complete Phase 4 with an interactive page widget
Phase 4 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md. The previous
commit added the interaction logic but left it an orphan: nothing routed
Makepad events into it, which is the exact defect the review names when it
says PdfPageView declares is_interactive() -> false and an empty
handle_event.

New pdf-makepad/src/page_view.rs, PdfPageWidget:
- is_interactive() returns true and handle_event routes real events.
- Step 4.1 ordering: form field events, then annotation hit testing, then
  text selection. The order is enforced inside InteractionState::click so
  it cannot drift between the widget and the tests.
- Step 4.2: link underlines and form widget borders are drawn, and the
  cursor changes over links and text fields.
- Step 4.3: field values draw inside their widget rects, a focused field is
  highlighted and shows its uncommitted buffer.
- Step 4.4: navigation is emitted as PdfPageAction to the host. The widget
  never opens a URL (rule 5).
- Key focus loss commits the pending edit, so an edit is never silently
  lost, while Escape still abandons it.
- Only keys the editor handles are consumed; everything else falls through
  to the host instead of being swallowed.

The widget keeps no decision logic of its own: it syncs the viewport from
its on-screen rect and delegates. That keeps the part that needs a live Cx
as small as possible, because it is the part that cannot be unit tested.

Also adds Selection to interaction.rs so drag-to-select and copy read
through the Phase 5 PageText API rather than re-deriving positions.

Tests: new tests/phase4_exit_criterion.rs drives the exit criterion against
the real two-page AcroForm fixture, not synthetic dictionaries. Click a link
gives OpenUri; click a field, type, commit, and the value changes, the
appearance regenerates with the new text and the draw list shows it. It also
asserts an abandoned edit changes nothing, the checkbox uses the /On state
declared in the file, and form fields route before annotations.

Validation: TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh
202 tests pass; rustfmt and clippy -D warnings clean.
2026-07-27 16:36:34 +00:00

285 lines
9.7 KiB
Rust

//! Phase 4 exit criterion, end to end against the real AcroForm fixture.
//!
//! > Click a link in a real PDF → host receives `OpenUri`. Click a text field
//! > → type text → field value updates → appearance regenerates → visual
//! > change visible.
//!
//! These tests drive the same `InteractionState` the widget's `handle_event`
//! delegates to, using a document parsed from bytes on disk rather than
//! synthetic dictionaries. What the widget adds on top is Makepad event
//! delivery, which needs a live `Cx`; the routing *order* it depends on is
//! asserted here so the untested surface stays as small as possible.
use nigig_pdf_document::appearance::regenerate_dirty;
use nigig_pdf_document::form::FieldValue;
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::content::parse_content_stream;
use nigig_pdf_graphics::recording::RecordingDevice;
use nigig_pdf_graphics::text::PageText;
use nigig_pdf_makepad::interaction::{
render_fields, HoverTarget, InteractionState, KeyInput, PdfAction, Viewport,
};
use nigig_pdf_makepad::page_view::{cursor_for, key_input_from_code};
/// The two-page AcroForm fixture added for Phase 3. Everything interactive
/// is on page index 1, so any code defaulting to page 0 fails here.
const FIXTURE: &[u8] = include_bytes!("../../pdf-document/tests/acroform.pdf");
const PAGE: usize = 1;
fn viewport() -> Viewport {
Viewport {
origin_x: 0.0,
origin_y: 0.0,
zoom: 1.0,
page_height: 792.0,
}
}
fn state() -> InteractionState {
InteractionState::new(PAGE, viewport())
}
/// Screen position of a PDF-space point under the test viewport.
fn screen(x: f64, y: f64) -> (f64, f64) {
viewport().to_screen(x, y)
}
#[test]
fn clicking_a_link_in_a_real_pdf_emits_open_uri_to_the_host() {
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
let annots = doc.page_annotations(PAGE).expect("annotations");
let mut state = state();
// The fixture's link occupies 100,500 to 200,520.
let (sx, sy) = screen(150.0, 510.0);
let action = state.click(sx, sy, &annots, None);
assert_eq!(
action,
Some(PdfAction::OpenUri("https://example.org/".into())),
"the viewer reports the intent; the host opens the URL"
);
}
#[test]
fn the_full_edit_cycle_updates_the_value_and_regenerates_the_appearance() {
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
let annots = doc.page_annotations(PAGE).expect("annotations");
let mut form = doc.acroform().expect("acroform").expect("present");
let field_ref = form.find_field("personal.name").expect("field").obj_ref;
let mut state = state();
// 1. Click the text field. Its widget is 100,600 to 300,620.
let (sx, sy) = screen(200.0, 610.0);
let action = state.click(sx, sy, &annots, Some(&mut form));
assert_eq!(action, None, "focusing a field is not itself a host action");
assert_eq!(state.focus.field, Some(field_ref));
assert_eq!(state.focus.buffer, "John", "the existing value is loaded");
// 2. Type: clear the field, then enter a new value.
for _ in 0..4 {
state.key(KeyInput::Backspace, &mut form);
}
for c in "Jane".chars() {
state.key(KeyInput::Char(c), &mut form);
}
// 3. The document must not have changed yet.
assert_eq!(
form.field(field_ref).expect("field").value,
FieldValue::Text("John".into()),
"typing must not mutate the document before it is committed"
);
// 4. Commit.
let action = state.key(KeyInput::Enter, &mut form);
assert_eq!(
action,
Some(PdfAction::FieldChanged {
field: field_ref,
invalidated_pages: vec![PAGE],
}),
"the host is told which pages to repaint"
);
assert_eq!(
form.field(field_ref).expect("field").value,
FieldValue::Text("Jane".into())
);
// 5. The appearance regenerates with the new value.
let (generated, errors) = regenerate_dirty(&form);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(generated.len(), 1);
let content = generated[0].1.content_str();
assert!(content.contains("(Jane) Tj"), "got: {content}");
assert!(!content.contains("(John)"), "the stale value must be gone");
// 6. The visual change is observable through the draw list.
let renders = render_fields(&form, PAGE, &viewport(), &state.focus);
let drawn = renders
.iter()
.find(|r| r.text == "Jane")
.expect("the new value must appear in the draw list");
assert!(drawn.rect[2] > 0.0 && drawn.rect[3] > 0.0);
}
#[test]
fn an_abandoned_edit_leaves_the_real_document_untouched() {
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
let mut form = doc.acroform().expect("acroform").expect("present");
let field_ref = form.find_field("personal.name").expect("field").obj_ref;
let mut state = state();
let (sx, sy) = screen(200.0, 610.0);
state.click(sx, sy, &[], Some(&mut form));
for c in "XYZ".chars() {
state.key(KeyInput::Char(c), &mut form);
}
state.key(KeyInput::Escape, &mut form);
assert_eq!(
form.field(field_ref).expect("field").value,
FieldValue::Text("John".into())
);
assert!(!form.field(field_ref).expect("field").dirty);
let (generated, _) = regenerate_dirty(&form);
assert!(
generated.is_empty(),
"nothing is dirty, so nothing regenerates"
);
}
#[test]
fn clicking_the_checkbox_uses_the_state_declared_in_the_file() {
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
let mut form = doc.acroform().expect("acroform").expect("present");
let field_ref = form.find_field("agree").expect("checkbox").obj_ref;
let mut state = state();
// The fixture's checkbox is 100,560 to 114,574 with /On and /Off states.
let (sx, sy) = screen(107.0, 567.0);
let action = state.click(sx, sy, &[], Some(&mut form));
assert!(matches!(action, Some(PdfAction::FieldChanged { .. })));
assert_eq!(
form.field(field_ref).expect("field").value,
FieldValue::State("On".into()),
"the on state comes from the widget's /AP, not an assumed /Yes"
);
assert!(form.field(field_ref).expect("field").is_checked());
}
#[test]
fn hovering_reports_the_cursor_the_host_should_show() {
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
let annots = doc.page_annotations(PAGE).expect("annotations");
let form = doc.acroform().expect("acroform").expect("present");
let state = state();
let (fx, fy) = screen(200.0, 610.0);
assert_eq!(
state.hover_at(fx, fy, &annots, Some(&form)),
HoverTarget::TextField
);
let (lx, ly) = screen(150.0, 510.0);
assert_eq!(
state.hover_at(lx, ly, &annots, Some(&form)),
HoverTarget::Link
);
let (ex, ey) = screen(450.0, 300.0);
assert_eq!(
state.hover_at(ex, ey, &annots, Some(&form)),
HoverTarget::None
);
// And the widget layer turns those into real cursors.
assert_ne!(
cursor_for(HoverTarget::Link),
cursor_for(HoverTarget::TextField)
);
}
/// The review's step 4.1 lists form-field events before annotation hit
/// testing. The fixture has a field and a link on the same page, so the
/// ordering is observable.
#[test]
fn form_fields_are_routed_before_annotations() {
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
let annots = doc.page_annotations(PAGE).expect("annotations");
let mut form = doc.acroform().expect("acroform").expect("present");
let mut state = state();
// Click the field: no navigation action, focus taken instead.
let (fx, fy) = screen(200.0, 610.0);
assert_eq!(state.click(fx, fy, &annots, Some(&mut form)), None);
assert!(state.focus.is_focused());
// Click the link: focus is dropped and the action is emitted.
let (lx, ly) = screen(150.0, 510.0);
let action = state.click(lx, ly, &annots, Some(&mut form));
assert_eq!(
action,
Some(PdfAction::OpenUri("https://example.org/".into()))
);
assert!(!state.focus.is_focused(), "clicking away drops focus");
}
#[test]
fn text_selection_reads_through_the_extracted_page_text() {
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
let page = doc.page(PAGE).expect("page");
let ops = parse_content_stream(&page.content_data).expect("content parses");
let commands = RecordingDevice::from_ops(&ops);
let page_text = PageText::from_commands(&commands);
assert!(
page_text.plain_text().contains("Page two"),
"extracted text was {:?}",
page_text.plain_text()
);
let mut state = state();
assert_eq!(
state.selected_text(&page_text),
"",
"an empty selection yields no text"
);
let (ax, ay) = screen(0.0, 0.0);
state.begin_selection(ax, ay);
assert!(state.selection.active);
state.end_selection();
assert!(!state.selection.active);
}
#[test]
fn every_key_the_widget_forwards_is_one_the_editor_handles() {
// The widget only forwards keys that map to a KeyInput; anything else
// must fall through to the host rather than being swallowed.
use makepad_widgets::KeyCode;
for code in [
KeyCode::Backspace,
KeyCode::Delete,
KeyCode::ArrowLeft,
KeyCode::ArrowRight,
KeyCode::Home,
KeyCode::End,
KeyCode::ReturnKey,
KeyCode::Escape,
KeyCode::Tab,
] {
assert!(
key_input_from_code(code).is_some(),
"{code:?} should be handled"
);
}
for code in [KeyCode::F5, KeyCode::ArrowUp, KeyCode::KeyZ] {
assert!(
key_input_from_code(code).is_none(),
"{code:?} must fall through to the host"
);
}
}